diff --git a/v2/operator-app/src/routes/reports/detail.tsx b/v2/operator-app/src/routes/reports/detail.tsx
index b6dd8e2..07ad11f 100644
--- a/v2/operator-app/src/routes/reports/detail.tsx
+++ b/v2/operator-app/src/routes/reports/detail.tsx
@@ -1,4 +1,4 @@
-import { useState } from 'react';
+import { useEffect, useState } from 'react';
import { useParams } from 'react-router-dom';
import {
useReport, useQaSignoff, useBuildReport, useRetryReport,
@@ -50,9 +50,40 @@ function fmtTime(iso: string | null): string {
return new Date(iso).toLocaleString();
}
+function fmtDuration(ms: number): string {
+ const s = Math.floor(ms / 1000);
+ if (s < 60) return `${s}s`;
+ const m = Math.floor(s / 60);
+ const rem = s % 60;
+ if (m < 60) return `${m}m ${rem}s`;
+ const h = Math.floor(m / 60);
+ return `${h}h ${m % 60}m`;
+}
+
+function fmtAgo(iso: string): string {
+ const ms = Date.now() - new Date(iso).getTime();
+ const s = Math.floor(ms / 1000);
+ if (s < 60) return `${s}s ago`;
+ const m = Math.floor(s / 60);
+ if (m < 60) return `${m}m ago`;
+ const h = Math.floor(m / 60);
+ return `${h}h ago`;
+}
+
+function useTick(intervalMs = 1000) {
+ const [, setT] = useState(0);
+ useEffect(() => {
+ const id = setInterval(() => setT((n) => n + 1), intervalMs);
+ return () => clearInterval(id);
+ }, [intervalMs]);
+}
+
export default function ReportDetail() {
const { id } = useParams();
const { data, isLoading, error } = useReport(id);
+ // Tick the wall clock so elapsed-time + last-activity-ago refresh while running.
+ // Must run unconditionally before any early returns (Rules of Hooks).
+ useTick(1000);
if (isLoading) return
Loading…
;
if (error || !data) return Could not load report.
;
@@ -61,6 +92,9 @@ export default function ReportDetail() {
const isTerminal = TERMINAL_STATUSES.includes(report.status);
const showSignoffPanel = report.status === 'qa' || (qa && (qa.cm_signoff || qa.strategist_signoff));
+ const elapsedMs = (report.finished_at ? new Date(report.finished_at).getTime() : Date.now())
+ - new Date(report.started_at).getTime();
+
return (
@@ -70,7 +104,8 @@ export default function ReportDetail() {
{report.brief_business_question}
Run {report.id.slice(0, 8)} ·
- started {fmtTime(report.started_at)}
+ started {fmtTime(report.started_at)} ·
+ {' '}{isTerminal ? 'ran for' : 'running for'} {fmtDuration(elapsedMs)}
{report.finished_at && <> · finished {fmtTime(report.finished_at)}>}
@@ -78,7 +113,7 @@ export default function ReportDetail() {
-
+ 0 ? (cost_events[cost_events.length - 1] ?? null) : null} />
{report.error_message && }
@@ -101,7 +136,8 @@ export default function ReportDetail() {
);
}
-function StageProgress({ report }: { report: Report }) {
+function StageProgress({ report, latestEvent }: { report: Report; latestEvent: CostEvent | null }) {
+ const isRunning = !TERMINAL_STATUSES.includes(report.status);
return (
Pipeline progress
@@ -123,14 +159,31 @@ function StageProgress({ report }: { report: Report }) {
state === 'done' ? 'text-text-body' :
state === 'current' ? 'text-accent' :
'text-text-dim';
+ const showActivity = state === 'current' && isRunning && latestEvent && latestEvent.stage === stageNum;
return (
-
-
- {stageNum}. {STAGE_LABELS[stage]}
+
+
+
+ {stageNum}. {STAGE_LABELS[stage]}
+ {state === 'current' && isRunning && (
+ working…
+ )}
+
+ {showActivity && latestEvent && (
+
+ ↳ {latestEvent.source} · {latestEvent.label} · {fmtAgo(latestEvent.created_at)}
+
+ )}
);
})}
+ {isRunning && (
+
+ Stages 2 + 4 (Apify scrapes) can take 1-3 minutes per hashtag/profile/video. Each finished scrape
+ appears below as a cost event — silence in the log without an error means a scrape is mid-flight.
+
+ )}
);
}