import type { Appointment, Attachment } from "fhir/r4"; import type { PractitionerWithRoles } from "../services/PractitionerService.tsx"; import { formatFhirHumanName } from "./fhirHumanName.tsx"; /** * Who an appointment is FOR and WITH - resolved from the reference, on every surface. * * THIS EXISTS BECAUSE THERE WERE THREE COPIES OF THE SAME HELPER AND THEY GAVE DIFFERENT ANSWERS. * The clinician calendar resolved practitioners against the loaded list and had no path at all for a * `Patient/` reference; the receptionist day board and its details dialog resolved **nothing** for * either type, falling straight through to `actor.display`. So the same appointment named its patient * on one screen and read *"Unknown Patient"* on the next - and 19 of 27 records on staging carry no * display at all, which is the measured 70% of rows that could not say who a visit was for. * It is not data rot: a correctly-formed FHIR appointment written with valid references and no * denormalised display was anonymous on screen. proved that with a hand-written record. * * DO NOT BACKFILL `actor.display` SERVER-SIDE. fence, unchanged: a FHIR reference * legitimately carries no display, a denormalised copy goes stale the moment anyone is renamed, and a * client that cannot resolve a reference fails again on the first record we did not write. * * BOTH LABELS LIVE HERE AND NEITHER TOUCHES A SERVICE. The patient half needs a FETCH, and that * fetch is the hook (`hooks/useAppointmentPatientNames.ts`) - but deciding what the row should SAY, * given what has been fetched, is pure. Keeping the two apart is what lets every rule here be unit * tested without mounting a provider tree, and it is the layering the module-load lessons keep * teaching: a pure module that drags in a service is a pure module nobody can load. * The hook re-exports the patient label, so the callers that already import it from there are * unaffected - there is still exactly ONE definition. */ /** patient id resolved name, plus every id a read has been ATTEMPTED for. Produced by the hook. */ export type PatientNameResolution = { /** An empty string means "resolved, but the record carries no usable name". */ names: Record; attempted: Set; /** The patient's photograph, where the record carries one. */ photos?: Record; /** The patient's first phone number, where the record carries one. */ phones?: Record; /** Whether the patient has a portal login, for each record that was read. */ logins?: Record; /** The patient's date of birth, where the record carries one. */ birthDates?: Record; }; const PATIENT_PREFIX = "Patient/"; const patientParticipant = (appointment: Appointment) => appointment.participant?.find((item) => item.actor?.reference?.startsWith(PATIENT_PREFIX)); export const appointmentPatientId = (appointment: Appointment): string | undefined => { const reference = patientParticipant(appointment)?.actor?.reference; const id = reference?.slice(PATIENT_PREFIX.length).trim(); return id || undefined; }; /** * What the row should say about the PATIENT. The vocabulary is four distinct facts, deliberately not * collapsed: * * the name - resolved from the reference (preferred over `display`, which goes stale * the moment a patient is renamed) * the display - an optimisation: shown while the read is in flight so a name we already * have never flickers away, and kept if the record resolves without one * "Loading patient..." - a reference we have not asked about yet * "Patient unavailable" - we asked and got nothing back (a denial, a 404, a nameless record) * "Unknown patient" - the appointment names NO patient. The only case where the original copy * was true. * * The middle two are the whole point. Saying "Unknown patient" for a reference we simply had not * read yet is the same class of defect as an empty state rendered before the server was asked: it * reports absence of DATA when the truth is absence of a REQUEST. */ export const resolveAppointmentPatientLabel = ( appointment: Appointment, resolution: PatientNameResolution, ): string => { const participant = patientParticipant(appointment); if (!participant) return "Unknown patient"; const display = participant.actor?.display?.trim(); const id = appointmentPatientId(appointment); // A reference we cannot even parse an id out of is unresolvable rather than pending. if (!id) return display || "Unknown patient"; const name = resolution.names[id]; if (name) return name; if (display) return display; return resolution.attempted.has(id) ? "Patient unavailable" : "Loading patient…"; }; const PRACTITIONER_TYPES = ["Practitioner", "PractitionerRole"] as const; const referenceIdOf = (reference?: string): string | undefined => { const id = reference?.split("/")[1]?.trim(); return id || undefined; }; /** * The practitioner an appointment is with. * * NO FETCH, AND THAT IS WHY THIS IS A PLAIN FUNCTION. Every surface that shows an appointment * already loads the practitioner list (it needs it to render a schedule at all), so the reference is * resolved against records the page is holding rather than by asking again. * * BOTH REFERENCE TYPES, and they resolve differently: an appointment may name the person * (`Practitioner/x`) or the hat they wear at that practice (`PractitionerRole/y`). A resolver that * handles only the first names nobody on a board whose bookings carry roles - which is what the * receptionist screen's own booking flow writes. * * The live record beats `actor.display`, which is an optimisation and goes stale on a rename. */ export const resolveAppointmentPractitionerLabel = ( appointment: Appointment, practitioners: readonly PractitionerWithRoles[], fallback: string, ): string => { const participant = appointment.participant?.find((item) => PRACTITIONER_TYPES.some((type) => item.actor?.reference?.startsWith(`${type}/`)), ); const reference = participant?.actor?.reference; const display = participant?.actor?.display?.trim(); const id = referenceIdOf(reference); const record = !id ? undefined : reference?.startsWith("Practitioner/") ? practitioners.find((item) => item.practitioner.id === id) : practitioners.find((item) => item.roles.some((role) => role.id === id)); if (record) { // `emptyText` rather than a bare `||`: a record that resolved but carries no usable name is a // different fact from one we could not find, and the display is still the better thing to show. return formatFhirHumanName(record.practitioner.name, { emptyText: display || fallback }); } return display || fallback; };