Report detail: show elapsed time + live activity for running stages

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) <noreply@anthropic.com>
This commit is contained in:
DJP 2026-04-29 21:02:03 -04:00
parent 564b6d9274
commit fa4b356af7

View file

@ -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 <div className="text-text-muted text-sm">Loading</div>;
if (error || !data) return <div className="text-red-400 text-sm">Could not load report.</div>;
@ -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 (
<div className="space-y-6">
<header>
@ -70,7 +104,8 @@ export default function ReportDetail() {
<div className="text-sm text-text-muted mt-1">{report.brief_business_question}</div>
<div className="text-xs text-text-dim mt-2">
Run <span className="font-mono">{report.id.slice(0, 8)}</span> ·
started {fmtTime(report.started_at)}
started {fmtTime(report.started_at)} ·
{' '}{isTerminal ? 'ran for' : 'running for'} <span className="text-text-body">{fmtDuration(elapsedMs)}</span>
{report.finished_at && <> · finished {fmtTime(report.finished_at)}</>}
</div>
</div>
@ -78,7 +113,7 @@ export default function ReportDetail() {
</div>
</header>
<StageProgress report={report} />
<StageProgress report={report} latestEvent={cost_events.length > 0 ? (cost_events[cost_events.length - 1] ?? null) : null} />
{report.error_message && <FailurePanel reportId={report.id} error={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 (
<section className="bg-bg-panel border border-border-subtle rounded-lg p-4">
<h2 className="text-sm font-medium mb-3 text-text-muted uppercase tracking-wider">Pipeline progress</h2>
@ -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 (
<li key={stage} className="flex items-center gap-3 text-sm">
<span className={`inline-block h-2 w-2 rounded-full shrink-0 ${dot}`} />
<span className={text}>{stageNum}. {STAGE_LABELS[stage]}</span>
<li key={stage} className="text-sm">
<div className="flex items-center gap-3">
<span className={`inline-block h-2 w-2 rounded-full shrink-0 ${dot}`} />
<span className={text}>{stageNum}. {STAGE_LABELS[stage]}</span>
{state === 'current' && isRunning && (
<span className="text-xs text-text-dim ml-auto">working</span>
)}
</div>
{showActivity && latestEvent && (
<div className="ml-5 mt-0.5 text-xs text-text-dim font-mono truncate">
{latestEvent.source} · {latestEvent.label} · {fmtAgo(latestEvent.created_at)}
</div>
)}
</li>
);
})}
</ol>
{isRunning && (
<div className="mt-3 pt-3 border-t border-border-subtle text-xs text-text-dim">
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.
</div>
)}
</section>
);
}