From 3b18615bbb178fb3c1160615dd3d1de72dfcd5ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Mon, 31 Aug 2026 12:19:03 +0100 Subject: [PATCH] a clock on the run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Measured from the RUN, not from the press. `startedAt` is stamped by the server when the pass begins, so the number is right for somebody who opened the modal halfway through, reloaded the page, or was not the one who started it — the watcher starts runs too. A timer anchored to the click would read zero in all three cases and be wrong by however long the run had already been going. It freezes at `finishedAt` rather than counting up beside a finished run, because the total is the useful number: it is what tells you the next full sync will also take forty minutes. `tabular-nums`, so a digit changing every second does not shift the words beside it. Clock skew is the one thing that can make this wrong, by exactly its error — both times are absolute, so a timezone cannot. The alternative, anchoring locally the first time a run is seen, is exact for whoever pressed the button and silently zero for everyone else. Co-Authored-By: Claude Opus 5 (1M context) --- web/SyncModal.tsx | 56 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) 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} + + )}