import type { Encounter, Observation } from "fhir/r4"; import { bloodPressureReading } from './observationPanelValue'; /** * The patient's record, organised the way they remember it: by visit. * * A record is deep - conditions, medications, immunisations, imaging, devices - and a patient does not * think in those categories. They think "the appointment where he ordered the blood test". So the visit * is the spine and the categories stay available as a second way in. * * This decides the SHAPE only. It reads no clock, fetches nothing, and renders nothing, so the rule can * be tested without a page or a network. */ export interface VisitResult { id: string; /** What the result is, in the patient's words where the resource gives us them. */ label: string; /** The value, already formatted, when there is a single obvious one. */ summary?: string; /** Who produced it, when that is not the practice the patient is looking at. */ performer?: string; } export interface Visit { id: string; /** Sorts and renders from this; undefined when the encounter carries no period at all. */ start?: string; /** "Annual physical" - why the visit happened, in a human's words. Never the terminology's. */ reason?: string; /** The clinician the patient actually saw, when the encounter names one. */ practitioner?: string; /** The practice the visit belongs to. Cross-org is the product, so a visit that cannot say where * it happened is barely a record at all. */ organization?: string; results: VisitResult[]; } /** An encounter reference on a result, normalised to a bare id. */ const encounterIdOf = (observation: Observation): string | undefined => { const reference = observation.encounter?.reference?.trim(); if (!reference) return undefined; const [, id] = reference.split("Encounter/"); return id || undefined; }; const observationLabel = (observation: Observation): string => observation.code?.text?.trim() || observation.code?.coding?.find((coding) => coding.display?.trim())?.display?.trim() || "Result"; /** * The value, when there is one worth putting on a row. * * A result with no simple value returns nothing rather than a placeholder: the row already names the * result, and "-" beside it reads as a value that came back empty, which is a different clinical fact * from a result whose value does not fit on one line. */ const observationSummary = (observation: Observation): string | undefined => { const pressure = bloodPressureReading(observation); if (pressure) return pressure; const quantity = observation.valueQuantity; if (quantity?.value !== undefined) { return [quantity.value, quantity.unit?.trim()].filter(Boolean).join(" "); } const components = observation.component ?? []; if (components.length > 1) { const parts = components .map((component) => component.valueQuantity?.value) .filter((value): value is number => value !== undefined); // A blood pressure is two numbers and one fact. Joined the way it is spoken. if (parts.length === components.length && parts.length > 0) { const unit = components[0]?.valueQuantity?.unit?.trim(); return [parts.join("/"), unit].filter(Boolean).join(" "); } } const text = observation.valueString?.trim() || observation.valueCodeableConcept?.text?.trim(); return text || undefined; }; const performerOf = (observation: Observation): string | undefined => observation.performer?.find((actor) => actor.display?.trim())?.display?.trim() || undefined; /** * 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 therefore does not * SOMETIMES produce terminology, it produces terminology every single time, and a patient was left * reading the code system's label for the kind of appointment they had. * * `type.text` is safe and is still read: `text` is what a person typed, `coding.display` is what the * terminology calls itself. The SNOMED default carries a coding and no text, so the two are cleanly * separable - and 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; /** * Participation types that mean "this person saw you", and the ones that mean the opposite. * * `ADM` is the one that matters here: 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 the old rule (take the first participant with a name) would * do exactly that on any encounter where reception was recorded first. */ const SAW_THE_PATIENT = new Set(["PPRF", "SPRF", "ATND", "CON"]); const DID_NOT_SEE_THE_PATIENT = new Set(["ADM", "DIS", "REF", "translator", "emergency"]); type EncounterParticipantLike = NonNullable[number]; const participantCodes = (participant: EncounterParticipantLike): 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 rather than 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. Never "whoever is listed first". * * Anyone whose ONLY recorded roles are administrative is dropped before any of that. */ const encounterPractitioner = (encounter: Encounter): string | 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?.display?.trim(); const clinician = eligible.find((participant) => /^(Practitioner|PractitionerRole)\//.test(participant.individual?.reference?.trim() ?? ""), ); return clinician?.individual?.display?.trim() || undefined; }; /** The practice the visit belongs to. Already on every encounter we create; it was simply never read. */ const encounterOrganization = (encounter: Encounter): string | undefined => encounter.serviceProvider?.display?.trim() || undefined; /** * Group results onto the visits that produced them, newest visit first. * * A result whose encounter we do not hold is NOT dropped - it is returned separately. Silently losing a * result because its visit is missing would take a lab panel off the record entirely, and the record is * the one thing the product promises to keep whole. The caller decides where to show them; this only * refuses to lose them. */ export const groupResultsByVisit = ( encounters: Encounter[] | undefined, observations: Observation[] | undefined, ): { visits: Visit[]; unlinked: VisitResult[] } => { const byEncounter = new Map(); const unlinked: VisitResult[] = []; for (const observation of observations ?? []) { if (!observation.id) continue; const result: VisitResult = { id: observation.id, label: observationLabel(observation), summary: observationSummary(observation), performer: performerOf(observation), }; const encounterId = encounterIdOf(observation); if (!encounterId) { unlinked.push(result); continue; } byEncounter.set(encounterId, [...(byEncounter.get(encounterId) ?? []), result]); } const known = new Set((encounters ?? []).map((encounter) => encounter.id).filter(Boolean)); for (const [encounterId, results] of byEncounter) { if (!known.has(encounterId)) unlinked.push(...results); } const visits: Visit[] = (encounters ?? []) .filter((encounter): encounter is Encounter & { id: string } => Boolean(encounter.id)) .map((encounter) => ({ id: encounter.id, start: encounter.period?.start, reason: encounterReason(encounter), practitioner: encounterPractitioner(encounter), organization: encounterOrganization(encounter), results: byEncounter.get(encounter.id) ?? [], })) // Newest first. An encounter with no period sorts last rather than being dropped: it is still a // visit that happened, and we simply cannot say when. .sort((a, b) => { if (!a.start && !b.start) return 0; if (!a.start) return 1; if (!b.start) return -1; return b.start.localeCompare(a.start); }); return { visits, unlinked }; }; /** * What a visit row says it holds. * * Returns nothing when there are none, rather than "0 results". The absence of a count is a different * statement from a count of zero, and a visit that produced notes and a prescription has not produced * nothing - it produced nothing of THIS kind. */ export const visitResultCount = (visit: Visit): string | undefined => { const count = visit.results.length; if (count === 0) return undefined; return `${count} result${count === 1 ? "" : "s"}`; }; /** * WHO the patient saw and WHERE - "Dr. Practitioner One - Great River Health". * * Spencer, reading his own record: *"they want to see I saw Dr. Somebody at Healthcare org name on * August 22nd."* Those are the two facts a person uses to recognise a visit, and neither of them was * on the row: it carried the encounter's SNOMED type and whichever participant happened to be listed * first. * * The organization is not decoration on a record that spans practices. It is the difference between * "a visit" and "the visit at the clinic across town", and a patient with two practices could not tell * their rows apart at all. * * Falls back to the patient's own word for the thing rather than to nothing, so a visit we hold almost * no detail about still reads as a visit instead of a bare date. Never a dangling separator. */ export const visitSubtitle = (visit: Visit): string => [visit.practitioner, visit.organization].filter(Boolean).join(" ยท ") || "Visit";