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',
|
'name': 'Fusion Plating — Shop Floor',
|
||||||
'version': '19.0.33.1.6',
|
'version': '19.0.33.1.7',
|
||||||
'category': 'Manufacturing/Plating',
|
'category': 'Manufacturing/Plating',
|
||||||
'summary': 'Shop-floor tablet stations, QR scanning, bake window enforcer, '
|
'summary': 'Shop-floor tablet stations, QR scanning, bake window enforcer, '
|
||||||
'first-piece inspection gates.',
|
'first-piece inspection gates.',
|
||||||
|
|||||||
@@ -45,6 +45,10 @@ export class FpJobWorkspace extends Component {
|
|||||||
data: null,
|
data: null,
|
||||||
jobId: null,
|
jobId: null,
|
||||||
focusStepId: 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 () => {
|
onMounted(async () => {
|
||||||
@@ -53,13 +57,53 @@ export class FpJobWorkspace extends Component {
|
|||||||
this.state.focusStepId = params.focus_step_id || null;
|
this.state.focusStepId = params.focus_step_id || null;
|
||||||
await this.refresh();
|
await this.refresh();
|
||||||
this._refreshInterval = setInterval(() => this.refresh(), 15000);
|
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(() => {
|
onWillUnmount(() => {
|
||||||
if (this._refreshInterval) clearInterval(this._refreshInterval);
|
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 ------------------------------------------------------
|
// ---- Data refresh ------------------------------------------------------
|
||||||
async refresh() {
|
async refresh() {
|
||||||
if (!this.state.jobId) return;
|
if (!this.state.jobId) return;
|
||||||
|
|||||||
@@ -632,3 +632,34 @@ $_ws-text-hex: #1d1d1f;
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: 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' : '')"
|
(step.blocker_kind !== 'none' ? ' blocked' : '')"
|
||||||
t-att-data-step-id="step.id">
|
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">
|
<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_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_num">Step <t t-esc="step.sequence_display"/></span>
|
||||||
<span class="o_fp_ws_step_name" t-esc="step.name"/>
|
<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 === '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>
|
<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">
|
<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.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>
|
<t t-if="step.duration_actual"> · <t t-esc="Math.round(step.duration_actual)"/> min</t>
|
||||||
|
|||||||
Reference in New Issue
Block a user