a clock on the run

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) <noreply@anthropic.com>
This commit is contained in:
2026-08-31 12:19:03 +01:00
co-authored by Claude Opus 5
parent ddcba1c95d
commit 3b18615bbb
+56
View File
@@ -43,6 +43,52 @@ const COLOURS: Record<LogLine['level'], string> = {
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<HTMLDivElement | null>(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'}
</span>
{/* 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 && (
<span className="ml-auto shrink-0 font-mono text-xs font-normal tabular-nums text-muted-foreground">
{elapsed}
</span>
)}
</DialogTitle>
</DialogHeader>