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 d947f89 on 2026-09-15. Licensed under the Functional Source License (FSL-1.1-ALv2).
This commit is contained in:
+135
@@ -0,0 +1,135 @@
|
||||
"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]);
|
||||
}
|
||||
Reference in New Issue
Block a user