live.ts
⎇
Raw
1import { createStore } from "solid-js/store";
2import { createSignal, onCleanup } from "solid-js";
3import { api, ApiError, UNAUTHORIZED, type PersonState } from "./api";
4
5const POLL_MS = 5_000;
6
7/**
8 * The polling live view.
9 *
10 * A store rather than a signal so a position update for one person only touches
11 * the DOM that reads that person. Polling pauses while the tab is hidden, and
12 * an unchanged poll is a 304 with no body thanks to the endpoint's ETag.
13 */
14export function createLiveStore(onUnauthorized: () => void) {
15 const [people, setPeople] = createStore<Record<number, PersonState>>({});
16 const [order, setOrder] = createSignal<number[]>([]);
17 const [serverTime, setServerTime] = createSignal(Math.floor(Date.now() / 1000));
18 const [error, setError] = createSignal<string | null>(null);
19
20 let etag: string | undefined;
21 let timer: number | undefined;
22 let stopped = false;
23
24 async function poll() {
25 try {
26 const res = await api.state(etag);
27 etag = res.etag ?? undefined;
28 setError(null);
29 if (res.data) {
30 setServerTime(res.data.server_time);
31 const seen = new Set<number>();
32 for (const p of res.data.people) {
33 seen.add(p.user_id);
34 setPeople(p.user_id, p);
35 }
36 for (const id of Object.keys(people).map(Number)) {
37 if (!seen.has(id)) setPeople(id, undefined!);
38 }
39 setOrder(res.data.people.map((p) => p.user_id));
40 }
41 } catch (e) {
42 if (e instanceof ApiError && e.status === UNAUTHORIZED) {
43 stop();
44 onUnauthorized();
45 return;
46 }
47 setError(e instanceof Error ? e.message : String(e));
48 }
49 schedule();
50 }
51
52 function schedule() {
53 if (stopped || document.hidden) return;
54 timer = window.setTimeout(poll, POLL_MS);
55 }
56
57 function stop() {
58 stopped = true;
59 clearTimeout(timer);
60 }
61
62 const onVisibility = () => {
63 clearTimeout(timer);
64 // Poll immediately on becoming visible: waiting 5 s to refresh a view the
65 // user just looked at is exactly when staleness is most noticeable.
66 if (!document.hidden && !stopped) void poll();
67 };
68 document.addEventListener("visibilitychange", onVisibility);
69 onCleanup(() => {
70 stop();
71 document.removeEventListener("visibilitychange", onVisibility);
72 });
73
74 void poll();
75
76 return { people, order, serverTime, error, refresh: () => void poll() };
77}
78
79/** "12s", "4m", "2h", "3d" — compared against the server clock, never the browser's. */
80export function ago(ts: number, now: number): string {
81 const d = Math.max(0, now - ts);
82 if (d < 60) return `${d}s`;
83 if (d < 3600) return `${Math.floor(d / 60)}m`;
84 if (d < 86400) return `${Math.floor(d / 3600)}h`;
85 return `${Math.floor(d / 86400)}d`;
86}
87
88/** Fresh under 15 min, amber to an hour, red beyond. */
89export function staleness(ts: number, now: number): "fresh" | "stale" | "old" {
90 const d = now - ts;
91 return d < 900 ? "fresh" : d < 3600 ? "stale" : "old";
92}
93