Files
threadcount-community/lib/client.tsx
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

85 lines
4.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { createContext, useCallback, useContext, useMemo, useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { itemMap, ledger, staffMap, variantList, type Snapshot } from "./compute";
import { TRACKED_OPS, failureKind, track } from "@/lib/analytics";
import { noteRev, useLiveRefresh } from "@/lib/live";
type Ctx = {
s: Snapshot;
isAdmin: boolean;
busy: boolean;
refresh: () => void;
mutate: <T = unknown>(op: string, payload?: unknown) => Promise<{ ok: true; result: T } | { ok: false; error: string }>;
};
const SnapshotContext = createContext<Ctx | null>(null);
export function SnapshotProvider({ snap, children }: { snap: Snapshot; children: React.ReactNode }) {
const router = useRouter();
const [pending, startTransition] = useTransition();
const [inflight, setInflight] = useState(0);
const refresh = useCallback(() => startTransition(() => router.refresh()), [router]);
useLiveRefresh(refresh);
const mutate = useCallback(async <T,>(op: string, payload?: unknown) => {
setInflight((n) => n + 1);
try {
const r = await fetch("/api/mutate", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ op, payload }) });
const j = await r.json().catch(() => ({}));
if (!r.ok) {
/* A dead session, sent back to the door this person actually came in by.
*
* This provider is shared by the desktop app and the counter app, and pushing everyone at
* /auth landed a nurse holding a phone on the website's sign-in pane — which also offers to
* create a facility — and then, after signing in, on /app rather than back where they were.
* The server guard and the /m layout both already send /m to /m/login; this was the one path
* that disagreed. A full navigation rather than a router push: the cookie is gone and every
* page behind it is server-rendered, so there is nothing left in the client tree worth
* keeping. */
if (r.status === 401) {
const p = typeof location === "undefined" ? "" : location.pathname + location.search;
const back = encodeURIComponent(p || "/app");
window.location.assign(p.startsWith("/m") ? `/m/login?next=${back}` : `/auth?next=${back}`);
}
const err = (j && j.error) || "Request failed";
// Refusals are worth counting — a facility repeatedly blocked by the variance gate is
// telling us something. Only the op name and a coarse category go out, never the message.
if (TRACKED_OPS[op]) track("action_refused", { action: TRACKED_OPS[op], reason: failureKind(String(err)) });
return { ok: false as const, error: err };
}
// The revision this write produced, so the live poll recognises it as ours and does not
// refresh the screen a second time a few seconds from now.
noteRev(j.rev);
startTransition(() => router.refresh());
// One event per whitelisted action. Everything else in ops.ts sends nothing at all.
if (TRACKED_OPS[op]) track(TRACKED_OPS[op]);
return { ok: true as const, result: j.result as T };
} catch {
if (TRACKED_OPS[op]) track("action_refused", { action: TRACKED_OPS[op], reason: "network" });
/* Not "nothing was saved", which we cannot know.
*
* fetch rejects when the answer never arrives, and the request may well have reached the
* server and committed before the wifi dropped — issuing deducts stock, a request emails a
* manager. None of the ops carry an idempotency key, so a retry invited by a flat "nothing was
* saved" writes the whole thing a second time. Telling the truth costs one extra glance at the
* record and is the only advice that cannot make it worse. */
return { ok: false as const, error: "The connection dropped before we heard back, so we cant say whether that saved. Check the record before trying again." };
} finally {
setInflight((n) => n - 1);
}
}, [router]);
const value = useMemo<Ctx>(() => ({ s: snap, isAdmin: snap.session.role === "Admin", busy: pending || inflight > 0, refresh, mutate }), [snap, pending, inflight, refresh, mutate]);
return <SnapshotContext.Provider value={value}>{children}</SnapshotContext.Provider>;
}
export function useSnap() {
const c = useContext(SnapshotContext);
if (!c) throw new Error("useSnap outside provider");
return c;
}
export function useDerived() {
const { s } = useSnap();
return useMemo(() => ({ L: ledger(s), byId: itemMap(s), staffById: staffMap(s), variants: variantList(s) }), [s]);
}