diff --git a/frontend/src/shell/AppShell.tsx b/frontend/src/shell/AppShell.tsx index 04c1c9e2..9949d327 100644 --- a/frontend/src/shell/AppShell.tsx +++ b/frontend/src/shell/AppShell.tsx @@ -20,7 +20,7 @@ import { } from "../api/sessions"; import type { SessionSummary } from "../api/types"; import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { useMemo, useRef, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; /** * The page is designed to live INSIDE the Omics Portal chrome (its left sidebar @@ -55,6 +55,12 @@ export function AppShell() { const refresh = () => queryClient.invalidateQueries({ queryKey: ["sessions"] }); const activeSession = sessions.find((s) => s.id === activeSessionId) ?? null; const finalized = activeSession?.status === "finalized"; + const [localStart, setLocalStart] = useState(null); + useEffect(() => { + if (activeSessionId && !activeSession) setLocalStart((prev) => prev ?? new Date().toISOString()); + else if (!activeSessionId) setLocalStart(null); + }, [activeSessionId, activeSession]); + const startedAt = activeSession?.created_at ?? localStart; function openPanel(id: string) { const s = sessions.find((x) => x.id === id); @@ -171,7 +177,11 @@ export function AppShell() { {activeSessionId && (
- +
)} diff --git a/frontend/src/shell/ElapsedTimer.test.tsx b/frontend/src/shell/ElapsedTimer.test.tsx new file mode 100644 index 00000000..be240639 --- /dev/null +++ b/frontend/src/shell/ElapsedTimer.test.tsx @@ -0,0 +1,17 @@ +import { render, screen } from "@testing-library/react"; +import { ElapsedTimer } from "./ElapsedTimer"; + +test("renders nothing without a start time", () => { + const { container } = render(); + expect(container).toBeEmptyDOMElement(); +}); + +test("frozen elapsed when stoppedAt is set (2m 5s)", () => { + render( + , + ); + expect(screen.getByText("2m 5s")).toBeInTheDocument(); +}); diff --git a/frontend/src/shell/ElapsedTimer.tsx b/frontend/src/shell/ElapsedTimer.tsx new file mode 100644 index 00000000..f1a040e1 --- /dev/null +++ b/frontend/src/shell/ElapsedTimer.tsx @@ -0,0 +1,40 @@ +import { useEffect, useState } from "react"; + +function fmt(ms: number): string { + const total = Math.max(0, Math.floor(ms / 1000)); + return `${Math.floor(total / 60)}m ${total % 60}s`; +} + +/** + * Total process time, anchored on the session's created_at (robust to reload and + * resume). Ticks every second while running; freezes at stoppedAt once the session + * is finalized. Renders nothing until a start time is known. + */ +export function ElapsedTimer({ + startedAt, + stoppedAt, +}: { + startedAt: string | null; + stoppedAt?: string | null; +}) { + const [now, setNow] = useState(() => Date.now()); + + useEffect(() => { + if (!startedAt || stoppedAt) return; + const t = setInterval(() => setNow(Date.now()), 1000); + return () => clearInterval(t); + }, [startedAt, stoppedAt]); + + if (!startedAt) return null; + const start = new Date(startedAt).getTime(); + const end = stoppedAt ? new Date(stoppedAt).getTime() : now; + return ( + + {fmt(end - start)} + + ); +} diff --git a/frontend/src/shell/WorkflowBar.tsx b/frontend/src/shell/WorkflowBar.tsx index 9caed2b8..b7012cdb 100644 --- a/frontend/src/shell/WorkflowBar.tsx +++ b/frontend/src/shell/WorkflowBar.tsx @@ -1,5 +1,6 @@ // frontend/src/shell/WorkflowBar.tsx import { useSessionStore } from "../store/sessionStore"; +import { ElapsedTimer } from "./ElapsedTimer"; const PHASES = [ { id: "F1", name: "Disambiguation" }, @@ -24,16 +25,25 @@ const DOT: Record = { pending: "bg-transparent text-muted-foreground/70 ring-border", }; -export function WorkflowBar({ finalized = false }: { finalized?: boolean }) { +export function WorkflowBar({ + finalized = false, + createdAt = null, + updatedAt = null, +}: { + finalized?: boolean; + createdAt?: string | null; + updatedAt?: string | null; +}) { const currentPhase = useSessionStore((s) => s.currentPhase); const phaseError = useSessionStore((s) => s.phaseError); const activeIdx = PHASES.findIndex((p) => p.id === currentPhase); return ( - + + + ); }