Files
threadcount-community/lib/live.ts
T
ThreadCount a6f1059ddf ThreadCount Community edition
Uniform stock management for healthcare linen rooms: the coordinator app, the phone counter and the staff app, for your own server. Built from a113353 on 2026-09-15. Licensed under the Functional Source License (FSL-1.1-ALv2).
2026-09-15 22:54:09 +10:00

136 lines
6.9 KiB
TypeScript

"use client";
import { useEffect, useRef } from "react";
/* Keeping a screen honest about changes it did not make.
*
* Every mutation in this product already ends in router.refresh(), so a screen is never stale about
* its OWN work. What it had no way to learn was that somebody else had changed something: a phone
* left open on a ward went on showing the catalogue as it stood when it was opened, and a
* coordinator adding a garment at the desk had to go and tell the counter to reload.
*
* Polling the snapshot itself to find out would mean every open device re-reading the facility's
* catalogue, register, stock and history every few seconds to discover, nearly always, that nothing
* had happened. So the server keeps a counter and bumps it once per mutation; this asks for that
* one number, and only pays for the real reload when it has moved — with one exception, the first
* answer this tab has no baseline for, for the reason set out in tick().
*
* Two things keep it quiet. It stops entirely while the tab is hidden — a phone in a pocket costs
* nothing, and the first thing it does on becoming visible again is ask, so coming back to the app
* is immediate rather than up to a poll late. And a mutation made HERE records the revision it
* produced, so your own save never bounces the screen a second time a few seconds later.
*/
const POLL_MS = 5000;
/** The last revision this tab knows about, from a poll or from its own mutation. */
let lastRev: number | null = null;
/* Bumped every time lastRev moves, so a poll can tell whether its answer was already out of date
* by the moment it arrived. A question asked before lastRev changed may have been read on the
* server before that change landed; one asked after it cannot have been. That is the whole of how
* we tell "an answer from before my own save" from "the world really is at a smaller number" —
* see the lower-revision branch in tick(). */
let revGen = 0;
function setRev(rev: number) {
lastRev = rev;
revGen++;
}
/** Called by mutate() with the revision its own write produced, so the poll does not re-fire it. */
export function noteRev(rev: unknown) {
if (typeof rev === "number") setRev(rev);
}
export function useLiveRefresh(refresh: () => void) {
const busy = useRef(false);
useEffect(() => {
let stopped = false;
let timer: ReturnType<typeof setTimeout> | undefined;
const schedule = () => {
if (timer) clearTimeout(timer);
if (!stopped) timer = setTimeout(tick, POLL_MS);
};
const tick = async () => {
if (stopped) return schedule();
// Hidden tabs ask nothing at all; visibilitychange below wakes them.
if (document.visibilityState !== "visible" || busy.current) return schedule();
busy.current = true;
// Taken before the question goes out, so the answer can be judged against what we knew when
// we asked rather than against what we have learned while waiting.
const genAsked = revGen;
try {
const r = await fetch("/api/rev", { cache: "no-store" });
if (r.ok) {
const { rev } = (await r.json()) as { rev?: unknown };
if (typeof rev === "number") {
if (lastRev === null) {
/* Nothing to compare against, so we cannot prove this screen is current.
*
* The server rendered the page at some revision nobody told the browser, and the
* linen room can mark a bag ready in the gap between that render and this first
* question. Quietly adopting the answer as a baseline loses that change for good:
* the ward phone goes on saying "Being picked" until somebody else in the facility
* happens to move the number again, and the nurse never walks down for the bag. So
* the first answer always reloads. It costs one extra render per page load, and the
* loop below is started at mount rather than a poll later so that render lands at
* launch, before anyone has begun counting into the screen.
*
* Once per fresh load of the app, then — not once per screen. lastRev belongs to the
* tab, so moving between screens inside the app still has a baseline to compare
* with, and anything that happened around that later render leaves the counter above
* the baseline, which the ordinary branch below picks up on its own.
*/
setRev(rev);
refresh();
} else if (rev > lastRev) {
// The ordinary case: somebody else moved the counter on.
setRev(rev);
refresh();
} else if (rev < lastRev && revGen === genAsked) {
/* The counter has genuinely gone backwards, and the screen has to follow it down.
*
* Restoring a backup rebuilds the facility row, and the revision it comes back with
* can be lower than a number this tab has already seen. A tab that only ever accepted
* higher numbers would then refuse every answer for as long as it stayed open — going
* on showing a catalogue and a stock position that no longer exist, with nothing on
* screen to say so. That is worse than the double refresh guarded against below,
* because nothing ever ends it.
*
* What separates the two is whether anything moved lastRev while this question was in
* the air. Nothing did, so this read cannot be an echo of the world before our own
* save: it was issued after we already held the newer number, and the server still
* answered with a smaller one. That is news, not a straggler, so we take it.
*/
setRev(rev);
refresh();
}
/* Anything left is our own save coming back to haunt us: this poll's read ran before
* mutate() bumped the facility, so it answers with the old number while noteRev has
* already recorded the new one. Taking it would walk lastRev backwards and reload the
* screen for a change it had already applied — then again five seconds later when the
* real number reappeared. And the same number twice was never news to begin with. */
}
}
} catch {
// Offline, asleep, or the server restarting mid-deploy. The next tick asks again; a missed
// poll is a few seconds of staleness, not an error worth putting in front of anybody.
} finally {
busy.current = false;
}
schedule();
};
const onVisible = () => { if (document.visibilityState === "visible") void tick(); };
document.addEventListener("visibilitychange", onVisible);
void tick();
return () => {
stopped = true;
if (timer) clearTimeout(timer);
document.removeEventListener("visibilitychange", onVisible);
};
}, [refresh]);
}