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:
gsinghpal
2026-05-24 19:18:49 -04:00
parent eed1c4619d
commit 0371624afb
4 changed files with 85 additions and 2 deletions

View File

@@ -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.',

View File

@@ -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;

View File

@@ -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; }
}

View File

@@ -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>