import { useCallback, useEffect, useRef, useState } from "react"; import type { ServiceRequest } from "fhir/r4"; import { useHttpService } from "../services/HttpService.tsx"; import { useOptionalWebSocketServiceContext } from "../services/WebSocketService"; import type { ArchiveReason } from "../utils/archivedResource"; import { environment } from "../environments/environment.ts"; import { relayServerFailure } from "../utils/fetchFailureMessage"; import { isOlderCopy, mergeQueueRead, movedPast, progressOf, returnIsOlder, type OrderProgress } from "../utils/diagnosticOrderProgress"; import { useListOwner, type ListOwner } from "./useListOwner"; import type { OwnedRequest } from "../services/ImagingUploadService.tsx"; import { isDiagnosticOrderState, type DiagnosticOrderKind, type DiagnosticOrderState, } from "../utils/diagnosticOrderQueue"; /** * A reread of the queue: the rows the server listed in that read, and, when it was asked after one order the read left * out, that order as read by id (undefined when it could not be read). False when the queue was not read. */ export type QueueReread = { listed: DiagnosticOrderQueueRow[]; order?: ServiceRequest } | false; // The rows each read listed, by the merged rows it published, so a caller can tell an answer from a row kept on screen. const listedBy = new WeakMap(); /** * The lab queue -- every order this practice is working, across patients. * * IT IS NOT A TASK SEARCH, and it cannot be, for the reason the referral queue records: every other * Task read in this product is patient-scoped, because `GET /R4/Task` refuses a search without a * `patient` as a disclosure fence. A work queue is org-wide by definition, so it has its own route * which rebuilds that fence rather than relaxing it. * * The server returns a PROJECTION -- what is waiting, where it got to, and who it is for. This hook * does not enrich it. A queue is not a record. */ export interface DiagnosticOrderQueueRow { taskId?: string; orderId?: string; /** * Which machine this order runs on. Without it the row cannot know which moves to offer: the * opening state `ordered` belongs to both, so deriving the machine from the state alone would * offer "collected" on an X-ray. */ kind?: DiagnosticOrderKind; /** Undefined means an order this system never stamped, and no move is offered for it. */ state?: DiagnosticOrderState; /** * A result has been typed and not yet released, which is the only signal that the release act is * waiting on somebody. * * THE STATE CANNOT SAY IT, deliberately: entry leaves the order exactly where it was, so a screen * keying off state alone shows nothing waiting. The server has sent this since the queue existed * and the client dropped it, which is why nothing in the product could release a result. */ /** * The laboratory refusing the ORDER, which is a different stoppage from refusing the sample: * their compendium changed or the account is closed, and the tube may be perfect. Present only * while nobody has dealt with it, because a resolved refusal is history rather than work. * * THE STATE CANNOT SAY THIS. The order is still `sent` -- it genuinely was sent -- so without * this field the row looks healthy and nobody finds out until they ask why the result never came. */ refusal?: { reason?: string; nextStep?: string; theirCode?: string; theirMessage?: string; recorded?: string }; /** The refusal once dealt with: no longer work, still part of the order's history. */ pastRefusal?: { reason?: string; nextStep?: string; theirCode?: string; theirMessage?: string; recorded?: string; resolved?: string; }; /** The last tube refused at receipt; a redraw keeps the order open for its replacement. */ rejection?: { reason?: string; disposition?: string; recorded?: string }; awaitingRelease?: boolean; /** What was ordered. Free text is legitimate -- see the note on the ordering form. */ test?: string; /** The code on the order, when one was chosen. The result form matches the catalogue on it. */ testCode?: string; /** The system testCode belongs to; only a LOINC can contradict a catalogue test's LOINC. */ testCodeSystem?: string; priority?: string; authoredOn?: string; /** * Whoever draws a specimen has to label the tube, so the row carries a name. The same amount the * referral queue carries, and the same reason -- it is not a route into the record. */ patientDisplay?: string; /** Imaging, for a reader: the patient's age in whole years, and how many earlier studies they have. */ patientAge?: number; priors?: number; /** A reader who opened this study within the hour. A warning for the next reader, never a lock. */ openedBy?: { name?: string; at: string; byYou: boolean }; /** The visit it was ordered at, so a row traces back to the encounter that produced it. */ encounterId?: string; /** * A result has been filed against this order, released or not, so the row can offer the laboratory's * own report back. Neither the state nor `awaitingRelease` can answer it: release clears the second * and advances the first only when the transition is legal. */ resultOnFile?: boolean; /** The practice that sent this order on paper; absent for our own orders. */ fromOutside?: string; /** The sender’s own number for a paper order, which a returned report is filed against. */ theirOrderNumber?: string; /** The clinician here who placed the order. */ orderedBy?: string; /** Why it was ordered, as the ordering clinician wrote it. */ question?: string; /** * The group this test was ordered in, when it was ordered with others. * * THE ID AND NOT THE NAME, which is what the bench needs from a panel: that one draw serves these * rows. Naming the panel would cost the queue a read of the parent per row, and a queue does not * enrich. */ panelId?: string; /** * The number an imaging department works by, minted when the study is booked in. * * IT LEADS THE ROW because it is what a technologist says out loud and types at a console. A study * not yet scheduled has none, and that is not a gap: the number is minted when the department * takes the study on. */ accession?: string; /** Where it was sent out to. Sent is not delivered: paper and a queued message both look like this. */ sentOut?: { laboratory?: string; at?: string }; /** What the laboratory's system answered, when it was sent electronically. */ transmission?: { outcome?: string; at?: string }; /** The laboratory confirmed this order electronically, so its results can be asked for from there. */ labConfirmed?: true; /** Saved to go electronically, but no send has started: its job may never have been queued. */ transmissionOwed?: boolean; /** Saved sent to an Akashic laboratory that has not been delivered to yet: the order offers to deliver it. */ deliveryOwed?: boolean; /** The server offers this caller the withdrawal: their order, untouched or awaiting an Akashic laboratory's acceptance. */ withdrawable?: boolean; /** A released outside result: what is known of returning the report version now on file. */ returned?: ReturnAttempt; /** Sent to another Akashic laboratory; each fact is present only once it happened. */ akashicRoute?: AkashicRoute; /** Received from another Akashic practice. */ fromAkashic?: { orderedBy?: string; theirOrderId: string }; /** Imaging only: when and where the study is booked, and the DICOM modality it runs on. */ scheduledFor?: string; room?: string; modality?: string; /** An upload still importing, or one that failed, as the order records it; survives a reload. */ imageImport?: ImageImport; /** Imaging only: who marked the study performed, and when. */ performed?: { by?: string; at: string }; /** When the report was written and signed, and whether the person asking wrote it. */ report?: { writtenAt?: string; writtenByYou?: boolean; releasedAt?: string; addendedAt?: string }; /** Imaging only: the images filed against the order. Absent means not counted, never none. */ images?: { studies: number; instances: number }; /** Imaging only: sent to another Akashic practice to be read; each fact is present only once it happened. */ readElsewhere?: ReadElsewhere; /** Imaging only: going to a reader that is not on Akashic, by staff. */ outsideRead?: OutsideRead; /** Imaging only: sent here by another practice to be read. */ readFor?: { from?: string; theirOrder?: string }; } /** Staff get the study there; Akashic sends nothing. What the reader said is recorded apart. */ export interface OutsideRead { reader: string; method: "disc" | "courier" | "their-portal" | "other"; contact?: string; sentAt: string; confirmation?: { outcome: "received" | "not-received"; at: string }; } export interface ReadElsewhere { reader?: string; sentAt: string; acceptedAt?: string; declinedAt?: string; declineReason?: string; returnedAt?: string; } /** * A report version and how its latest return attempt went. at is when the server recorded that attempt. after is kept * only on this screen: the latest attempt it already knew of when the server confirmed an untimed change. */ export interface ReturnAttempt { version?: number; outcome: string; at?: string; after?: string } /** Sent to the ordering practice's staff when another Akashic laboratory files a result on its order. */ export const LAB_RETURN_EVENT = "com.akashichealthrecords.fhir.servicerequest.returned"; /** Sent when that laboratory accepts the order, and when it refuses it. */ export const LAB_ACCEPT_EVENT = "com.akashichealthrecords.fhir.servicerequest.accepted"; export const LAB_REFUSE_EVENT = "com.akashichealthrecords.fhir.servicerequest.refused"; /** Sent to this practice's own staff when another of its screens moves one of its laboratory orders. */ export const LAB_QUEUED_EVENT = "com.akashichealthrecords.fhir.servicerequest.queued"; export const LAB_RELEASED_EVENT = "com.akashichealthrecords.fhir.servicerequest.released"; export const LAB_SENT_EVENT = "com.akashichealthrecords.fhir.servicerequest.sent"; export const LAB_ADVANCED_EVENT = "com.akashichealthrecords.fhir.servicerequest.advanced"; export const LAB_ENTERED_EVENT = "com.akashichealthrecords.fhir.servicerequest.entered"; export const LAB_DELIVERY_EVENT = "com.akashichealthrecords.fhir.servicerequest.delivery"; /** Sent to both practices when a refusal or a refused sample is recorded on an order, or marked dealt with. */ export const LAB_REJECTION_EVENT = "com.akashichealthrecords.fhir.servicerequest.rejection"; /** How long one queue read may take before it is cancelled and said to be late. */ export const QUEUE_READ_DEADLINE_MS = 20_000; /** How long a notice is followed, one read at a time, before the list says it may be behind. */ export const QUEUE_FOLLOW_WINDOW_MS = 120_000; export const QUEUE_FOLLOW_EVERY_MS = 3000; /** How long, and over how many reads, an order must stay out of the search before it leaves the list. */ export const QUEUE_ABSENCE_MS = 60_000; export const QUEUE_ABSENCE_READS = 2; // What a notice is waiting to see on its order: where the order stood when it came, and the return attempt it names. type Wait = { orderId: string; step: Step; before?: OrderProgress; version?: number; outcome?: string; at?: string; until: number }; // A step is shown once the order has moved past where it stood when the notice came, or, on the screen that made the // change, once the queue is no older than the change it confirmed. type Step = { event: string; shows: (row: DiagnosticOrderQueueRow, moved: boolean, wait: Wait) => boolean }; const expectsReturn = (row: DiagnosticOrderQueueRow) => row.returned !== undefined || Boolean(row.fromOutside) || Boolean(row.fromAkashic); // A return attempt is shown when the row carries the version and outcome the notice named, or a later version; unnamed, // a later version or a later timed attempt. const returnShown = (row: DiagnosticOrderQueueRow, wait: Wait) => { const now = progressOf(row); if (!row.returned || row.returned.outcome === "none") return false; const before = wait.before; // A later attempt than the row showed when the notice came: a later version, or a later recorded time at that version. const later = !before || now.returnVersion > before.returnVersion || (now.returnVersion === before.returnVersion && now.returnAt > before.returnAt); if (wait.version === undefined) return later; if (now.returnVersion !== wait.version) return now.returnVersion > wait.version; if (wait.outcome !== undefined && row.returned.outcome !== wait.outcome) return false; // Named by its recorded time, the attempt is shown once the row carries it; untimed, once the row shows a later one. return wait.at ? now.returnAt >= Date.parse(wait.at) : later; }; // A recorded time, or none when it is missing or unreadable. const timeOf = (at?: string): number | undefined => { const parsed = Date.parse(at ?? ""); return Number.isFinite(parsed) ? parsed : undefined; }; // Whether a notice names a later attempt than the one already awaited, so a delayed older notice never replaces it. At one // version a timed attempt replaces an untimed wait, and an untimed notice never replaces a timed one. const laterTarget = (next: { version: number; at?: string }, known?: { version?: number; at?: string }) => { if (known?.version === undefined || next.version > known.version) return true; if (next.version !== known.version) return false; const nextAt = timeOf(next.at); const knownAt = timeOf(known.at); return nextAt !== undefined && (knownAt === undefined || nextAt > knownAt); }; const LAB_STEPS: Step[] = [ { event: LAB_ACCEPT_EVENT, shows: (row) => Boolean(row.akashicRoute?.acceptedAt) }, { event: LAB_REFUSE_EVENT, shows: (row) => Boolean(row.akashicRoute?.refusedAt) }, { event: LAB_RETURN_EVENT, shows: (row) => Boolean(row.akashicRoute?.returnedAt) }, { event: LAB_QUEUED_EVENT, shows: () => true }, { event: LAB_SENT_EVENT, shows: (row) => Boolean(row.sentOut) || Boolean(row.akashicRoute?.deliveredAt) || row.state === "sent" }, // Released, and where the result goes back, with the evidence of how its return went: a failed return stays failed. { event: LAB_RELEASED_EVENT, shows: (row, moved, wait) => moved && progressOf(row).released && (!expectsReturn(row) || returnShown(row, wait)), }, { event: LAB_ADVANCED_EVENT, shows: (_row, moved) => moved }, { event: LAB_ENTERED_EVENT, shows: (_row, moved) => moved }, { event: LAB_DELIVERY_EVENT, shows: (row, _moved, wait) => returnShown(row, wait) }, { event: LAB_REJECTION_EVENT, shows: (_row, moved) => moved }, ]; /** * A return the server confirmed to this screen, laid over what the row already showed. An untimed confirmation at the * version the row had follows the attempt the row carried, so a later read of that attempt is older. */ const confirmedReturn = (known: ReturnAttempt | undefined, confirmed: ReturnAttempt): ReturnAttempt => { if (confirmed.at || !known || known.version !== confirmed.version) return confirmed; const after = known.at ?? known.after; return after ? { ...confirmed, after } : confirmed; }; /** A completion the dialog hands back late is laid over the row only if it is no older than the return the row carries. */ const completedReturn = (row: DiagnosticOrderQueueRow, confirmed: ReturnAttempt): ReturnAttempt => { const next = confirmedReturn(row.returned, confirmed); if (!row.returned) return next; return returnIsOlder(progressOf({ ...row, returned: next }), progressOf(row)) ? row.returned : next; }; /** What the send-out route answered. */ export interface SentOutAnswer { state?: string; laboratory?: string; sentAt?: string; akashic?: { laboratory?: string; deliveredAt?: string }; transmission?: string; } export interface AkashicRoute { laboratory?: string; deliveredAt?: string; acceptedAt?: string; refusedAt?: string; returnedAt?: string; } interface DiagnosticOrderQueueResponse { orders?: { taskId?: string; orderId?: string; kind?: string; state?: string; awaitingRelease?: boolean; refusal?: { reason?: string; nextStep?: string; theirCode?: string; theirMessage?: string; recorded?: string }; pastRefusal?: { reason?: string; nextStep?: string; theirCode?: string; theirMessage?: string; recorded?: string; resolved?: string; }; rejection?: { reason?: string; disposition?: string; recorded?: string }; test?: string; testCode?: string; testCodeSystem?: string; priority?: string; authoredOn?: string; patientDisplay?: string; patientAge?: number; priors?: number; openedBy?: { name?: string; at: string; byYou: boolean }; encounterId?: string; resultOnFile?: boolean; fromOutside?: string; theirOrderNumber?: string; orderedBy?: string; question?: string; panelId?: string; accession?: string; sentOut?: { laboratory?: string; at?: string }; transmission?: { outcome?: string; at?: string }; labConfirmed?: unknown; transmissionOwed?: boolean; deliveryOwed?: boolean; withdrawable?: boolean; returned?: ReturnAttempt; akashicRoute?: AkashicRoute; fromAkashic?: { orderedBy?: string; theirOrderId: string }; scheduledFor?: string; room?: string; modality?: string; imageImport?: ImageImport; performed?: { by?: string; at: string }; images?: { studies: number; instances: number }; report?: { writtenAt?: string; writtenByYou?: boolean; releasedAt?: string; addendedAt?: string }; readElsewhere?: ReadElsewhere; outsideRead?: OutsideRead; readFor?: { from?: string; theirOrder?: string }; }[]; } export const toRow = (raw: NonNullable[number]): DiagnosticOrderQueueRow => ({ taskId: raw.taskId, orderId: raw.orderId, // An unrecognised kind reads as undefined for the same reason an unrecognised state does: a row // whose machine we cannot identify must be offered no moves at all. kind: raw.kind === "laboratory" || raw.kind === "imaging" ? raw.kind : undefined, // An unrecognised state reads as UNDEFINED rather than being passed through. A row whose state we // cannot interpret must not be offered a move, and letting an unknown string reach the UI would // put a button next to it whose only outcome is a refusal. state: isDiagnosticOrderState(raw.state) ? raw.state : undefined, awaitingRelease: raw.awaitingRelease, refusal: raw.refusal, pastRefusal: raw.pastRefusal, rejection: raw.rejection, test: raw.test, testCode: raw.testCode, testCodeSystem: raw.testCodeSystem, priority: raw.priority, authoredOn: raw.authoredOn, patientDisplay: raw.patientDisplay, patientAge: raw.patientAge, priors: raw.priors, openedBy: raw.openedBy, encounterId: raw.encounterId, resultOnFile: raw.resultOnFile, fromOutside: raw.fromOutside, // A study read here for another practice is filed there against their number, as a paper order is. theirOrderNumber: raw.theirOrderNumber ?? raw.readFor?.theirOrder, orderedBy: raw.orderedBy, question: raw.question, panelId: raw.panelId, accession: raw.accession, sentOut: raw.sentOut, transmission: raw.transmission, ...(raw.labConfirmed === true ? { labConfirmed: true as const } : {}), transmissionOwed: raw.transmissionOwed === true ? true : undefined, deliveryOwed: raw.deliveryOwed === true ? true : undefined, withdrawable: raw.withdrawable === true ? true : undefined, returned: raw.returned, akashicRoute: raw.akashicRoute, fromAkashic: raw.fromAkashic, scheduledFor: raw.scheduledFor, room: raw.room, modality: raw.modality, imageImport: raw.imageImport, performed: raw.performed, images: raw.images, report: raw.report, readElsewhere: raw.readElsewhere, outsideRead: raw.outsideRead, readFor: raw.readFor, }); /** What a department records when it books a study. Every field is optional. */ export interface ImageImport { state: "pending" | "failed"; uploadId: string; /** The HealthImaging job, once started; Check again needs it. */ jobId?: string; startedAt: string; reason?: string; /** Already filed on another order: uploading it again here would file nothing. */ alreadyFiled?: true; /** The import finished but is not filed: its files are still being checked, failed the check, or could not be confirmed. */ filingHeld?: "held" | "blocked" | "unscannable" | "unknown"; /** A refused upload staff discarded; the study may be uploaded again. */ discarded?: true; } export interface ImagingSchedule { at?: string; room?: string; /** One of the practice's room Locations; the server stamps its stored name. */ roomId?: string; modality?: string; } /** A read or write that finished after its list was closed, or opened for another viewer, session or practice. */ export class QueueRetiredError extends Error { constructor() { super("This list was closed or changed before the answer came back."); this.name = "QueueRetiredError"; } } class QueueDeadlineError extends Error {} // A read overtaken by a later one that already settled; it changes nothing and says nothing. class QueueSupersededError 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 QueueSupersededError; const hidden = () => typeof document !== "undefined" && document.visibilityState === "hidden"; // What one opening of the list, 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 { rows: DiagnosticOrderQueueRow[]; started: number; settled: number; reading?: Promise; cancelled: Set; sentHere: Map>; ahead: Map; missing: Map; waiting: Map; following: boolean; } const newOpening = (): Opening => ({ rows: [], started: 0, settled: 0, cancelled: new Set(), sentHere: new Map(), ahead: new Map(), missing: new Map(), waiting: new Map(), following: false, }); interface View { owner: object; rows: DiagnosticOrderQueueRow[]; loading: boolean; error?: string } const failureText = (err: unknown) => (err instanceof QueueDeadlineError ? "Lab orders took too long to answer. Reload to try again." : isRefusal(err) ? relayServerFailure(err, "Lab orders are not available to you now.") : relayServerFailure(err, "Could not load lab orders. Please try again.")); export const useDiagnosticOrderQueue = () => { const { get, 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; }, []); const admitFor = useCallback((of: ListOwner) => () => { if (!live(of)) throw new QueueRetiredError(); }, [live]); const [view, setView] = useState({ owner, rows: [], loading: true }); // A replaced opening's rows, error and loading are concealed in the same render that replaces it. const shown: View = view.owner === owner ? view : { owner, rows: [], loading: true }; const publish = useCallback((of: ListOwner, change: Partial) => { if (!live(of)) return; setView((prev) => ({ ...(prev.owner === of ? prev : { owner: of, rows: [], loading: true }), ...change, owner: of })); }, [live]); const fresh = useCallback((opening: Opening, response?: DiagnosticOrderQueueResponse) => (response?.orders ?? []).map(toRow).filter((row) => !(row.orderId && opening.cancelled.has(row.orderId))).map((row) => { const sent = row.orderId ? opening.sentHere.get(row.orderId) : undefined; if (!sent) return row; if (row.sentOut || row.akashicRoute?.deliveredAt) { opening.sentHere.delete(row.orderId!); return row; } return { ...row, ...sent }; }), []); // An order the search left out stays until it has been absent for a while and over more than one read, or was cancelled. const settledAbsence = useCallback((opening: Opening, read: DiagnosticOrderQueueRow[]) => { const answered = new Set(read.map((row) => row.orderId)); const now = Date.now(); opening.rows.forEach((row) => { if (!row.orderId) return; if (answered.has(row.orderId)) { opening.missing.delete(row.orderId); return; } const gone = opening.missing.get(row.orderId) ?? { since: now, reads: 0 }; gone.reads += 1; opening.missing.set(row.orderId, gone); }); return (row: DiagnosticOrderQueueRow) => { const gone = row.orderId ? opening.missing.get(row.orderId) : undefined; if (!gone || opening.cancelled.has(row.orderId!)) return false; if (gone.reads < QUEUE_ABSENCE_READS || now - gone.since < QUEUE_ABSENCE_MS) return true; opening.missing.delete(row.orderId!); return false; }; }, []); // One read, owned by its opening, cancelled at its deadline. A follow or a wait 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. // A read `owned` by one operation (a discard's recount) never joins or is joined: it is admitted, cancelled and // sent in that operation's practice too, and once that operation is retired it publishes and settles nothing. const readQueue = useCallback((of: ListOwner, join = false, owned?: OwnedRequest): Promise => { if (!live(of)) return Promise.reject(new QueueRetiredError()); const opening = openingOf(of); if (join && !owned && opening.reading) return opening.reading; const read = ++opening.started; const ownerRetired = () => { try { owned?.admit?.(); return false; } catch { return true; } }; let reading: Promise | undefined; reading = (async () => { const controller = new AbortController(); if (owned?.signal?.aborted) controller.abort(); owned?.signal?.addEventListener("abort", () => controller.abort(), { once: true }); 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 QueueDeadlineError()); }, QUEUE_READ_DEADLINE_MS); }); const listAdmit = admitFor(of); const admit = () => { listAdmit(); owned?.admit?.(); }; try { const response = await Promise.race([late, get(`${environment.fhirBaseApi}/diagnostic-order/queue`, { signal: controller.signal, admit, ...(owned?.organizationId ? { organizationId: owned.organizationId } : {}), })]); admit(); if (read <= opening.settled) return undefined; opening.settled = read; const answered = fresh(opening, response); opening.rows = mergeQueueRead(opening.rows, answered, settledAbsence(opening, answered)); listedBy.set(opening.rows, answered); publish(of, { rows: opening.rows, error: undefined, loading: false }); return opening.rows; } catch (err) { if (!live(of) || err instanceof QueueRetiredError) throw new QueueRetiredError(); if (ownerRetired()) throw err; if (read <= opening.settled) throw new QueueSupersededError(); opening.settled = read; throw controller.signal.aborted ? new QueueDeadlineError() : err; } finally { clearTimeout(timer); if (opening.reading === reading) opening.reading = undefined; } })(); if (!owned) opening.reading = reading; return reading!; }, [get, fresh, live, openingOf, publish, admitFor, settledAbsence]); // 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]); /** Reads the queue again: what this read listed, before rows the search left out are kept on screen, or false. */ const load = useCallback(async (after?: unknown): Promise => { const of = owner; publish(of, { loading: true }); try { const rows = await readQueue(of); const listed = rows ? listedBy.get(rows) : undefined; if (!listed) return false; // A withdrawn order leaves the queue, so the order itself is read to tell what became of it. const orderId = typeof after === "string" ? after : undefined; if (!orderId || listed.some((row) => row.orderId === orderId)) return { listed }; // Owned like the list read: admitted for this opening before it is sent, cancelled at the same deadline, and // answered only while the opening is still the one on screen. const controller = new AbortController(); let timer: ReturnType | undefined; const late = new Promise((resolve) => { timer = setTimeout(() => { controller.abort(); resolve(undefined); }, QUEUE_READ_DEADLINE_MS); }); const order = await Promise.race([late, get(`${environment.fhirClinicalApi}/ServiceRequest/${encodeURIComponent(orderId)}`, { signal: controller.signal, admit: admitFor(of), }).catch(() => undefined)]).finally(() => clearTimeout(timer)); if (!live(of)) return false; return { listed, order: order?.resourceType === "ServiceRequest" && order.id === orderId ? order : undefined }; } catch (err) { if (isQuiet(err)) return false; console.error("Error loading the lab queue:", err); publish(of, { error: failureText(err) }); return false; } finally { settleLoading(of); } }, [owner, readQueue, publish, settleLoading, get, admitFor, live]); /** * Re-reads the queue without the loading state and answers with one order's image count, so an * upload can wait for its study to be filed while the list stays on screen. */ const recountImages = useCallback(async (orderId: string, owned?: OwnedRequest) => { const next = await readQueue(owner, true, owned); return next?.find((row) => row.orderId === orderId)?.images; }, [owner, readQueue]); // A change this screen made, confirmed by the server, is held over any later read that carries an older copy. const confirm = useCallback((of: ListOwner, orderId: string, change: (row: DiagnosticOrderQueueRow) => DiagnosticOrderQueueRow) => { if (!live(of)) throw new QueueRetiredError(); const opening = openingOf(of); opening.rows = opening.rows.map((row) => { if (row.orderId !== orderId) return row; const next = change(row); opening.ahead.set(orderId, progressOf(next)); return next; }); publish(of, { rows: opening.rows }); }, [live, openingOf, publish]); /** * Move one order forward. * * NEVER SHOWS A STATE THE SERVER DID NOT CONFIRM, and does not reload to find out: the queue is a projection over an * eventually consistent search, so a read straight after the write can answer without the change and the row appears * not to have moved. The move has succeeded and the response carries the state the server set. A refusal rejects * and nothing below runs. The move belongs to the list it was offered on: once that list is closed or changed it is * not sent, and an answer that arrives after is not shown or reported as done. */ const advance = useCallback( async (orderId: string, state: DiagnosticOrderState, schedule?: ImagingSchedule) => { const of = owner; const admit = admitFor(of); admit(); const response = await put<{ orderId?: string; state?: DiagnosticOrderState; schedule?: ImagingSchedule; performed?: { by?: { display?: string }; at: string }; accession?: string; images?: { studies: number; instances: number }; }>( `${environment.fhirBaseApi}/diagnostic-order/${orderId}/state`, schedule ? { state, schedule } : { state }, { admit }, ); confirm(of, orderId, (row) => ({ ...row, // The server's answer, falling back to the move it accepted: a 200 means the transition happened. state: response?.state ?? state, ...(response?.schedule?.at ? { scheduledFor: response.schedule.at } : {}), ...(response?.schedule?.room ? { room: response.schedule.room } : {}), ...(response?.schedule?.modality ? { modality: response.schedule.modality } : {}), ...(response?.performed ? { performed: { at: response.performed.at, by: response.performed.by?.display } } : {}), ...(response?.accession ? { accession: response.accession } : {}), // Counted by the server as the study was performed, so Upload is offered without a reload. ...(response?.images ? { images: response.images } : {}), })); }, [owner, put, confirm, admitFor], ); /** Cancels a study; only once the server confirms does its row leave, and no later read restores it. */ const cancelStudy = useCallback(async (orderId: string, reason: ArchiveReason, note?: string) => { const of = owner; const admit = admitFor(of); admit(); await put(`${environment.fhirClinicalApi}/diagnostic-order/${orderId}/cancel`, { reason, note }, { admit }); admit(); const opening = openingOf(of); opening.cancelled.add(orderId); opening.rows = opening.rows.filter((row) => row.orderId !== orderId); publish(of, { rows: opening.rows }); }, [owner, put, admitFor, openingOf, publish]); /** * The row after somebody released its result, from the server's own answer. Not a reload, which would answer * without the change and unmount the dialog that called it. */ const markReleased = useCallback((orderId: string, state?: DiagnosticOrderState, returned?: { outcome: string; version?: number; at?: string }) => { if (!live(owner)) return; confirm(owner, orderId, (row) => ({ ...row, awaitingRelease: false, resultOnFile: true, state: state ?? row.state, report: { ...row.report, releasedAt: new Date().toISOString() }, // The return the release itself attempted, so a failed one shows on the row at once. ...(returned ? { returned: completedReturn(row, { ...returned, version: returned.version ?? 0 }) } : {}), })); }, [owner, live, confirm]); /** * The return line after a delivery was recorded or the report gained an addendum, from the server's own answer. * Only a row that has a return state, or goes back to an outside sender, is touched. */ const markReturned = useCallback((orderId: string, returned: { version: number; outcome: string; at?: string }) => { if (!live(owner)) return; confirm(owner, orderId, (row) => (row.returned || row.fromOutside || row.fromAkashic ? { ...row, returned: completedReturn(row, returned) } : row)); }, [owner, live, confirm]); /** The row after it was sent out, from the server's own answer, kept over later reads until the queue shows it. */ const markSentOut = useCallback((orderId: string, sent: SentOutAnswer) => { if (!live(owner)) return; const change: Partial = { state: isDiagnosticOrderState(sent.state) ? sent.state : "sent", sentOut: { laboratory: sent.laboratory, at: sent.sentAt }, ...(sent.akashic ? { akashicRoute: { laboratory: sent.akashic.laboratory, deliveredAt: sent.akashic.deliveredAt } } : {}), ...(sent.transmission === "refused" || sent.transmission === "unconfirmed" ? { transmissionOwed: true } : {}), }; openingOf(owner).sentHere.set(orderId, change); confirm(owner, orderId, (row) => ({ ...row, ...change })); }, [owner, live, confirm, openingOf]); useEffect(() => { // Not gated on an organization id: the SERVER takes the organization from the authorizer context. void load(); }, [load]); /** * Re-reads the queue quietly until an order a write just created is on it. The queue is a search that lags a write, * so one read straight after can miss it; bounded, like the image upload's wait. Answers false once the list is * closed or changed, or access is refused, rather than a success nobody can see. */ const waitForOrder = useCallback(async (orderId: string, attempts = 10, delayMs = 3000, shows: (row: DiagnosticOrderQueueRow) => boolean = () => true) => { const of = owner; for (let attempt = 1; attempt <= attempts; attempt++) { if (!live(of)) return false; try { const next = await readQueue(of, true); if (!live(of)) return false; if (next?.some((row) => row.orderId === orderId && shows(row))) return true; } catch (err) { if (err instanceof QueueRetiredError || isRefusal(err)) return false; // A failed or overtaken read is one more attempt, not a verdict on the write. if (!(err instanceof QueueSupersededError)) console.error("Error re-reading the lab queue:", err); } if (attempt < attempts) await new Promise((resolve) => setTimeout(resolve, delayMs)); } return false; }, [owner, live, readQueue]); // Notices about this practice's orders. Each names only an order; the queue search lags the write, so the order is // followed until it 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 readQueue(of, true); } catch (err) { if (err instanceof QueueRetiredError) return; if (err instanceof QueueDeadlineError || isRefusal(err)) { opening.waiting.clear(); publish(of, { error: failureText(err) }); return; } if (!(err instanceof QueueSupersededError)) console.error("Error following the lab queue:", err); } const now = Date.now(); let behind = false; opening.waiting.forEach((wait, key) => { const row = opening.rows.find((each) => each.orderId === wait.orderId); const ahead = opening.ahead.get(wait.orderId); const moved = Boolean(row) && ( (wait.before ? movedPast(progressOf(row!), wait.before) : true) || (ahead !== undefined && !isOlderCopy(progressOf(row!), ahead)) ); if (row && wait.step.shows(row, moved, wait)) opening.waiting.delete(key); else if (now >= wait.until) { opening.waiting.delete(key); behind = true; } }); if (behind) publish(of, { error: "Lab orders may be behind a change made on another screen. Reload to check." }); if (opening.waiting.size === 0) return; await new Promise((resolve) => setTimeout(resolve, QUEUE_FOLLOW_EVERY_MS)); } } finally { opening.following = false; } }, [live, openingOf, publish, readQueue]); const socket = useOptionalWebSocketServiceContext(); const subscribe = socket?.subscribe; const unsubscribe = socket?.unsubscribe; useEffect(() => { if (!subscribe || !unsubscribe) return undefined; const listeners = LAB_STEPS.map((step) => { const heard = (notice: { data?: { orderId?: unknown; version?: unknown; outcome?: unknown; at?: unknown } } | undefined) => { const orderId = notice?.data?.orderId; if (typeof orderId !== "string" || !orderId || !live(owner)) return; const version = notice?.data?.version; const outcome = notice?.data?.outcome; const noticeAt = typeof notice?.data?.at === "string" && timeOf(notice.data.at) !== undefined ? notice.data.at : undefined; const opening = openingOf(owner); const key = step.event + "#" + orderId; const row = opening.rows.find((each) => each.orderId === orderId); const known = opening.waiting.get(key); opening.waiting.set(key, { orderId, step, before: known ? known.before : row ? progressOf(row) : undefined, ...(Number.isInteger(version) && laterTarget({ version: version as number, at: noticeAt }, known) ? { version: version as number, ...(typeof outcome === "string" ? { outcome } : {}), ...(noticeAt ? { at: noticeAt } : {}) } : known?.version !== undefined ? { version: known.version, outcome: known.outcome, at: known.at } : {}), until: Date.now() + QUEUE_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]); // 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() + QUEUE_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 { rows: shown.rows, loading: shown.loading, error: shown.error, reload: load, recountImages, waitForOrder, advance, cancelStudy, markReleased, markReturned, markSentOut, }; };