import { useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"; import type { Attachment, DiagnosticReport, Observation, Reference, ServiceRequest } from "fhir/r4"; import { useDiagnosticReportServiceContext } from "../services/DiagnosticReportService.tsx"; import { readQualityControlPosition, type QualityControlPosition } from "../utils/qualityControlNotice"; import { useHttpService } from "../services/HttpService.tsx"; import { useOptionalWebSocketServiceContext } from "../services/WebSocketService.tsx"; import { returnedResultWithheld } from "../utils/returnedPublication"; import { environment } from "../environments/environment.ts"; import type { DiagnosticOrderStudy } from "./useDiagnosticOrderStudies.ts"; import { readEnteredReportReference } from "../utils/diagnosticOrderResult"; import { referencedId } from "../utils/fhirReferencedId"; import { SaveRetiredError, type SaveTransport } from "../utils/saveOperation"; import { isSignedReport, readAddenda, readerOf, type ReportAddendum } from "../utils/reportAddendum"; import { readReportText } from "../utils/reportText"; import { buildResultDetail, type ResultLine } from "../utils/patientResultDetail"; import { onViewerChange, viewerEpoch } from "../utils/writeThroughHold"; // Refused to this viewer, which a reload does not change: read off the status every HTTP failure here carries. const isRefusal = (error: unknown) => { const status = (error as { status?: unknown } | undefined)?.status; return status === 401 || status === 403; }; const IMAGING_CATEGORY_CODE = "363679005"; export interface OrderResultDetail { /** The laboratory's own documents, filed with the result or attached afterwards. */ reports: Attachment[]; /** What was added after the report was signed, in the order it was written. */ addenda: ReportAddendum[]; /** Who signed the report and when, with its words when it has any; absent while it is not yet signed. */ signed?: SignedReport; /** The imaging studies the report names, as ImagingStudy ids. */ studies?: string[]; /** What was measured, each with its recorded value, range and flag, read from the report's own results. */ values?: ResultLine[]; /** Why the values are not shown: a returned result still being filed here, or withdrawn here. */ withheld?: string; /** Values the report names that could not be read. */ valuesUnread?: number; /** The report itself could not be read: the row says so rather than looking as though nothing was filed. */ unavailable?: boolean; /** The report was refused to this viewer, which a reload does not change. */ denied?: boolean; /** Values the report names that were refused to this viewer. */ valuesDenied?: number; /** Values the report names that are not this patient's on this order, or not named in a form that can be read. */ valuesUnproven?: number; /** The report is not this patient's on this order, or not named in a form that can be read: nothing of it is shown. */ unproven?: boolean; /** This row's current report is still being read; nothing of an earlier one is shown meanwhile. */ checking?: boolean; /** Where quality control stood when the result was released, as stamped on the signed report. */ qualityControl?: QualityControlPosition; } export interface SignedReport { findings?: string; impression?: string; /** The reader as a screen shows them: a name when the reference carries one, its id otherwise. */ by?: string; at?: string; } /** * What a chart shows off the report on each of a list of orders, keyed by order. * * READ FROM THE ORDER, NOT FROM A SECOND SEARCH. Each order already names its report -- the marker a * worklist reads to know a result is waiting -- so nothing new has to be projected for this. * * ONE READ PER ORDER THAT HAS A RESULT, and none for an order that does not. A chart holding a dozen * orders of which two have been resulted makes two requests, not a dozen. * * BEST EFFORT. A report that will not load costs its own row an attachment and nothing else: the * orders around it are what the screen is for, and a chart that refuses to render because one PDF is * unreachable is worse than one missing document. */ const readSigned = (report: DiagnosticReport): SignedReport | undefined => { if (!isSignedReport(report)) return undefined; const { findings, impression } = readReportText(report); // Who signed and when, with or without words: a returned lab is often its values alone, and is still signed. return { findings, impression, by: readerOf(report.resultsInterpreter?.[0]), at: report.issued }; }; const isImagingOrder = (order: ServiceRequest): boolean => (order.category ?? []).some((category) => (category.coding ?? []).some((coding) => coding.code === IMAGING_CATEGORY_CODE)); type Wanted = { orderId: string; reference: string; kind: string }; type Held = { key: string; detail?: OrderResultDetail }; const CHECKING: OrderResultDetail = { reports: [], addenda: [], checking: true }; const UNPROVEN: OrderResultDetail = { reports: [], addenda: [], unproven: true }; // Every producer copies the order's subject and basedOn onto its report and values, so both must agree. const boundTo = (resource: { subject?: Reference; basedOn?: Reference[] }, patientId: string, orderId: string): boolean => referencedId(resource.subject?.reference, "Patient") === patientId && (!resource.basedOn?.length || resource.basedOn.some((based) => referencedId(based.reference, "ServiceRequest") === orderId)); export const useOrderLabReports = (orders: ServiceRequest[] | null, patientId?: string) => { const { fetchDiagnosticReport } = useDiagnosticReportServiceContext(); const [held, setHeld] = useState>({}); // The viewer and practice the page is read for; a change rereads even when the list is the same. const epoch = useSyncExternalStore(onViewerChange, viewerEpoch); // THE FETCHER IS HELD RATHER THAN DEPENDED ON, and that is not a style choice: depending on it made // this loop forever and exhaust the heap the moment a caller handed over a function it rebuilt each // render -- fetch, set state, re-render, new function, fetch. What this effect is actually about is // which reports are wanted, and the signature below says that exactly. const { get } = useHttpService(); const fetcher = useRef(fetchDiagnosticReport); fetcher.current = fetchDiagnosticReport; const getter = useRef(get); getter.current = get; // `orders` is rebuilt on every load, so a new array identity would refetch every report each time // anything on the page changed. const signature = JSON.stringify((orders ?? []) .map((order): Wanted => ({ orderId: order.id ?? "", reference: readEnteredReportReference(order) ?? "", kind: isImagingOrder(order) ? "imaging" : "" })) .filter((want) => want.orderId && want.reference) .sort((a, b) => a.orderId.localeCompare(b.orderId))); const owner = `${epoch}|${patientId ?? ""}`; const keyOf = (want: Wanted) => `${owner}\n${want.reference}\n${want.kind}`; // Another viewer starts with nothing of the last one's. useEffect(() => onViewerChange(() => setHeld({})), []); // A report on screen, or a value it names, changed elsewhere: that row goes back to checking at once and is read // again, so an open order never keeps words or values a newer publication held back. Resource id to order id. const socket = useOptionalWebSocketServiceContext(); const watched = useRef(new Map()); // Moved on the moment a change is heard, so an answer still out for the opening before it is retired at once. const generation = useRef(0); const [changed, setChanged] = useState(0); useEffect(() => { if (!socket) return; const heard = (cloudEvent: { data?: { id?: string } }) => { const orderId = cloudEvent?.data?.id ? watched.current.get(cloudEvent.data.id) : undefined; if (!orderId) return; generation.current += 1; setHeld((last) => { if (!(orderId in last)) return last; const next = { ...last }; delete next[orderId]; return next; }); setChanged((count) => count + 1); }; const channels = ["diagnosticreport.created", "diagnosticreport.updated", "observation.created", "observation.updated"] .map((event) => `com.akashichealthrecords.fhir.${event}`); channels.forEach((channel) => socket.subscribe(channel, heard)); return () => channels.forEach((channel) => socket.unsubscribe(channel, heard)); }, [socket]); useEffect(() => { const wanted = JSON.parse(signature) as Wanted[]; if (!wanted.length || !patientId) { setHeld((last) => (Object.keys(last).length ? {} : last)); return; } let live = true; const controller = new AbortController(); const kept = new Set(wanted.map((want) => want.orderId)); // Each requested report is watched before it is read, and the values an order named stay watched until its // replacement read names them again, so a change made while either read is out is still heard. watched.current = new Map([ ...[...watched.current].filter(([, orderId]) => kept.has(orderId)), ...wanted.flatMap((want): [string, string][] => { const id = referencedId(want.reference, "DiagnosticReport"); return id ? [[id, want.orderId]] : []; }), ]); const mine = generation.current; const owns = () => live && viewerEpoch() === epoch && generation.current === mine; // Every read in this chain is admitted by the opening it was started for, so a retired one sends nothing more. const transport: SaveTransport = { admit: () => { if (!owns()) throw new SaveRetiredError(); }, signal: controller.signal }; // Each row is published as it resolves, and only into the opening it was read for. const publish = (orderId: string, key: string, detail?: OrderResultDetail) => { if (!owns()) return; setHeld((last) => ({ ...Object.fromEntries(Object.entries(last).filter(([id]) => kept.has(id))), [orderId]: { key, detail }, })); }; // A report names its study only when one was picked; the study filed on the order still answers it. const ownStudies = async (orderId: string): Promise => { try { const response = await getter.current<{ studies?: DiagnosticOrderStudy[] }>( `${environment.fhirBaseApi}/diagnostic-order/${orderId}/imaging-studies`, transport, ); return (response?.studies ?? []).filter((study) => study.forThisOrder && study.id).map((study) => study.id as string); } catch { return []; } }; // The values the report names, each read by id and shown only as this patient's value on this order. const readValues = async (report: DiagnosticReport, orderId: string) => { const named = report.result ?? []; const ids = named.map((ref) => referencedId(ref.reference, "Observation")); let unproven = ids.filter((id) => !id).length; let denied = 0; const read = await Promise.all(ids.filter((id): id is string => Boolean(id)).map(async (id) => { try { const observation = await getter.current(`${environment.fhirClinicalApi}/Observation/${id}`, transport); if (observation?.resourceType === "Observation" && observation.id === id && boundTo(observation, patientId, orderId)) return observation; unproven += 1; } catch (error) { if (isRefusal(error)) denied += 1; } return undefined; })); const observations = read.filter((observation): observation is Observation => Boolean(observation)); // A returned result is paired against everything its report names, so a value not read here holds the set back. const withheld = returnedResultWithheld(report, observations); if (withheld) return { values: [], unread: 0, denied, unproven, withheld }; const proven = { ...report, result: observations.map((observation) => ({ reference: `Observation/${observation.id}` })) }; const values = buildResultDetail(proven, observations).lines; // A value withdrawn here is not one still to arrive. const withdrawn = observations.filter((observation) => observation.status === "entered-in-error").length; return { values, unread: Math.max(0, named.length - values.length - denied - unproven - withdrawn), denied, unproven }; }; wanted.forEach((want) => void (async () => { const { orderId, kind } = want; const key = keyOf(want); const reportId = referencedId(want.reference, "DiagnosticReport"); if (!reportId) return publish(orderId, key, UNPROVEN); try { const report = await fetcher.current(reportId, false, transport); if (!owns()) return; watched.current.set(reportId, orderId); (report?.result ?? []).forEach((each) => { const id = referencedId(each.reference, "Observation"); if (id) watched.current.set(id, orderId); }); if (report?.resourceType !== "DiagnosticReport" || report.id !== reportId || !boundTo(report, patientId, orderId)) { return publish(orderId, key, UNPROVEN); } const named = (report.imagingStudy ?? []) .map((study) => referencedId(study.reference, "ImagingStudy")) .filter((id): id is string => Boolean(id)); const studies = named.length || kind !== "imaging" ? named : await ownStudies(orderId); if (!owns()) return; const { values, unread, denied, unproven, withheld } = await readValues(report, orderId); // A held-back set keeps its words, documents and checks back with its numbers. const forms = withheld ? [] : (report.presentedForm ?? []).filter((form) => Boolean(form.url)); const addenda = withheld ? [] : readAddenda(report); const signed = withheld ? undefined : readSigned(report); const qualityControl = withheld ? undefined : readQualityControlPosition(report); if (!(forms.length || addenda.length || signed || studies.length || values.length || unread || denied || unproven || withheld || qualityControl)) { return publish(orderId, key); } publish(orderId, key, { reports: forms, addenda, ...(signed ? { signed } : {}), ...(studies.length ? { studies } : {}), ...(values.length ? { values } : {}), ...(unread ? { valuesUnread: unread } : {}), ...(denied ? { valuesDenied: denied } : {}), ...(unproven ? { valuesUnproven: unproven } : {}), ...(withheld ? { withheld } : {}), ...(qualityControl ? { qualityControl } : {}), }); } catch (error) { if (!owns()) return; console.error(`Could not read the report filed on order ${orderId}:`, error); publish(orderId, key, isRefusal(error) ? { reports: [], addenda: [], denied: true } : { reports: [], addenda: [], unavailable: true }); } })()); return () => { live = false; controller.abort(); }; // eslint-disable-next-line react-hooks/exhaustive-deps -- keyOf is made of epoch and patientId }, [signature, epoch, patientId, changed]); // Only a detail read for this row's current report, viewer and patient is shown; anything else is still checking. return useMemo(() => { const shown: Record = {}; if (!patientId) return shown; for (const want of JSON.parse(signature) as Wanted[]) { const entry = held[want.orderId]; if (!entry || entry.key !== keyOf(want)) shown[want.orderId] = CHECKING; else if (entry.detail) shown[want.orderId] = entry.detail; } return shown; // eslint-disable-next-line react-hooks/exhaustive-deps -- keyOf is made of owner }, [held, signature, owner, patientId]); };