live.ts
| 1 | import { createStore } from "solid-js/store"; |
| 2 | import { createSignal, onCleanup } from "solid-js"; |
| 3 | import { api, ApiError, UNAUTHORIZED, type PersonState } from "./api"; |
| 4 | |
| 5 | const 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 | */ |
| 14 | export 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. */ |
| 80 | export 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. */ |
| 89 | export 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 |