diff --git a/flight-comparator/frontend/src/components/ScanTimer.tsx b/flight-comparator/frontend/src/components/ScanTimer.tsx
new file mode 100644
index 0000000..8f5f9ea
--- /dev/null
+++ b/flight-comparator/frontend/src/components/ScanTimer.tsx
@@ -0,0 +1,51 @@
+import type { ScanTimerResult } from '../hooks/useScanTimer';
+
+/** Format a non-negative number of seconds into a human-readable string. */
+export function formatDuration(totalSeconds: number): string {
+ const s = Math.floor(totalSeconds);
+ if (s < 60) return `${s}s`;
+ const m = Math.floor(s / 60);
+ const rem = s % 60;
+ if (m < 60) return rem > 0 ? `${m}m ${rem}s` : `${m}m`;
+ const h = Math.floor(m / 60);
+ const remM = m % 60;
+ return remM > 0 ? `${h}h ${remM}m` : `${h}h`;
+}
+
+interface ScanTimerProps extends ScanTimerResult {
+ /** When true, renders a compact single-line format for the completed stat card. */
+ compact?: boolean;
+}
+
+/**
+ * Displays elapsed time and ETA for an active scan, or final duration for a
+ * completed/failed scan.
+ */
+export default function ScanTimer({ elapsedSeconds, remainingSeconds, isEstimating, compact }: ScanTimerProps) {
+ if (compact) {
+ return {formatDuration(elapsedSeconds)};
+ }
+
+ const remainingLabel = isEstimating
+ ? 'Estimating…'
+ : remainingSeconds !== null
+ ? `~${formatDuration(remainingSeconds)}`
+ : null;
+
+ return (
+
+ Elapsed
+
+ {formatDuration(elapsedSeconds)}
+
+ {remainingLabel !== null && (
+ <>
+ Remaining
+
+ {remainingLabel}
+
+ >
+ )}
+
+ );
+}
diff --git a/flight-comparator/frontend/src/hooks/useScanTimer.ts b/flight-comparator/frontend/src/hooks/useScanTimer.ts
new file mode 100644
index 0000000..4953a64
--- /dev/null
+++ b/flight-comparator/frontend/src/hooks/useScanTimer.ts
@@ -0,0 +1,88 @@
+import { useEffect, useRef, useState } from 'react';
+import type { Scan } from '../api';
+
+export interface ScanTimerResult {
+ /** Seconds elapsed since the scan started processing. */
+ elapsedSeconds: number;
+ /**
+ * Estimated seconds remaining, or null when not enough data yet
+ * (fewer than 5 routes scanned or elapsed time is 0).
+ */
+ remainingSeconds: number | null;
+ /** True while the estimate is still too early to be reliable. */
+ isEstimating: boolean;
+}
+
+const MIN_ROUTES_FOR_ESTIMATE = 5;
+
+function calcElapsed(startedAt: string): number {
+ return Math.max(0, (Date.now() - new Date(startedAt).getTime()) / 1000);
+}
+
+function calcRemaining(
+ elapsed: number,
+ routesScanned: number,
+ totalRoutes: number,
+): number | null {
+ if (elapsed <= 0 || routesScanned < MIN_ROUTES_FOR_ESTIMATE || totalRoutes <= 0) {
+ return null;
+ }
+ const rate = routesScanned / elapsed; // routes per second
+ const remaining = (totalRoutes - routesScanned) / rate;
+ return Math.max(0, remaining);
+}
+
+export function useScanTimer(scan: Scan | null): ScanTimerResult {
+ const [elapsedSeconds, setElapsedSeconds] = useState(0);
+ const [remainingSeconds, setRemainingSeconds] = useState(null);
+ const intervalRef = useRef | undefined>(undefined);
+
+ useEffect(() => {
+ if (!scan) return;
+
+ // For completed / failed scans with both timestamps: compute static duration.
+ if (
+ (scan.status === 'completed' || scan.status === 'failed') &&
+ scan.started_at &&
+ scan.completed_at
+ ) {
+ const duration = Math.max(
+ 0,
+ (new Date(scan.completed_at).getTime() - new Date(scan.started_at).getTime()) / 1000,
+ );
+ setElapsedSeconds(duration);
+ setRemainingSeconds(0);
+ return;
+ }
+
+ // For running scans with a start time: run a live 1-second timer.
+ if (scan.status === 'running' && scan.started_at) {
+ const tick = () => {
+ const elapsed = calcElapsed(scan.started_at!);
+ const remaining = calcRemaining(elapsed, scan.routes_scanned, scan.total_routes);
+ setElapsedSeconds(elapsed);
+ setRemainingSeconds(remaining);
+ };
+
+ tick(); // run immediately
+ intervalRef.current = setInterval(tick, 1000);
+
+ return () => {
+ if (intervalRef.current !== undefined) {
+ clearInterval(intervalRef.current);
+ intervalRef.current = undefined;
+ }
+ };
+ }
+
+ // Pending or no started_at: reset
+ setElapsedSeconds(0);
+ setRemainingSeconds(null);
+ }, [scan?.status, scan?.started_at, scan?.completed_at, scan?.routes_scanned, scan?.total_routes]);
+
+ const isEstimating =
+ scan?.status === 'running' &&
+ (scan.routes_scanned < MIN_ROUTES_FOR_ESTIMATE || scan.total_routes <= 0);
+
+ return { elapsedSeconds, remainingSeconds, isEstimating };
+}