Files
pastilhasandClaude Opus 5 3b18615bbb 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>
2026-08-31 12:19:03 +01:00

220 lines
9.6 KiB
TypeScript

import { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { Check, Copy, Loader2, RefreshCw } from 'lucide-react';
import { copyToClipboard } from 'helpers/clipboard';
import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { useFlash } from './useFlash';
import { useMediaSync, type LogLine } from './useMediaSync';
/**
* What the sync is doing, line by line, while it does it.
*
* ── Why a transcript and not a progress bar ──
*
* The bar answers "how much" and it was the only answer available. It cannot say WHICH file, and the
* question people actually have during a long run is "is it stuck?" — which looks identical to "it is
* decoding a 40-megapixel HEIC" from outside. A photograph is around thirty encodes and a video is
* three plus a poster ladder; none of that was visible, so a run that was working normally and one
* that had wedged produced the same screen.
*
* ── The lines come from the poll, not a socket ──
*
* The state is already polled once a second while a run is going, and `?since=` means each line is
* transferred once. So this is a stream in the only sense that matters here — lines appear as they
* happen — without a second transport to open, authenticate and reconnect. Sub-second arrival would
* need SSE and would buy nothing: the events themselves are seconds apart.
*
* ── It is not the only place a failure is recorded ──
*
* Closing this loses nothing. The transcript lives in the shared slot and the ledger in Administration
* outlives the run entirely — this is the live view, not the record.
*/
const COLOURS: Record<LogLine['level'], string> = {
info: 'text-muted-foreground',
// The busiest level by far, and the least interesting line at a time. Dimmer than `info` so the
// structure of a run — its starts and its finishes — stays readable as the rungs scroll past.
step: 'text-muted-foreground/60',
done: 'text-foreground',
warn: 'text-amber-500',
error: 'text-destructive',
};
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);
/*
Follow the tail, unless somebody has scrolled away from it.
A transcript that always jumps to the bottom is unreadable while it is being written: the line
somebody is reading leaves the screen a second after they find it. So this follows only while the
view is already AT the bottom, which is the state it starts in and returns to on its own.
*/
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;
node.scrollTop = node.scrollHeight;
}, [log.length, following]);
// Reset to following whenever it opens, so reopening mid-run lands on the newest line rather than
// wherever the last visit left off.
useEffect(() => {
if (open) setFollowing(true);
}, [open]);
const transcript = log.map((line) => `${clock(line.at)} ${line.file ? `${line.file} — ` : ''}${line.text}`).join('\n');
return (
<Dialog open={open} onOpenChange={(next) => !next && onClose()}>
<DialogContent className="flex max-h-[80vh] flex-col gap-3 sm:max-w-3xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-sm">
{running && <Loader2 className="size-3.5 animate-spin" />}
Sync
<span className="font-normal text-muted-foreground">
{running
? state?.total
? `${state.done} of ${state.total}${percent === null ? '' : ` · ${percent}%`}`
: 'reading originals…'
: state?.finishedAt
? '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>
{running && (
<div className="h-1 w-full shrink-0 overflow-hidden rounded-full bg-muted">
{/* Indeterminate until the walk has counted: a bar that starts full and shrinks is worse
than one that has not started. */}
<div
className="h-full rounded-full bg-primary transition-[width] duration-500"
style={{ width: `${percent ?? 0}%` }}
/>
</div>
)}
<div
ref={box}
// `onScroll` rather than a button: following is a thing somebody stops by scrolling, which is
// what they were already doing, and resumes by scrolling back down.
onScroll={(event) => {
const node = event.currentTarget;
setFollowing(node.scrollHeight - node.scrollTop - node.clientHeight < 24);
}}
className="min-h-[16rem] flex-1 overflow-auto rounded-md border border-border bg-muted/30 p-3 font-mono text-[11px] leading-relaxed"
>
{log.length === 0 ? (
<p className="text-muted-foreground">
Nothing yet. Press Sync to build web-ready copies of everything in originals.
</p>
) : (
log.map((line) => (
<div key={line.n} className={`flex gap-2 ${COLOURS[line.level]}`}>
<span className="shrink-0 opacity-50">{clock(line.at)}</span>
{/* The file is not repeated on every rung of the same file — the eye follows a column
of identical names badly, and the interesting part is what changed. */}
{line.file && <span className="min-w-0 shrink-0 max-w-[45%] truncate">{line.file}</span>}
<span className="min-w-0 flex-1 break-words">{line.text}</span>
</div>
))
)}
</div>
{error && <p className="shrink-0 text-xs text-destructive">{error}</p>}
<div className="flex shrink-0 items-center justify-between gap-2">
<Button
size="sm"
variant="outline"
className="h-7 gap-1.5 text-xs"
disabled={log.length === 0}
onClick={() => {
void copyToClipboard(transcript);
flashCopied();
}}
>
{copied ? <Check className="size-3" /> : <Copy className="size-3" />}
{copied ? 'Copied' : 'Copy'}
</Button>
{/* Start from in here too. Opening the modal to watch and then having to close it to press
the button is the sort of thing that only reads as obvious to whoever built it. */}
<Button
size="sm"
className="h-7 gap-1.5 text-xs"
onClick={() => void start()}
disabled={busy || running}
>
{running ? <Loader2 className="size-3 animate-spin" /> : <RefreshCw className="size-3" />}
{running ? 'Syncing' : 'Sync'}
</Button>
</div>
</DialogContent>
</Dialog>
);
};