import { createStore } from "solid-js/store"; import { createSignal, onCleanup } from "solid-js"; import { api, ApiError, UNAUTHORIZED, type PersonState } from "./api"; const POLL_MS = 5_000; /** * The polling live view. * * A store rather than a signal so a position update for one person only touches * the DOM that reads that person. Polling pauses while the tab is hidden, and * an unchanged poll is a 304 with no body thanks to the endpoint's ETag. */ export function createLiveStore(onUnauthorized: () => void) { const [people, setPeople] = createStore>({}); const [order, setOrder] = createSignal([]); const [serverTime, setServerTime] = createSignal(Math.floor(Date.now() / 1000)); const [error, setError] = createSignal(null); let etag: string | undefined; let timer: number | undefined; let stopped = false; async function poll() { try { const res = await api.state(etag); etag = res.etag ?? undefined; setError(null); if (res.data) { setServerTime(res.data.server_time); const seen = new Set(); for (const p of res.data.people) { seen.add(p.user_id); setPeople(p.user_id, p); } for (const id of Object.keys(people).map(Number)) { if (!seen.has(id)) setPeople(id, undefined!); } setOrder(res.data.people.map((p) => p.user_id)); } } catch (e) { if (e instanceof ApiError && e.status === UNAUTHORIZED) { stop(); onUnauthorized(); return; } setError(e instanceof Error ? e.message : String(e)); } schedule(); } function schedule() { if (stopped || document.hidden) return; timer = window.setTimeout(poll, POLL_MS); } function stop() { stopped = true; clearTimeout(timer); } const onVisibility = () => { clearTimeout(timer); // Poll immediately on becoming visible: waiting 5 s to refresh a view the // user just looked at is exactly when staleness is most noticeable. if (!document.hidden && !stopped) void poll(); }; document.addEventListener("visibilitychange", onVisibility); onCleanup(() => { stop(); document.removeEventListener("visibilitychange", onVisibility); }); void poll(); return { people, order, serverTime, error, refresh: () => void poll() }; } /** "12s", "4m", "2h", "3d" — compared against the server clock, never the browser's. */ export function ago(ts: number, now: number): string { const d = Math.max(0, now - ts); if (d < 60) return `${d}s`; if (d < 3600) return `${Math.floor(d / 60)}m`; if (d < 86400) return `${Math.floor(d / 3600)}h`; return `${Math.floor(d / 86400)}d`; } /** Same buckets as `ago`, for a timestamp in the future. Separate function because a flag on `ago` would put the tense at the call site, not in the name. */ export function until(ts: number, now: number): string { return ago(now, ts); } /** Fresh under 15 min, amber to an hour, red beyond. */ export function staleness(ts: number, now: number): "fresh" | "stale" | "old" { const d = now - ts; return d < 900 ? "fresh" : d < 3600 ? "stale" : "old"; }