import { useEffect, useMemo, useRef, useState } from "react"; import type { Appointment, Attachment, Patient } from "fhir/r4"; import { usePatientServiceContext } from "../services/PatientService.tsx"; import { formatFhirHumanName } from "../utils/fhirHumanName.tsx"; import { patientHasLogin } from "../utils/cognitoIdentity.ts"; import { appointmentPatientId, resolveAppointmentPatientLabel, type PatientNameResolution, } from "../utils/appointmentParticipants.ts"; /** * Resolves the PATIENT a calendar row is for, from the reference rather than from a denormalised * display.. * * THE DEFECT THIS EXISTS FOR. The appointments calendar's own participant helper resolved a * reference to a live record for `Practitioner/` and `PractitionerRole/` only. A `Patient/` * reference had no resolution path at all, so the row fell through to * `actor.display || 'Unknown patient'` - and a row therefore showed a name if and only if the record * happened to carry a display. **19 of 27 appointments on staging carry none**, which is the measured * 70% of rows telling a clinician the product does not know who the visit is for, while other rows on * the same screen named that very patient. * * **Do not "fix" this by backfilling `actor.display` server-side.** A FHIR reference legitimately * carries no display; a client that cannot resolve a reference fails again the first time it meets a * record written by anything but us. * * The status correlation noted when this was found ("all four Unknown rows were cancelled") is NOT * the mechanism - nothing on that path reads `appointment.status`. It was a property of the fixture. * * Shape follows `components/video/useParticipantNames.ts`, whose header documents why: an `attempted` * ref (synchronous, so a burst of rows cannot each start the same fetch) and results applied by id * unconditionally (a cancellation gate there stranded a name for the life of the call). * * PHI: resolved names live in this caller's React state only - never storage - and are re-fetched on * every mount, exactly as the video roster does. */ /** `undefined` = never attempted - `null` = attempted, nothing usable came back - string = the name. */ type ResolvedName = string | null; /** What a read of the patient kept: the name for the label, the photo and phone the board shows, and whether they can sign in. */ type Resolved = { name: ResolvedName; photo?: Attachment; phone?: string; hasLogin?: boolean; birthDate?: string }; const photoOf = (patient?: Patient): Attachment | undefined => patient?.photo?.find((photo) => Boolean(photo?.url || photo?.data)); const phoneOf = (patient?: Patient): string | undefined => patient?.telecom?.find((telecom) => telecom.system === "phone" && telecom.value?.trim())?.value?.trim(); // THE LABEL RULE AND THE PARTICIPANT HELPERS MOVED to `utils/appointmentParticipants.ts`, where the // PRACTITIONER half now lives beside them (/337). Three surfaces held three different answers // to "who is this visit for", and keeping the pure rule inside a module that imports a SERVICE is part // of why: it could not be reached without a provider tree, so the next surface wrote its own. // Re-exported here, so every existing importer is untouched and there is still ONE definition. export { resolveAppointmentPatientLabel }; export type { PatientNameResolution }; const ID_KEY_DELIMITER = ","; /** Local alias - the fetch below reads ids exactly as the label rule does, by construction. */ const patientIdOf = appointmentPatientId; /** * Resolve patient names for a set of ids - the engine under the appointment hook below. * * EXTRACTED rather than copied, because the next caller had ids and no * appointments: the practitioner's owed-work panel lists waiting NOTES, and a scribe draft carries a * `patientId` and nothing else. A second hand-written resolver is how three surfaces came to hold * three different answers to *"who is this for"* in the first place - see this file's own header. * * The caller passes ids; every rule about caching, one-fetch-per-id and how a failure is recorded * stays here, once. */ export function usePatientNamesByIds(patientIds: readonly (string | undefined)[]): PatientNameResolution { const { fetchPatient } = usePatientServiceContext(); const [resolved, setResolved] = useState>({}); // Synchronous, so several rows referencing one patient in the same tick cannot each start a fetch. // Also the only thing that stops an id being re-read: once here it stays here for the hook's life. const attemptedRef = useRef>(new Set()); // A STRING key rather than the array: the caller's list is a fresh array on every load, and depending // on the reference would re-run this effect on every unrelated redraw. const idsKey = useMemo( () => Array.from(new Set(patientIds.filter(Boolean) as string[])) .sort() .join(ID_KEY_DELIMITER), [patientIds], ); useEffect(() => { const toFetch = idsKey .split(ID_KEY_DELIMITER) .filter((id) => id && !attemptedRef.current.has(id)); if (toFetch.length === 0) return; toFetch.forEach((id) => attemptedRef.current.add(id)); void (async () => { const results = await Promise.all( toFetch.map(async (id): Promise => { try { const patient = await fetchPatient(id); // `formatFhirHumanName` returning nothing is a resolved record with no usable name - kept // apart from a failed read, because the row's fallback differs between the two. return [id, { name: formatFhirHumanName(patient?.name, { emptyText: "" }) || "", photo: photoOf(patient), phone: phoneOf(patient), hasLogin: patientHasLogin(patient), birthDate: patient?.birthDate, }] as const; } catch { // A failed read is recorded, not retried: the row says "unavailable" rather than sitting // on "loading" forever, and a denied patient is not re-requested on every redraw. return [id, { name: null }] as const; } }), ); // Applied by id, unconditionally - `attemptedRef` hands each id to exactly one run, so no two // runs can hold a result for the same id and there is nothing here for a later run to clobber. setResolved((previous) => { const next = { ...previous }; for (const [id, read] of results) next[id] = read; return next; }); })(); }, [idsKey, fetchPatient]); return useMemo(() => { const names: Record = {}; const photos: Record = {}; const phones: Record = {}; const logins: Record = {}; const birthDates: Record = {}; const attempted = new Set(); for (const [id, read] of Object.entries(resolved)) { attempted.add(id); if (typeof read.name === "string") names[id] = read.name; if (read.photo) photos[id] = read.photo; if (read.phone) phones[id] = read.phone; if (read.hasLogin !== undefined) logins[id] = read.hasLogin; if (read.birthDate) birthDates[id] = read.birthDate; } return { names, attempted, photos, phones, logins, birthDates }; }, [resolved]); } export function useAppointmentPatientNames(appointments: Appointment[]): PatientNameResolution { // The id rule stays `appointmentPatientId`, so the fetch reads ids exactly as the LABEL rule does, // by construction - the property this file's header exists to protect. const ids = useMemo(() => appointments.map(patientIdOf), [appointments]); return usePatientNamesByIds(ids); }