From fa4b356af75bb808f48ff0335c3a3beaa929e6fb Mon Sep 17 00:00:00 2001 From: DJP Date: Wed, 29 Apr 2026 21:02:03 -0400 Subject: [PATCH] Report detail: show elapsed time + live activity for running stages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stage 2 hashtag scrapes can take 1-3 min each, and the run page gave no sign of life between cost events — the active stage just had a pulsing dot. Now shows the elapsed run time in the header, a 'working…' marker on the active stage, the latest cost event under it (e.g. 'apify · hashtag:#hairtok · 30s ago'), and a hint that long Apify silences are mid-flight scrapes, not stalls. Co-Authored-By: Claude Opus 4.7 (1M context) --- v2/operator-app/src/routes/reports/detail.tsx | 67 +++++++++++++++++-- 1 file changed, 60 insertions(+), 7 deletions(-) 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. +
    + )}
    ); }