import type { Attachment, DiagnosticReport, Observation } from "fhir/r4"; import { vitalValue } from "./patientVisitDetail"; import { reportTextAsOne } from "./reportText"; import { isSignedReport, readAddenda, type ReportAddendum } from "./reportAddendum"; import { CHANGED_HERE_TEXT, changedHere, returnedResultWithheld } from "./returnedPublication"; import { isBloodPressurePanel } from './observationPanelValue'; /** * ONE SET OF RESULTS, OPENED - the second container in the approved record. * * EVENTS ARE CONTAINERS, and the stream's *"Blood test - 4 results"* row is a promise that the four * are one tap away. Without this the row links at a route that does not exist, which is the same * defect the visit container closed - and nothing catches it: the module's tests pass, the stream's * tests pass, and a route that was never written cannot fail a type check. * * IT ONLY EVER CLAIMS WHAT IT CAN PROVE, exactly as the visit container does. A result belongs to a * report because the report NAMES it in `result`. Never because it shares a date: two draws on one * morning are two reports, and merging them puts a number under the wrong heading on a screen somebody * makes a decision from. * * THE VISIT IT WAS ORDERED AT is the report's own `encounter` - FHIR's "health care event when test * ordered" - so the screen can name it without the ordering the product does not have yet. * * THE LINE OVER TIME is drawn only from exact matches (`buildResultSeries` below). The sentence the * deck writes under it - *"Falling steadily for two years"* - is still not written: a trend is a claim * about the series, and the numbers are shown so the reader can see it for themselves. * * Shape only: no clock, no fetch, no render. */ export interface ResultLine { id: string; /** "HbA1c" - what the test is called, in whatever words the lab used. */ label: string; /** "6.8 %" - already assembled, so no component does unit arithmetic. */ value: string; /** Where the value was read from, for field history. */ valuePath: "valueQuantity" | "component"; /** "Usual range 4.0-5.6 %", when the result carries one. */ range?: string; /** "Above the usual range" - "In your usual range". */ interpretation?: string; /** True only for a reassuring reading - the one that renders green rather than amber. */ reassuring: boolean; /** What the lab wrote about this one line. */ note?: string; /** Said beside a returned value this practice changed after the laboratory returned it. */ changedHere?: string; } export interface ResultDetail { id: string; /** "Blood test". */ title: string; when?: string; /** The lab or practice that ran it. */ performer?: string; /** Who signed it, once it is signed: the name its words are credited to. */ signer?: string; /** The report's own conclusion, as it was written. */ conclusion?: string; /** * The laboratory's own report, where the test was sent out and came back on paper. * * Part of the result rather than a note about it, so it reaches the patient with the rest of * it. Each is stored as a key the reader presigns; a corrected report sits beside the first, * so this is a list rather than one document. */ reports: Attachment[]; /** What was added after it was signed, beside the signed words rather than inside them. */ addenda: ReportAddendum[]; lines: ResultLine[]; /** * HOW MANY RESULTS THE REPORT SAYS IT HAS, against how many we could actually read. * * A report naming five results while we hold three is not a screen that should quietly show three: * a patient counting rows against the *"5 results"* they tapped concludes two are missing, and they * would be right. Both numbers are carried so the screen can say so. */ named: number; /** Why none of it is shown: a returned result still being filed here, or read across two of its publications. */ withheld?: string; } const REASSURING = new Set(["N", "NORMAL"]); // The plain high and low flags, said as the deck says them ("Above the usual range"). Same meaning, the // patient's words. Critical (HH, LL) and every other flag keep the lab's own wording. const SIDE_OF_RANGE: Record = { H: "Above the usual range", L: "Below the usual range" }; /** "4.0-5.6 %" - a range only when there is one; a half-open bound is not a range a patient can read. */ const rangeOf = (observation: Observation): string | undefined => { const range = observation.referenceRange?.[0]; if (!range) return undefined; const text = range.text?.trim(); if (text) return `Usual range ${text}`; const low = range.low?.value; const high = range.high?.value; if (low === undefined || high === undefined) return undefined; const unit = range.high?.unit ?? range.low?.unit ?? observation.valueQuantity?.unit; return `Usual range ${low}–${high}${unit ? ` ${unit}` : ""}`; }; const interpretationOf = (observation: Observation): { text?: string; reassuring: boolean } => { const coding = observation.interpretation?.[0]?.coding?.[0]; const code = coding?.code?.toUpperCase(); const reassuring = Boolean(code && REASSURING.has(code)); const written = observation.interpretation?.[0]?.text?.trim() || coding?.display?.trim(); return { // "In your usual range" rather than the lab's "Normal": the patient's words, and the one place // this module rewrites rather than reports. Anything NOT reassuring keeps the lab's own wording // - softening a flagged result is the one edit that could change what somebody does about it. text: reassuring ? "In your usual range" : (code && SIDE_OF_RANGE[code]) || written, reassuring, }; }; export const buildResultDetail = ( report: DiagnosticReport, observations: readonly Observation[], ): ResultDetail => { const named = (report.result ?? []) .map((reference) => reference.reference?.split("/").pop()) .filter((id): id is string => Boolean(id)); const byId = new Map( observations.filter((observation) => observation.id).map((observation) => [observation.id!, observation]), ); const lines = named .map((id) => byId.get(id)) .filter((observation): observation is Observation => Boolean(observation) && observation!.status !== "entered-in-error") .map((observation) => { const { text, reassuring } = interpretationOf(observation); return { id: observation.id ?? "", label: observation.code?.text?.trim() || observation.code?.coding?.[0]?.display?.trim() || "Result", value: vitalValue(observation) ?? "", valuePath: observation.valueQuantity?.value !== undefined && !isBloodPressurePanel(observation) ? "valueQuantity" as const : "component" as const, range: rangeOf(observation), interpretation: text, reassuring, note: observation.note?.[0]?.text?.trim() || undefined, ...(changedHere(observation) ? { changedHere: CHANGED_HERE_TEXT } : {}), }; }) // ⛔ A line with no value is a hole with a border, not a result. Unlike a medicine, a result with // no number says nothing at all — there is no dosage underneath it to act on. .filter((line) => line.value.length > 0); const withheld = returnedResultWithheld(report, observations); return { id: report.id ?? "", title: report.code?.text?.trim() || report.code?.coding?.[0]?.display?.trim() || "Results", when: report.effectiveDateTime ?? report.issued, performer: report.performer?.[0]?.display?.trim(), signer: isSignedReport(report) ? report.resultsInterpreter?.[0]?.display?.trim() || undefined : undefined, conclusion: reportTextAsOne(report), reports: (report.presentedForm ?? []).filter((form) => Boolean(form.url)), addenda: readAddenda(report), lines, // A value withdrawn here is not one still to arrive. named: named.filter((id) => byId.get(id)?.status !== "entered-in-error").length, ...(withheld ? { conclusion: undefined, signer: undefined, reports: [], addenda: [], lines: [], named: 0, withheld } : {}), }; }; /** * The heading over a result's words, the same in the staff record and the patient's: the person who * signed it, as the report board names them, else whoever performed it, else the practice. */ export const whoSaid = (detail: Pick, practice?: string): string => { const who = detail.signer ?? detail.performer ?? practice; return who ? `What ${who} said` : "What the lab said"; }; export interface ResultPoint { id: string; /** The number exactly as the lab sent it - never rounded or converted here. */ value: number; when: string; } const SETTLED = new Set(["final", "amended", "corrected"]); /** * THE SAME TEST OVER TIME - the deck's "Over time" line under one result. * * A point joins the series only when it is provably the same measurement: the same coded test (system * and code, never the display words), a number in the same unit, a settled status and a date. A loose * match - by name, or across units - draws a confident line through the wrong numbers, so anything that * fails one test is left out rather than guessed at. Oldest first; the result being read is included. */ export const buildResultSeries = ( observation: Observation, observations: readonly Observation[], limit = 5, ): ResultPoint[] => { const codings = (observation.code?.coding ?? []).filter((coding) => coding.system && coding.code); const unit = observation.valueQuantity?.unit ?? observation.valueQuantity?.code; const ownWhen = new Date(observation.effectiveDateTime ?? observation.issued ?? "").getTime(); // A blood pressure is two numbers; a panel-level number stored beside them is not its trend. if (isBloodPressurePanel(observation)) return []; if (codings.length === 0 || observation.valueQuantity?.value === undefined || Number.isNaN(ownWhen)) return []; const sameTest = (candidate: Observation) => (candidate.code?.coding ?? []).some((coding) => codings.some((own) => own.system === coding.system && own.code === coding.code), ); return observations .filter((candidate) => candidate.id === observation.id || (candidate.id && SETTLED.has(candidate.status ?? ""))) .filter(sameTest) .filter((candidate) => typeof candidate.valueQuantity?.value === "number") .filter((candidate) => (candidate.valueQuantity?.unit ?? candidate.valueQuantity?.code) === unit) .map((candidate) => ({ id: candidate.id!, value: candidate.valueQuantity!.value!, when: candidate.effectiveDateTime ?? candidate.issued ?? "", })) // Up to the result being read: a line that runs past it would end somewhere else. .filter((point) => point.when && new Date(point.when).getTime() <= ownWhen) .sort((left, right) => new Date(left.when).getTime() - new Date(right.when).getTime()) .slice(-limit); };