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 }; +}