1import { useSyncExternalStore } from "react";
2
3/** Minute-quantized UI clock ("YYYY-MM-DDTHH:MM"). One module-level timer
4 feeds every consumer through useSyncExternalStore. */
5
6function currentMinute(): string {
7 return new Date().toISOString().slice(0, 16);
8}
9
10let nowMinute = currentMinute();
11let timerId: number | null = null;
12let timerIsInterval = false;
13const listeners = new Set<() => void>();
14
15function tick(): void {
16 const next = currentMinute();
17 if (next !== nowMinute) {
18 nowMinute = next;
19 for (const listener of listeners) listener();
20 }
21}
22
23function startTimer(): void {
24 // Align to the next UTC minute boundary, then tick every 60s. Ticks re-read
25 // the clock, so a throttled or late timer self-corrects when it fires.
26 timerIsInterval = false;
27 timerId = window.setTimeout(
28 () => {
29 tick();
30 timerIsInterval = true;
31 timerId = window.setInterval(tick, 60_000);
32 },
33 60_000 - (Date.now() % 60_000),
34 );
35}
36
37function subscribe(listener: () => void): () => void {
38 if (listeners.size === 0) {
39 startTimer();
40 }
41 listeners.add(listener);
42 return () => {
43 listeners.delete(listener);
44 if (listeners.size === 0 && timerId !== null) {
45 if (timerIsInterval) window.clearInterval(timerId);
46 else window.clearTimeout(timerId);
47 timerId = null;
48 }
49 };
50}
51
52function getSnapshot(): string {
53 // With no timer running (no subscribers yet — e.g. the first render after
54 // a full unmount), the stored minute may be stale; re-read it so a fresh
55 // mount renders the current minute instead of waiting for the first tick.
56 // While the timer runs the cached value is returned untouched, as
57 // useSyncExternalStore requires between change notifications.
58 if (timerId === null) {
59 nowMinute = currentMinute();
60 }
61 return nowMinute;
62}
63
64export function useNowMinute(): string {
65 return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
66}