import { useCallback, useEffect, useRef, useState } from "react"; import { useHttpService, type RequestAdmission } from "../services/HttpService.tsx"; import { useOptionalWebSocketServiceContext } from "../services/WebSocketService"; import { environment } from "../environments/environment.ts"; import { relayServerFailure } from "../utils/fetchFailureMessage"; import { useListOwner, type ListOwner } from "./useListOwner"; import { LAB_ADVANCED_EVENT, LAB_ENTERED_EVENT, LAB_QUEUED_EVENT, LAB_RELEASED_EVENT, QueueRetiredError } from "./useDiagnosticOrderQueue"; /** * The bench -- what is in front of a technician, worked by specimen rather than by order. * * The queue beside it answers what the practice has outstanding. This answers what is in somebody's * hand, which is a different list: an order with no sample yet, a tube waiting to be run, a result * typed and not released, and what was turned away today. * * The server returns the four sections already cut and counted. This hook does not join, filter or * re-sort them -- a bench that disagreed with the server about which section a specimen was in * would be worse than one that showed nothing. The exceptions are what the server confirmed to this screen: a later * read that lags does not show that order further back, and one that leaves an order out does not end it at once. */ export interface BenchRow { orderId?: string; /** Absent until the sample is taken, because that is the moment the number is minted. */ accession?: string; specimenId?: string; test?: string; patientDisplay?: string; orderedBy?: string; authoredOn?: string; priority?: string; container?: string; volume?: string; collectedAt?: string; collectedBy?: string; enteredAt?: string; rejectedFor?: string; /** Present once the order has gone to an outside laboratory. */ sentOut?: { laboratory?: string }; /** Collected and not yet received, so there is no accession yet. */ awaitingReceipt?: boolean; /** The order's first code and the system it belongs to. */ testCode?: string; testCodeSystem?: string; /** Accepted from another Akashic organization, which collects it. */ acceptedFromNetwork?: boolean; /** What a tube awaiting receipt may be refused for: the test's own list or the practice default. */ rejectionReasons?: { code?: string; display?: string }[]; /** Why the last tube was refused, on an order waiting for its replacement. */ recollectionFor?: string; } /** What the server confirmed about a refusal; each fact is shown as it came back. */ export interface RejectionOutcome { reason?: string; patientTaskRaised?: boolean; senderNotified?: "delivered" | "failed"; alreadyRecorded?: boolean; } export interface RejectionInput { /** Kept across retries of one refusal, so a lost answer is finished rather than repeated. */ attemptId: string; reasonCode: string; note?: string; } export interface Bench { toCollect: BenchRow[]; onTheBench: BenchRow[]; awaitingRelease: BenchRow[]; rejectedToday: BenchRow[]; } const EMPTY: Bench = { toCollect: [], onTheBench: [], awaitingRelease: [], rejectedToday: [] }; const SECTIONS = ["toCollect", "onTheBench", "awaitingRelease", "rejectedToday"] as const; export const BENCH_UNREADABLE = "Unable to load the bench. We cannot confirm what is waiting."; /** Names one receipt attempt on the tube it writes; the server keeps the tube under it. */ export const ACCESSION_ATTEMPT_SYSTEM = "https://akashichealthrecords.com/fhir/accession-attempt"; export const BENCH_READ_DEADLINE_MS = 20_000; export const BENCH_FOLLOW_WINDOW_MS = 120_000; export const BENCH_FOLLOW_EVERY_MS = 3000; /** How long a confirmed placement is held over reads that lag. */ export const BENCH_HOLD_MS = 10 * 60_000; /** How long, and over how many reads, an order must stay out of the search before it leaves the bench. */ export const BENCH_ABSENCE_MS = 60_000; export const BENCH_ABSENCE_READS = 2; export const SPECIMEN_RECEIVED_EVENT = "com.akashichealthrecords.fhir.specimen.created"; export const SPECIMEN_REJECTED_EVENT = "com.akashichealthrecords.fhir.specimen.updated"; /** A zero is only a settled section when the server sent that section; a missing one is unknown. */ const toBench = (response: Partial | undefined): Bench | undefined => SECTIONS.every((section) => Array.isArray(response?.[section])) ? (response as Bench) : undefined; // Where an order stands on the bench, in the order work moves it. Released is past every section. const TO_COLLECT = 1; const AWAITING_RECEIPT = 2; const ON_THE_BENCH = 3; const AWAITING_RELEASE = 4; const RELEASED = 5; const sectionOf = (bench: Bench, orderId: string): { rank: number; row: BenchRow } | undefined => { const waiting = bench.awaitingRelease.find((row) => row.orderId === orderId); if (waiting) return { rank: AWAITING_RELEASE, row: waiting }; const tube = bench.onTheBench.find((row) => row.orderId === orderId); if (tube) return { rank: tube.awaitingReceipt ? AWAITING_RECEIPT : ON_THE_BENCH, row: tube }; const due = bench.toCollect.find((row) => row.orderId === orderId); return due ? { rank: TO_COLLECT, row: due } : undefined; }; const rankIn = (bench: Bench, orderId: string): number | undefined => sectionOf(bench, orderId)?.rank; const instant = (value?: string) => { const at = Date.parse(value ?? ""); return Number.isFinite(at) ? at : 0; }; // The latest work the row records. A row further back that records later work is new work, not a lagging copy. const evidenceOf = (row?: BenchRow) => Math.max(instant(row?.collectedAt), instant(row?.enteredAt)); // Where this screen saw an order confirmed, as the row it then showed, and the work that placement rests on. interface Floor { rank: number; row?: BenchRow; evidence: number; recollecting: boolean; since: number } const floorFrom = (rank: number, row: BenchRow | undefined, now = Date.now()): Floor => ({ rank, row, evidence: evidenceOf(row), recollecting: Boolean(row?.recollectionFor), since: now }); /** The order shown where this screen saw it confirmed; the refusals of the day are left as the read had them. */ const place = (bench: Bench, orderId: string, floor: Floor): Bench => { const without = (rows: BenchRow[]) => rows.filter((row) => row.orderId !== orderId); const next = { ...bench, toCollect: without(bench.toCollect), onTheBench: without(bench.onTheBench), awaitingRelease: without(bench.awaitingRelease) }; if (!floor.row || floor.rank === RELEASED) return next; if (floor.rank === AWAITING_RELEASE) return { ...next, awaitingRelease: [floor.row, ...next.awaitingRelease] }; if (floor.rank === TO_COLLECT) return { ...next, toCollect: [floor.row, ...next.toCollect] }; return { ...next, onTheBench: [floor.row, ...next.onTheBench] }; }; /** * A read laid over what this screen has seen confirmed. An order the read shows further back is held where it was * confirmed unless the read records later work, or a refused tube sent it back for recollection; one the read shows * level or further on raises the hold. Holds last a bounded time, so a placement never outlives its evidence for long. */ const holdOver = (read: Bench, floors: Map, now = Date.now()): Bench => { let bench = read; floors.forEach((floor, orderId) => { if (now - floor.since > BENCH_HOLD_MS) { floors.delete(orderId); return; } const shown = sectionOf(read, orderId); // Left out altogether: the absence hold below keeps it, and for a release absence is what was confirmed. if (!shown) return; if (shown.rank >= floor.rank) { floors.set(orderId, { ...floorFrom(shown.rank, shown.row, floor.since), evidence: Math.max(floor.evidence, evidenceOf(shown.row)) }); return; } const newWork = evidenceOf(shown.row) > floor.evidence || (Boolean(shown.row.recollectionFor) && !floor.recollecting); if (newWork) floors.delete(orderId); else bench = place(bench, orderId, floor); }); return bench; }; interface BenchStep { event: string; orderOf: (data: Record | undefined) => string | undefined; /** Shown only by what a read carries; an order the search left out shows no step. */ shows: (bench: Bench, orderId: string, before?: number) => boolean; /** Where the order stands once it shows, held over later reads that lag. */ holds?: boolean; } const named = (data: Record | undefined) => (typeof data?.orderId === "string" && data.orderId ? data.orderId : undefined); const requestedBy = (data: Record | undefined) => { const requests = Array.isArray(data?.request) ? (data.request as { reference?: unknown }[]) : []; const reference = requests.map((each) => each?.reference).find((each): each is string => typeof each === "string" && each.startsWith("ServiceRequest/")); return reference ? reference.slice("ServiceRequest/".length) || undefined : undefined; }; const atLeast = (rank: number) => (bench: Bench, orderId: string) => (rankIn(bench, orderId) ?? 0) >= rank; // What another screen of this practice changes on the bench, and what a read shows once it has it. A release is // confirmed by its notice, which the server sends only once the release is saved; the bench then reads until it agrees. const BENCH_STEPS: BenchStep[] = [ { event: LAB_QUEUED_EVENT, orderOf: named, shows: (bench, id) => rankIn(bench, id) !== undefined }, { event: LAB_ADVANCED_EVENT, orderOf: named, shows: (bench, id, before) => { const rank = rankIn(bench, id); return rank !== undefined && rank !== before; }, holds: true }, { event: SPECIMEN_RECEIVED_EVENT, orderOf: requestedBy, shows: atLeast(ON_THE_BENCH), holds: true }, { event: SPECIMEN_REJECTED_EVENT, orderOf: requestedBy, shows: (bench, id) => bench.rejectedToday.some((row) => row.orderId === id) }, { event: LAB_ENTERED_EVENT, orderOf: named, shows: atLeast(AWAITING_RELEASE), holds: true }, { event: LAB_RELEASED_EVENT, orderOf: named, shows: (bench, id) => rankIn(bench, id) === undefined }, ]; class BenchDeadlineError extends Error {} class BenchSupersededError extends Error {} const statusOf = (error: unknown): number | undefined => { const value = error as { status?: unknown; response?: { status?: unknown } } | null; const status = value?.status ?? value?.response?.status; return typeof status === "number" ? status : undefined; }; const isRefusal = (error: unknown) => statusOf(error) === 401 || statusOf(error) === 403; const isQuiet = (error: unknown) => error instanceof QueueRetiredError || error instanceof BenchSupersededError; const hidden = () => typeof document !== "undefined" && document.visibilityState === "hidden"; // What one opening of the bench, for one viewer, session, practice and transport, has read and is waiting on. Nothing // in it outlives the opening, so a replacement never shares a wait, a hold or a read with the one it replaced. interface Opening { bench: Bench; started: number; settled: number; reading?: Promise; floors: Map; refused: Map; missing: Map; waiting: Map; following: boolean; } const newOpening = (): Opening => ({ bench: EMPTY, started: 0, settled: 0, floors: new Map(), refused: new Map(), missing: new Map(), waiting: new Map(), following: false, }); interface View { owner: object; bench: Bench; loading: boolean; error?: string } const failureText = (err: unknown) => (err instanceof BenchDeadlineError ? "The bench took too long to answer. Reload to try again." : relayServerFailure(err, BENCH_UNREADABLE)); /** An order the read left out stays where it was until it has been absent for a while and over more than one read. */ const keepAbsent = (opening: Opening, merged: Bench, now = Date.now()): Bench => { let bench = merged; const before = opening.bench; [...before.toCollect, ...before.onTheBench, ...before.awaitingRelease].forEach((row) => { const orderId = row.orderId; if (!orderId) return; if (rankIn(merged, orderId) !== undefined || opening.floors.get(orderId)?.rank === RELEASED) { opening.missing.delete(orderId); return; } const gone = opening.missing.get(orderId) ?? { since: now, reads: 0 }; gone.reads += 1; opening.missing.set(orderId, gone); if (gone.reads >= BENCH_ABSENCE_READS && now - gone.since >= BENCH_ABSENCE_MS) { opening.missing.delete(orderId); return; } const was = sectionOf(before, orderId); if (was) bench = place(bench, orderId, floorFrom(was.rank, was.row, now)); }); return bench; }; export const useBench = () => { const { get, post, put } = useHttpService(); // One opening per viewer, session, practice and transport: what one read is never shown under another. const { owner, live } = useListOwner(get); const openings = useRef(new WeakMap()); const openingOf = useCallback((of: ListOwner) => { let opening = openings.current.get(of); if (!opening) { opening = newOpening(); openings.current.set(of, opening); } return opening; }, []); // A write may bring its own admission as well, which is asked at the same points. const admitFor = useCallback((of: ListOwner, also?: RequestAdmission): RequestAdmission => (phase) => { if (!live(of)) throw new QueueRetiredError(); also?.(phase); }, [live]); const [view, setView] = useState({ owner, bench: EMPTY, loading: true }); // A replaced opening's bench, error and loading are concealed in the same render that replaces it. const shown: View = view.owner === owner ? view : { owner, bench: EMPTY, loading: true }; const publish = useCallback((of: ListOwner, change: Partial) => { if (!live(of)) return; setView((prev) => ({ ...(prev.owner === of ? prev : { owner: of, bench: EMPTY, loading: true }), ...change, owner: of })); }, [live]); const settle = useCallback((of: ListOwner, opening: Opening, bench: Bench) => { opening.bench = bench; publish(of, { bench, error: undefined, loading: false }); }, [publish]); // One read, owned by its opening, cancelled at its deadline. A follow joins the read already under way rather than // starting another; a reload starts its own. A read that started before another that has already settled, by answer // or by failure, changes nothing and reports nothing. const readBench = useCallback((of: ListOwner, join = false): Promise => { if (!live(of)) return Promise.reject(new QueueRetiredError()); const opening = openingOf(of); if (join && opening.reading) return opening.reading; const read = ++opening.started; // Known only once the read has started; a read that fails before its first wait has nothing to clear. let reading: Promise | undefined; const started = (async () => { const controller = new AbortController(); let timer: ReturnType | undefined; // The deadline ends the wait even for a transport that does not honour the signal. const late = new Promise((_resolve, reject) => { timer = setTimeout(() => { controller.abort(); reject(new BenchDeadlineError()); }, BENCH_READ_DEADLINE_MS); }); const admit = admitFor(of); let unreadable = false; try { const answer = await Promise.race([late, get>(`${environment.fhirClinicalApi}/diagnostic-bench`, { signal: controller.signal, admit })]); admit(); if (read <= opening.settled) return undefined; opening.settled = read; const response = toBench(answer); if (!response) { unreadable = true; throw new Error(BENCH_UNREADABLE); } opening.refused.forEach((_row, orderId) => { if (response.rejectedToday.some((row) => row.orderId === orderId)) opening.refused.delete(orderId); }); const bench = keepAbsent(opening, holdOver(response, opening.floors)); settle(of, opening, { ...bench, rejectedToday: [...opening.refused.values(), ...bench.rejectedToday] }); return opening.bench; } catch (err) { if (!live(of) || err instanceof QueueRetiredError) throw new QueueRetiredError(); if (!unreadable && read <= opening.settled) throw new BenchSupersededError(); opening.settled = read; throw controller.signal.aborted ? new BenchDeadlineError() : err; } finally { clearTimeout(timer); if (opening.reading === reading) opening.reading = undefined; } })(); reading = started; opening.reading = started; return started; }, [get, live, openingOf, settle, admitFor]); // Loading ends only when no later read is still under way, so an overtaken read never clears it early. const settleLoading = useCallback((of: ListOwner) => { const opening = openingOf(of); if (opening.settled >= opening.started) publish(of, { loading: false }); }, [openingOf, publish]); const load = useCallback(async () => { const of = owner; publish(of, { loading: true, error: undefined }); try { await readBench(of); } catch (err) { if (isQuiet(err)) return; console.error("Error loading the bench:", err); publish(of, { error: failureText(err) }); } finally { settleLoading(of); } }, [owner, readBench, publish, settleLoading]); useEffect(() => { void load(); }, [load]); // A move this screen made, confirmed by the server, is held over later reads that still show the order behind it. const hold = useCallback((of: ListOwner, orderId: string, floorOf: (bench: Bench) => Floor) => { if (!live(of)) throw new QueueRetiredError(); const opening = openingOf(of); const floor = floorOf(opening.bench); opening.floors.set(orderId, floor); settle(of, opening, place(opening.bench, orderId, floor)); }, [live, openingOf, settle]); // Collection moves the order only. No accession is minted or claimed here: that happens at receipt. The row moves // from the server's answer rather than a re-read, which this projection lags. It belongs to the bench it was offered // on: once that bench is closed or changed the move is not sent, and an answer after is not shown. const markCollected = useCallback(async (orderId: string, also?: () => void) => { const of = owner; const admit = admitFor(of, also); admit(); await put(`${environment.fhirBaseApi}/diagnostic-order/${orderId}/state`, { state: "collected" }, { admit }); hold(of, orderId, (bench) => { const row = bench.toCollect.find((each) => each.orderId === orderId); const was = sectionOf(bench, orderId); return row ? floorFrom(AWAITING_RECEIPT, { ...row, awaitingReceipt: true }) : floorFrom(was?.rank ?? AWAITING_RECEIPT, was?.row); }); }, [owner, put, admitFor, hold]); // Receipt mints the accession: the server reads the order's specimen requirements and answers with // the number the row then shows. The order and the attempt are all that is sent: a retry names the same attempt, so // a receipt whose answer was lost is answered with the tube it wrote rather than a second one. const receive = useCallback(async (orderId: string, also?: () => void, attemptId?: string) => { const of = owner; const admit = admitFor(of, also); admit(); const specimen = await post<{ id?: string; accessionIdentifier?: { value?: string }; collection?: { collectedDateTime?: string; collector?: { display?: string } }; }>( `${environment.fhirClinicalApi}/Specimen`, // resourceType is required: without it the write is refused before the handler sees it. { resourceType: "Specimen", request: [{ reference: `ServiceRequest/${orderId}` }], ...(attemptId ? { identifier: [{ system: ACCESSION_ATTEMPT_SYSTEM, value: attemptId }] } : {}), }, { admit }, ); if (typeof specimen?.id !== "string" || !specimen.id || typeof specimen.accessionIdentifier?.value !== "string" || !specimen.accessionIdentifier.value) { throw new Error("Receiving the specimen could not be confirmed. Retry this receipt before receiving another tube."); } hold(of, orderId, (bench) => { const row = bench.onTheBench.find((each) => each.orderId === orderId && each.awaitingReceipt); const was = sectionOf(bench, orderId); return row ? floorFrom(ON_THE_BENCH, { ...row, awaitingReceipt: false, specimenId: specimen?.id, accession: specimen?.accessionIdentifier?.value, collectedAt: specimen?.collection?.collectedDateTime ?? row.collectedAt, collectedBy: specimen?.collection?.collector?.display ?? row.collectedBy, }) : floorFrom(Math.max(was?.rank ?? ON_THE_BENCH, ON_THE_BENCH), was?.row); }); return specimen?.accessionIdentifier?.value; }, [owner, post, admitFor, hold]); // A tube refused at receipt gets no accession. The order stays open for the replacement. const rejectAtReceipt = useCallback(async (row: BenchRow, input: RejectionInput, also?: () => void) => { const of = owner; const admit = admitFor(of, also); admit(); const outcome = await post( `${environment.fhirBaseApi}/diagnostic-order/${row.orderId}/rejection`, { attemptId: input.attemptId, reasonCode: input.reasonCode, disposition: "redraw", askPatientToReturn: true, ...(input.note?.trim() ? { noteForClinician: input.note.trim() } : {}), }, { admit }, ); admit('answer'); const reason = row.rejectionReasons?.find((each) => each.code === input.reasonCode)?.display ?? input.reasonCode; const opening = openingOf(of); const refused = { ...row, awaitingReceipt: false, recollectionFor: undefined, rejectedFor: reason }; if (row.orderId) { opening.refused.set(row.orderId, refused); // The order is back for a replacement tube: that is new work, not a lagging copy of the receipt. opening.floors.delete(row.orderId); } settle(of, opening, { ...opening.bench, // The order stays open for the replacement tube, as the server lists it on the next read. onTheBench: opening.bench.onTheBench.map((each) => (each.orderId === row.orderId && each.awaitingReceipt ? { ...each, recollectionFor: reason } : each)), rejectedToday: [refused, ...opening.bench.rejectedToday.filter((each) => each.orderId !== row.orderId)], }); return outcome; }, [owner, post, admitFor, openingOf, settle]); // Released from the server's answer; a re-read of this projection can still show it waiting, and is held off. const markReleased = useCallback((orderId: string) => { if (!live(owner)) return; hold(owner, orderId, (bench) => ({ ...floorFrom(RELEASED, undefined), evidence: evidenceOf(sectionOf(bench, orderId)?.row) })); }, [owner, live, hold]); // Notices about this practice's orders. Each names only an order; the bench search lags the write, so the order is // followed until a read shows the step. Every notice joins one owned read at a time, which stops at a refusal or a // late answer, waits while the screen is hidden, and ends with its opening. const follow = useCallback(async (of: ListOwner) => { const opening = openingOf(of); if (opening.following) return; opening.following = true; try { while (live(of) && opening.waiting.size > 0 && !hidden()) { try { await readBench(of, true); } catch (err) { if (err instanceof QueueRetiredError) return; if (err instanceof BenchDeadlineError || isRefusal(err)) { opening.waiting.clear(); publish(of, { error: failureText(err) }); return; } if (!(err instanceof BenchSupersededError)) console.error("Error following the bench:", err); } const now = Date.now(); let behind = false; opening.waiting.forEach((wait, key) => { if (wait.step.shows(opening.bench, wait.orderId, wait.before)) { opening.waiting.delete(key); const shown = wait.step.holds ? sectionOf(opening.bench, wait.orderId) : undefined; const known = opening.floors.get(wait.orderId); if (shown && (!known || known.rank < shown.rank)) opening.floors.set(wait.orderId, floorFrom(shown.rank, shown.row, now)); } else if (now >= wait.until) { opening.waiting.delete(key); behind = true; } }); if (behind) publish(of, { error: "The bench may be behind a change made on another screen. Reload to check." }); if (opening.waiting.size === 0) return; await new Promise((resolve) => setTimeout(resolve, BENCH_FOLLOW_EVERY_MS)); } } finally { opening.following = false; } }, [live, openingOf, publish, readBench]); const socket = useOptionalWebSocketServiceContext(); const subscribe = socket?.subscribe; const unsubscribe = socket?.unsubscribe; useEffect(() => { if (!subscribe || !unsubscribe) return undefined; const listeners = BENCH_STEPS.map((step) => { const heard = (notice: { data?: Record } | undefined) => { const orderId = step.orderOf(notice?.data); if (!orderId || !live(owner)) return; const opening = openingOf(owner); if (step.event === LAB_RELEASED_EVENT) { const was = sectionOf(opening.bench, orderId); const floor = { ...floorFrom(RELEASED, undefined), evidence: evidenceOf(was?.row) }; opening.floors.set(orderId, floor); settle(owner, opening, place(opening.bench, orderId, floor)); } const key = step.event + "#" + orderId; const known = opening.waiting.get(key); opening.waiting.set(key, { orderId, step, before: known ? known.before : rankIn(opening.bench, orderId), until: Date.now() + BENCH_FOLLOW_WINDOW_MS, }); void follow(owner); }; subscribe(step.event, heard); return [step.event, heard] as const; }); return () => listeners.forEach(([event, heard]) => unsubscribe(event, heard)); }, [subscribe, unsubscribe, owner, live, openingOf, follow, settle]); // Back on screen: what was held while hidden is read once, for this opening only, with a fresh window. useEffect(() => { const back = () => { if (hidden() || !live(owner)) return; const opening = openingOf(owner); if (opening.waiting.size === 0) return; const until = Date.now() + BENCH_FOLLOW_WINDOW_MS; opening.waiting.forEach((wait) => { wait.until = Math.max(wait.until, until); }); void follow(owner); }; document.addEventListener("visibilitychange", back); return () => document.removeEventListener("visibilitychange", back); }, [owner, live, openingOf, follow]); return { bench: shown.bench, loading: shown.loading, error: shown.error, reload: load, markCollected, receive, rejectAtReceipt, markReleased }; };