diff --git a/web/SyncModal.tsx b/web/SyncModal.tsx index ba73765..72d07bc 100644 --- a/web/SyncModal.tsx +++ b/web/SyncModal.tsx @@ -43,6 +43,52 @@ const COLOURS: Record = { const clock = (iso: string) => new Date(iso).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', second: '2-digit' }); +/** `1m 12s`, or `8s`. The same shape the run history uses, so two readings of one run agree. */ +const spell = (ms: number): string => { + const seconds = Math.max(0, Math.round(ms / 1000)); + return seconds < 60 ? `${seconds}s` : `${Math.floor(seconds / 60)}m ${seconds % 60}s`; +}; + +/** + * How long this run has been going, ticking while it is. + * + * ── Measured from the RUN, not from the press ── + * + * `startedAt` is stamped by the server when the pass begins, so the clock is right for somebody who + * opened the modal halfway through, or reloaded the page, or was not the one who pressed the button — + * the watcher starts runs too. A timer anchored to the click would read zero in every one of those + * cases and be wrong by however long the run had already been going. + * + * ── It stops when the run does ── + * + * Frozen at `finishedAt`, so the total stays on screen afterwards rather than counting up forever next + * to a finished run. That number is the useful one: it is what tells you the next full sync will also + * take forty minutes. + * + * ── Clock skew is possible and small ── + * + * `startedAt` comes from the server's clock and `Date.now()` from the browser's. Both are absolute, so + * a timezone cannot hurt this; only a genuinely wrong clock can, and then by exactly its error. The + * alternative — anchoring locally the first time we see a run — is exact for the person who pressed + * the button and silently zero for everyone else, which is the worse trade. + */ +const useElapsed = (startedAt: string | null, finishedAt: string | null, running: boolean): string | null => { + const [, tick] = useState(0); + + useEffect(() => { + if (!running) return; + const timer = setInterval(() => tick((n) => n + 1), 1000); + return () => clearInterval(timer); + }, [running]); + + if (!startedAt) return null; + const began = Date.parse(startedAt); + if (!Number.isFinite(began)) return null; + + const until = running ? Date.now() : finishedAt ? Date.parse(finishedAt) : Date.now(); + return spell(until - began); +}; + export const SyncModal = ({ open, onClose }: { open: boolean; onClose: () => void }) => { const { log, state, running, percent, error, start, busy } = useMediaSync(); const [copied, flashCopied] = useFlash(1500); @@ -57,6 +103,8 @@ export const SyncModal = ({ open, onClose }: { open: boolean; onClose: () => voi const box = useRef(null); const [following, setFollowing] = useState(true); + const elapsed = useElapsed(state?.startedAt ?? null, state?.finishedAt ?? null, running); + useLayoutEffect(() => { const node = box.current; if (!node || !following) return; @@ -87,6 +135,14 @@ export const SyncModal = ({ open, onClose }: { open: boolean; onClose: () => voi ? 'finished' : 'idle'} + + {/* Right-aligned and monospaced, because a digit that changes every second must not move + the words beside it. `tabular-nums` is what stops 1m 9s becoming 1m 10s and shifting. */} + {elapsed && ( + + {elapsed} + + )}