import type { DiagnosticReport, Encounter, EpisodeOfCare, MedicationRequest, Observation, Reference } from "fhir/r4"; import { createdAtOrganization } from "./createdAtOrganization"; import { readReportText } from "./reportText"; import { WITHDRAWN_RESULT_TEXT, withheldText } from "./returnedPublication"; import { medicationNameOf } from "./medicationName"; /** * ONE MERGED STREAM ACROSS EVERY PRACTICE - the spine of the approved record. * * THE RULE THAT MAKES IT GENERAL: a record holds two kinds of thing and they want opposite * treatments. **EVENTS happened**, so each one opens onto what it produced. **STATE is true now**, so it * belongs on one page with no dates to navigate. This module is the EVENT half. * * WHAT IT REPLACES, and why the old shape could not hold: we organise by FHIR RESOURCE TYPE, which is * why a destination is called *Clinical Impressions*, why Tuesday's note is filed under *Conditions & * allergies*, and why a patient hunting for "the appointment where he ordered the blood test" has to * know our thirteen list names to find it. The stream is the same record ordered the way it happened. * * IT DECIDES SHAPE ONLY. No clock, no fetch, no render - so the ordering rules can be tested without * a page or a network, which is the only way the undated and cross-practice cases ever get exercised. */ /** * `care` IS THE EPISODE OF CARE Spencer, 2026-08-26: *"fold in the two leftovers then retire * those two."* It was the only thing keeping the `Visits` destination alive. * * A patient never sees the word. `EPISODE` is a resource type; a patient calls it *"a course of * care"* `_CLAUDE/2026-08-26-ruling-patient-facing-surfaces-use-patient-words.md`. */ export type RecordEventKind = "visit" | "care" | "results" | "prescription" | "imaging"; export interface RecordEvent { /** Stable within a stream. `/`, so two kinds can never collide. */ id: string; kind: RecordEventKind; /** * ISO instant this happened. * * `undefined` IS A REAL STATE AND IT IS NOT ZERO. A resource with no date is still part of the * record - dropping it is the failure mode, because the patient cannot tell a record that omits * something from one that never had it. Undated events sort LAST and are never filtered out. */ when?: string; /** * What `when` was, where a bare date would mislead. "Entered" for a result nobody has released: its * time is the entry, not when the sample was taken. */ whenWord?: string; /** * THE WORD THAT OPENS THE BOLD LINE, when the kind word is not the right one. * * A visit reads *"Visit - Dr. Reyes"* - the kind, then who. A result reads *"Blood test - 4 * results"*, where the NAME OF THE TEST is what a patient is scanning for and "Results" would be * the least useful word on the row. The kind word is the default, not the rule. */ lead?: string; /** The rest of the bold line. "Dr. Practitioner One" - "4 results" - "Metformin 1000mg". */ title: string; /** The quieter line under it. "Annual physical" - "All in your usual range". */ detail?: string; /** Which practice this came from. Always carried - see `practiceIsWorthShowing`. */ organizationName?: string; organizationId?: string; /** Where the row goes. Undefined means the event has nothing to open yet. */ to?: string; /** * The clinician, for the `Who` facet. * * SEPARATE FROM `title` ON PURPOSE. A visit row leads with the clinician's name, so the two * are the same string today - but a prescription row leads with the MEDICINE and is still * attributable to a prescriber. Filtering on the row's bold line would quietly mean * *"everything called Dr. Reyes"* rather than *"everything Dr. Reyes did"*. */ clinicianId?: string; clinicianName?: string; /** * WHAT THIS EVENT IS ABOUT - the facet no portal does, and the reason to build this. * * *Everything about my diabetes*, across every practice and every year. The joins already * exist in the record: `Encounter.diagnosis`, `MedicationRequest.reasonReference`. An event can * be about more than one thing, so this is a list and never a single value. */ about?: RecordSubject[]; } /** Something a record is ABOUT - a condition somebody recorded, carried with the event it explains. */ export interface RecordSubject { /** The Condition id when there is one; otherwise the coded text, so an uncoded reason still groups. */ id: string; label: string; /** * DID SOMEBODY RECORD THIS AS A CONDITION, or did a clinician type it into a reason box? * * The deck promises the `About` options are *"a condition somebody recorded"*. Free text is not * that: walked 2026-08-26, the facet filled with one-off visit reasons *"Video visit * meeting window layout."*, *"ZZTEST collapse ladder UI test"* each with a count of * ONE. An option that matches a single event is not a facet; filtering to it returns the row the * reader was already looking at. */ coded: boolean; } export interface RecordMonth { /** `2026-08`. Sorts, and keys the React list. */ key: string; /** "August 2026" - already formatted, because the month heading is not a date the caller re-parses. */ label: string; events: RecordEvent[]; } /** Undated events collect under this key, which sorts after every real month. */ export const UNDATED_MONTH_KEY = "undated"; /** * WHICH MONTH AN EVENT BELONGS TO, and the two answers are not the same question. * * FHIR GIVES US BOTH SHAPES THROUGH ONE FIELD. `2026-08-01` is a DATE - it means the first of * August and nothing about a time. `2026-08-01T00:00:00Z` is an INSTANT, and in Iowa that instant is * seven in the evening on the 31st of July. * * TREATING THE FIRST AS THE SECOND IS THE BUG THIS PRODUCT HAS ALREADY PAID FOR TWICE - a date-only * value parses as UTC midnight, and reading it back in a western timezone moves it a day earlier. Caught * here by a test whose fixture sat on the boundary: three events in August came out as two. * * So a date-only string is read from the STRING, and an instant is read in the reader's own * timezone - which is right for an instant, because a visit at 7pm on the 31st happened on the 31st. */ const DATE_ONLY = /^(\d{4})-(\d{2})(?:-\d{2})?$/; const monthKeyOf = (when: string | undefined): string => { if (!when) return UNDATED_MONTH_KEY; const dateOnly = DATE_ONLY.exec(when); if (dateOnly) return `${dateOnly[1]}-${dateOnly[2]}`; const at = new Date(when); if (Number.isNaN(at.getTime())) return UNDATED_MONTH_KEY; return `${at.getFullYear()}-${String(at.getMonth() + 1).padStart(2, "0")}`; }; /** * The DAY an event happened, as `yyyy-mm-dd`, in the same two-shapes-one-field way as the month. * * A string rather than a Date because that is what a from-to range compares against, and comparing * `yyyy-mm-dd` strings is exactly comparing days - no timezone gets a second chance to move one. * Undated events return `undefined`, and a range must decide about them EXPLICITLY. Falling through * to a comparison with `undefined` silently drops them, which is the failure this module exists to * refuse: a patient cannot tell a record that omits something from one that never had it. */ export const dayKeyOf = (when: string | undefined): string | undefined => { if (!when) return undefined; // A date-only value is already the answer, and re-parsing it is what moves it a day. if (/^\d{4}-\d{2}-\d{2}/.test(when) && !when.includes("T")) return when.slice(0, 10); const at = new Date(when); if (Number.isNaN(at.getTime())) return undefined; return `${at.getFullYear()}-${String(at.getMonth() + 1).padStart(2, "0")}-${String(at.getDate()).padStart(2, "0")}`; }; const monthLabelOf = (key: string): string => { if (key === UNDATED_MONTH_KEY) return "No date recorded"; const [year, month] = key.split("-"); // Built from the parts rather than from the original instant: a UTC midnight formatted in a // western timezone lands in the previous month, so the heading and the row would disagree about // which month the event is in. const at = new Date(Number(year), Number(month) - 1, 1); return at.toLocaleDateString(undefined, { month: "long", year: "numeric" }); }; /** * Newest first, and undated last. * * NOT a plain `b.when - a.when`. `undefined` in that arithmetic is `NaN`, every comparison with NaN * is false, and the sort silently leaves those rows wherever they happened to be - which reads as a * random ordering rather than as a rule. */ const byNewestFirst = (a: RecordEvent, b: RecordEvent): number => { if (a.when && b.when) return b.when.localeCompare(a.when); if (a.when) return -1; if (b.when) return 1; return 0; }; /** * The stream, grouped into the months it renders as. * * A MONTH WITH NOTHING IN IT DOES NOT EXIST HERE. The grouping is derived from the events, so an * empty heading cannot be produced - which is the shape that makes a sparse record read as sparse * rather than as broken. */ export const groupEventsByMonth = (events: readonly RecordEvent[]): RecordMonth[] => { const months = new Map(); for (const event of events) { const key = monthKeyOf(event.when); const bucket = months.get(key); if (bucket) bucket.push(event); else months.set(key, [event]); } return [...months.entries()] .sort(([a], [b]) => { if (a === UNDATED_MONTH_KEY) return 1; if (b === UNDATED_MONTH_KEY) return -1; return b.localeCompare(a); }) .map(([key, bucket]) => ({ key, label: monthLabelOf(key), events: [...bucket].sort(byNewestFirst), })); }; /** * SHOULD THE ROWS NAME THEIR PRACTICE? YES, WHENEVER THERE IS ONE TO NAME. * * THIS RULE WAS REVERSED, AND THE OLD REASONING IS KEPT SO NOBODY REVERSES IT BACK BY ACCIDENT. * It used to require MORE THAN ONE practice before the marker appeared, on the card's first control: * *"a record with ONE practice in it still reads correctly - the practice marker must not become * noise for the majority who have one."* * * Spencer overruled that on 2026-08-29, asked directly: *"the org always shows regardless of if * it's the only one or not."* The organization is part of saying WHO somebody is, not a * disambiguator that earns its place once there are two of them - a patient reading "Dr. Reyes" * should never have to know how many practices they attend to know which Dr. Reyes this is. It also * keeps this row consistent with the medications list and the care plan header, which name the * practice unconditionally. * * Still false when NOTHING is named: a marker with no practice behind it is an empty chip. */ export const practiceIsWorthShowing = (events: readonly RecordEvent[]): boolean => events.some((event) => Boolean(event.organizationId ?? event.organizationName)); // ------------------------------------------------------------------------------------------------ // Building events out of what the record actually holds. // ------------------------------------------------------------------------------------------------ /** * A REFERENCE FIELD IS NOT A PROMISE ABOUT WHAT IT POINTS AT. `MedicationRequest.requester` is a * Practitioner OR an Organization OR the patient, and our own form lets whoever fills it choose. Reading * it as the practice put a clinician's name under the practice marker and into the `Where` facet - a * wrong answer that looks exactly like a right one, because both are names. * The reference states its type. Ask it. */ const referenceTypeOf = (reference?: { reference?: string }): string | undefined => reference?.reference?.split("/").slice(-2)[0]; const organizationOf = (reference?: { reference?: string; display?: string }) => referenceTypeOf(reference) === "Organization" ? { organizationId: reference?.reference?.split("/").pop(), organizationName: reference?.display?.trim() || undefined, } : {}; /** * THE PRACTICE BEHIND AN EVENT, and every event has one. * * The resource type first - `serviceProvider`, `managingOrganization`, a performing lab - because that * field names the organization the record is ABOUT. The `createdAtOrganization` extension is the * fallback, and it is why a row is never left with no practice on it: FHIR gives most resource types * nowhere to record where they were written, so the product carries it itself on all of them. * * Spencer, 2026-08-29: the practice shows on EVERY row, whether or not the patient attends more than * one. See `practiceIsWorthShowing` for the ruling and the reasoning it replaced. */ const practiceOf = ( resource: { extension?: Array<{ url?: string; valueReference?: Reference }> }, reference?: { reference?: string; display?: string }, ): { organizationId?: string; organizationName?: string } => { const named = organizationOf(reference); if (named.organizationName || named.organizationId) return named; const written = createdAtOrganization(resource); return { organizationId: written.id, organizationName: written.name }; }; /** The first reference of a given type. A performer list holds a lab AND a clinician, in either order. */ const firstOfType = ( references: readonly ({ reference?: string; display?: string } | undefined)[] | undefined, ...types: readonly string[] ) => (references ?? []).find((reference) => types.includes(referenceTypeOf(reference) ?? "")); const clinicianOf = (reference?: { reference?: string; display?: string }) => referenceTypeOf(reference) === "Practitioner" || referenceTypeOf(reference) === "PractitionerRole" ? { clinicianId: reference?.reference?.split("/").pop(), clinicianName: reference?.display?.trim() || undefined, } : {}; /** * What an event is about, from the joins the record already carries. * * A reason with no Condition behind it still groups - keyed on its own text, so *"Diabetes review"* * typed into a reason field lands with the coded condition of the same name instead of vanishing. An * unlabelled reference does NOT: an option reading `Condition/a1b2` is not something a patient can pick. */ const subjectsOf = ( references: readonly ({ reference?: string; display?: string } | undefined)[], texts: readonly (string | undefined)[] = [], ): RecordSubject[] => { const found = new Map(); for (const reference of references) { const label = reference?.display?.trim(); if (!label) continue; const id = reference?.reference?.split("/").pop() ?? label.toLowerCase(); if (!found.has(id)) found.set(id, { id, label, coded: Boolean(reference?.reference) }); } for (const text of texts) { const label = text?.trim(); if (!label) continue; const id = label.toLowerCase(); if (!found.has(id)) found.set(id, { id, label, coded: false }); } return [...found.values()]; }; /** * PARTICIPATION TYPES THAT MEAN "THIS PERSON SAW YOU", AND THE ONES THAT MEAN THE OPPOSITE. * * `ADM` is the one that matters: it is the ADMITTER - the person who checked the patient in, which is * a receptionist. Presenting them as the clinician a patient saw is not clutter, it is **a false * statement about their care**, and first-wins does exactly that on any encounter where reception was * recorded first. Found by and proven with a mutation that put "Reception One" back. * * DUPLICATED ON PURPOSE, AND IT SHOULD NOT STAY THAT WAY. The same three rules live in * `patientVisitsAndResults.ts`, where wrote and mutation-checked them - but they are private * there, the surface that consumed them has been replaced by this stream, and that card is still * CLAIMED by another session, so exporting them is not mine to do. * Two implementations of "who did the patient see" is exactly the kind of pair that drifts. The * alternative was leaving a receptionist's name on a patient's own record until the lane clears, which * is worse. Raised in the pair log; collapse this onto theirs the moment they are exported. */ const SAW_THE_PATIENT = new Set(["PPRF", "SPRF", "ATND", "CON"]); const DID_NOT_SEE_THE_PATIENT = new Set(["ADM", "DIS", "REF", "translator", "emergency"]); type EncounterParticipant = NonNullable[number]; const participantCodes = (participant: EncounterParticipant): string[] => (participant.type ?? []) .flatMap((type) => type.coding ?? []) .map((coding) => coding.code?.trim()) .filter((code): code is string => Boolean(code)); /** * The clinician the patient actually saw - ordered, never first-wins, because "first" is an accident * of how the encounter was assembled. * * 1. someone whose recorded role says they performed or attended the visit, * 2. otherwise a Practitioner - the automated paths write the appointment's practitioner and give the * participant no type at all, so this is the ORDINARY case rather than a fallback, * 3. otherwise nobody. * * Anyone whose ONLY recorded roles are administrative is dropped before any of that. */ const encounterPractitioner = (encounter: Encounter): EncounterParticipant["individual"] | undefined => { const named = (encounter.participant ?? []).filter((participant) => Boolean(participant.individual?.display?.trim()), ); const eligible = named.filter((participant) => { const codes = participantCodes(participant); return codes.length === 0 || !codes.every((code) => DID_NOT_SEE_THE_PATIENT.has(code)); }); const byRole = eligible.find((participant) => participantCodes(participant).some((code) => SAW_THE_PATIENT.has(code)), ); if (byRole) return byRole.individual; return eligible.find((participant) => /^(Practitioner|PractitionerRole)\//.test(participant.individual?.reference?.trim() ?? ""), )?.individual; }; /** * Why the visit happened, in a human's words. * * `type.coding.display` is deliberately NOT read, and that omission is the whole point. Every * encounter this product creates is stamped with the same SNOMED default - 308335008, whose display is * *"Patient encounter procedure"* - because US Core requires a `type` and neither the check-in path nor * the clinician's start-visit path knows a better one yet. Reading the coding does not SOMETIMES * produce terminology, it produces terminology every single time. * `type.text` is safe and still read: `text` is what a person typed, `coding.display` is what the * terminology calls itself, and the default carries a coding and no text - so a clinician who writes a * real type still gets it shown. */ const encounterReason = (encounter: Encounter): string | undefined => encounter.reasonCode?.find((reason) => reason.text?.trim())?.text?.trim() || encounter.reasonCode?.[0]?.coding?.find((coding) => coding.display?.trim())?.display?.trim() || encounter.type?.find((type) => type.text?.trim())?.text?.trim() || undefined; /** * A visit. The bold line names WHO, because that is what a patient remembers about an appointment - * the practice is on its own line and the reason is the quiet one. */ export const visitEvent = (encounter: Encounter): RecordEvent => ({ id: `Encounter/${encounter.id}`, kind: "visit", when: encounter.period?.start, // EMPTY, NOT "Visit". The ROW already opens the sentence with the kind word, so a fallback // here renders **"Visit - Visit"** - thirteen rows of it on the first record this was walked // against. The module's own test asserted the fallback and so agreed with the bug. title: encounterPractitioner(encounter)?.display?.trim() ?? "", detail: encounterReason(encounter), ...practiceOf(encounter, encounter.serviceProvider), ...clinicianOf(encounterPractitioner(encounter)), about: subjectsOf( (encounter.diagnosis ?? []).map((entry) => entry.condition), (encounter.reasonCode ?? []).map((reason) => reason.text), ), to: encounter.id ? `/dashboard/patient/visit/${encounter.id}` : undefined, }); /** * A COURSE OF CARE - an `EpisodeOfCare`, which is a run of treatment for one thing rather than a * single appointment. * * It is an EVENT because it started on a day and belongs in the stream beside the visits it * contains. It does not open onto anything yet: the visits inside it are already their own rows, so * a container would repeat the stream rather than add to it. */ export const careEvent = (episode: EpisodeOfCare): RecordEvent => ({ id: `EpisodeOfCare/${episode.id}`, kind: "care", when: episode.period?.start, title: episode.type?.find((type) => type.text?.trim())?.text?.trim() || episode.type?.[0]?.coding?.find((coding) => coding.display?.trim())?.display?.trim() || "", // What it is FOR, in the reader's words, when the record says so. detail: (episode.diagnosis ?? []) .map((entry) => entry.condition?.display?.trim()) .filter(Boolean)[0], ...practiceOf(episode, episode.managingOrganization), about: subjectsOf((episode.diagnosis ?? []).map((entry) => entry.condition)), to: undefined, }); /** * A set of results. COUNTED, not listed: "4 results" is the row, and the four are what opening it is * for. A row per observation would bury a visit under the bloods it produced. */ export const resultsEvent = ( report: DiagnosticReport, resultCount: number, withheld: string | undefined = withheldText(report), ): RecordEvent => ({ id: `DiagnosticReport/${report.id}`, kind: "results", // A RESULT NOT YET RELEASED IS DATED BY ITS ENTRY. It carries no effective or issued time until a // person releases it, so its own write is the moment it was entered, and it says so. ...(report.status === "preliminary" ? { when: report.meta?.lastUpdated ?? report.effectiveDateTime ?? report.issued, whenWord: "Entered" } : { when: report.effectiveDateTime ?? report.issued }), lead: report.code?.text?.trim() || report.code?.coding?.[0]?.display?.trim() || "Results", // "1 results" is the tell that nobody read this row out loud. title: withheld === WITHDRAWN_RESULT_TEXT ? "Withdrawn" : withheld ? "Still being filed" : resultCount > 0 ? `${resultCount} result${resultCount === 1 ? "" : "s"}` : "Results ready", detail: withheld ?? describeResults(report), ...practiceOf(report, firstOfType(report.performer, "Organization")), ...clinicianOf(firstOfType(report.performer, "Practitioner", "PractitionerRole")), about: subjectsOf([], withheld ? [] : (report.conclusionCode ?? []).map((code) => code.text)), to: report.id ? `/dashboard/patient/result/${report.id}` : undefined, }); /** * A prescription. `authoredOn` rather than a period: a prescription is written on a day, and the day * it was written is the one a patient is looking for. */ export const prescriptionEvent = (request: MedicationRequest): RecordEvent => ({ id: `MedicationRequest/${request.id}`, kind: "prescription", when: request.authoredOn, title: medicationNameOf(request) ?? "Prescription", detail: request.dosageInstruction?.[0]?.text?.trim(), ...practiceOf(request, request.requester), ...clinicianOf(request.requester), about: subjectsOf( request.reasonReference ?? [], (request.reasonCode ?? []).map((reason) => reason.text), ), to: undefined, }); /** * WHAT THE STREAM ADMITS. Spencer 2026-08-27: it "only admits things that happened to you and things * that came back". A planned or cancelled visit did not happen, a draft prescription was never written, * and a registered report has not come back; entered-in-error never happened at all. */ const VISIT_NOT_HAPPENED = new Set(["planned", "cancelled", "entered-in-error"]); const CARE_NOT_HAPPENED = new Set(["planned", "waitlist", "cancelled", "entered-in-error"]); const RESULT_NOT_BACK = new Set(["registered", "cancelled", "entered-in-error"]); const PRESCRIPTION_NOT_WRITTEN = new Set(["draft", "entered-in-error"]); export const visitHappened = (encounter: Encounter): boolean => !VISIT_NOT_HAPPENED.has(encounter.status); export const careHappened = (episode: EpisodeOfCare): boolean => !CARE_NOT_HAPPENED.has(episode.status); export const resultCameBack = (report: DiagnosticReport): boolean => !RESULT_NOT_BACK.has(report.status); export const prescriptionWritten = (request: MedicationRequest): boolean => !PRESCRIPTION_NOT_WRITTEN.has(request.status); /** An interpretation code that means the reading is where it should be. */ const REASSURING_INTERPRETATION = new Set(["N", "NORMAL"]); /** * THE QUIET LINE UNDER A RESULT IS A CLINICAL CLAIM, so it is only ever the REPORT'S OWN. * * The deck reads *"All in your usual range"*. That sentence is worth a great deal to a frightened * patient and it is worth LESS THAN NOTHING if we are guessing - somebody who reads it and skips the * appointment has been harmed by a summary. * SO NOTHING HERE IS DERIVED. We do not tally interpretations across the observations and decide the * set looks fine; a report where two of five results carry no interpretation is not one we can call * reassuring, and the arithmetic that would say otherwise is exactly the kind that looks careful. * Three sources, in order, and silence when there is none: the report's own written conclusion, its * coded finding put into the reader's words, or that finding's own display text. */ export const describeResults = (report: DiagnosticReport): string | undefined => { const held = withheldText(report); if (held) return held; // A clinician's own sentence outranks any code, including a reassuring one: a report can be // coded normal and still carry "recheck in three months", and that is the half that matters. const { findings, impression } = readReportText(report); const conclusion = impression ?? findings; if (conclusion) return conclusion; const finding = report.conclusionCode?.[0]; const code = finding?.coding?.[0]?.code?.toUpperCase(); if (code && REASSURING_INTERPRETATION.has(code)) return "All in your usual range"; return finding?.text?.trim() || finding?.coding?.[0]?.display?.trim() || undefined; }; /** * How many results a report actually carries, counted from the observations we hold rather than from * the report's own `result` array - a reference to something we cannot read is not a result the patient * can open, and counting it promises a row that will not be there. */ export const countReportResults = ( report: DiagnosticReport, observations: readonly Observation[], ): number => { const ids = new Set( (report.result ?? []) .map((reference) => reference.reference?.split("/").pop()) .filter((id): id is string => Boolean(id)), ); return observations.filter((observation) => observation.id && ids.has(observation.id)).length; };