feat(workspace): live HH:MM:SS timer on active step
Pure client-side tick — 1s setInterval bumps state.tickNow which the template reads via formatActiveStepElapsed(step). No RPC per tick. Reads step.date_started_iso (UTC) from the existing payload, parses to ms, displays elapsed since. - Green pill (#d1fae5 bg, monospace tabular-nums) on the ACTIVE badge - Flips red (#fee2e2 + pulse animation) when elapsed > 1.5x duration_expected — visual cue for the operator that the step is running long against the recipe target Cleanup interval on onWillUnmount alongside the existing 15s refresh interval. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -5,7 +5,7 @@
|
||||
|
||||
{
|
||||
'name': 'Fusion Plating — Shop Floor',
|
||||
'version': '19.0.33.1.6',
|
||||
'version': '19.0.33.1.7',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Shop-floor tablet stations, QR scanning, bake window enforcer, '
|
||||
'first-piece inspection gates.',
|
||||
|
||||
@@ -45,6 +45,10 @@ export class FpJobWorkspace extends Component {
|
||||
data: null,
|
||||
jobId: null,
|
||||
focusStepId: null,
|
||||
// Reactive monotonic tick — bumped every 1s by _tickInterval so
|
||||
// the active-step timer re-renders without an RPC. The template
|
||||
// reads tickNow and re-runs formatActiveStepElapsed each second.
|
||||
tickNow: Date.now(),
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
@@ -53,13 +57,53 @@ export class FpJobWorkspace extends Component {
|
||||
this.state.focusStepId = params.focus_step_id || null;
|
||||
await this.refresh();
|
||||
this._refreshInterval = setInterval(() => this.refresh(), 15000);
|
||||
// 1s tick — pure client-side; no RPC. Drives the live timer
|
||||
// on the active step's badge area.
|
||||
this._tickInterval = setInterval(() => {
|
||||
this.state.tickNow = Date.now();
|
||||
}, 1000);
|
||||
});
|
||||
|
||||
onWillUnmount(() => {
|
||||
if (this._refreshInterval) clearInterval(this._refreshInterval);
|
||||
if (this._tickInterval) clearInterval(this._tickInterval);
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Live timer helpers (Spec 2026-05-24 follow-up) -------------------
|
||||
// formatActiveStepElapsed reads step.date_started_iso (UTC string from
|
||||
// the payload), parses to ms, and returns HH:MM:SS elapsed since.
|
||||
// Returns '' when no start time. Re-runs every 1s via state.tickNow.
|
||||
|
||||
formatActiveStepElapsed(step) {
|
||||
if (!step || !step.date_started_iso) return "";
|
||||
// Parse "YYYY-MM-DD HH:MM:SS" as UTC (controller uses fp_format which
|
||||
// formats in UTC by default for ISO-style strings).
|
||||
const isoUtc = step.date_started_iso.replace(" ", "T") + "Z";
|
||||
const startedMs = Date.parse(isoUtc);
|
||||
if (!startedMs || isNaN(startedMs)) return "";
|
||||
// touch state.tickNow so OWL re-evaluates this getter every tick
|
||||
const now = this.state.tickNow || Date.now();
|
||||
const totalSec = Math.max(0, Math.floor((now - startedMs) / 1000));
|
||||
const h = Math.floor(totalSec / 3600);
|
||||
const m = Math.floor((totalSec % 3600) / 60);
|
||||
const s = totalSec % 60;
|
||||
const pad = (n) => (n < 10 ? "0" + n : "" + n);
|
||||
return pad(h) + ":" + pad(m) + ":" + pad(s);
|
||||
}
|
||||
|
||||
isActiveStepOvertime(step) {
|
||||
// True when elapsed > 1.5× expected duration (drives red colour).
|
||||
// duration_expected is in minutes.
|
||||
if (!step || !step.date_started_iso || !step.duration_expected) return false;
|
||||
const isoUtc = step.date_started_iso.replace(" ", "T") + "Z";
|
||||
const startedMs = Date.parse(isoUtc);
|
||||
if (!startedMs || isNaN(startedMs)) return false;
|
||||
const now = this.state.tickNow || Date.now();
|
||||
const elapsedMin = (now - startedMs) / 1000 / 60;
|
||||
return elapsedMin > step.duration_expected * 1.5;
|
||||
}
|
||||
|
||||
// ---- Data refresh ------------------------------------------------------
|
||||
async refresh() {
|
||||
if (!this.state.jobId) return;
|
||||
|
||||
@@ -632,3 +632,34 @@ $_ws-text-hex: #1d1d1f;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// LIVE TIMER on active step (2026-05-24 follow-up)
|
||||
// Ticks every 1s via state.tickNow; flips red when > 1.5x expected duration.
|
||||
// =============================================================================
|
||||
|
||||
.o_fp_ws_step_timer {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
background: #d1fae5;
|
||||
color: #064e3b;
|
||||
padding: 0.2rem 0.6rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.95rem;
|
||||
margin-left: 0.4rem;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.o_fp_ws_step_timer_over {
|
||||
background: #fee2e2;
|
||||
color: #7f1d1d;
|
||||
animation: o_fp_ws_timer_pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes o_fp_ws_timer_pulse {
|
||||
0%, 100% { opacity: 1.0; }
|
||||
50% { opacity: 0.6; }
|
||||
}
|
||||
|
||||
@@ -226,13 +226,21 @@
|
||||
(step.blocker_kind !== 'none' ? ' blocked' : '')"
|
||||
t-att-data-step-id="step.id">
|
||||
|
||||
<!-- ALWAYS visible: line 1 (icon + step# + name + badges + meta) -->
|
||||
<!-- ALWAYS visible: line 1 (icon + step# + name + badges + live timer + meta) -->
|
||||
<div class="o_fp_ws_step_l1">
|
||||
<span class="o_fp_ws_step_icon" t-esc="iconForStepState(step.state)"/>
|
||||
<span class="o_fp_ws_step_num">Step <t t-esc="step.sequence_display"/></span>
|
||||
<span class="o_fp_ws_step_name" t-esc="step.name"/>
|
||||
<span t-if="step.state === 'in_progress'" class="o_fp_ws_step_badge">ACTIVE</span>
|
||||
<span t-if="step.state === 'paused'" class="o_fp_ws_step_badge o_fp_ws_step_badge_paused">PAUSED</span>
|
||||
<!-- Live ticking HH:MM:SS timer for in_progress steps.
|
||||
Re-renders every 1s via state.tickNow.
|
||||
Flips red when > 1.5x expected duration. -->
|
||||
<span t-if="step.state === 'in_progress' and step.date_started_iso"
|
||||
t-att-class="'o_fp_ws_step_timer' + (isActiveStepOvertime(step) ? ' o_fp_ws_step_timer_over' : '')">
|
||||
<i class="fa fa-clock-o me-1"/>
|
||||
<t t-esc="formatActiveStepElapsed(step)"/>
|
||||
</span>
|
||||
<span class="o_fp_ws_step_meta">
|
||||
<t t-if="step.assigned_user_name"><t t-esc="step.assigned_user_name"/></t>
|
||||
<t t-if="step.duration_actual"> · <t t-esc="Math.round(step.duration_actual)"/> min</t>
|
||||
|
||||
Reference in New Issue
Block a user