import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { Appointment, Patient, Task } from "fhir/r4"; import { resolveTaskAppointment, useAppointmentServiceContext, useFhirServiceContext, usePatientServiceContext, usePatientReadinessService, useTaskServiceContext, } from "../../../services"; import type { PatientReadinessSummary } from "../../../services/PatientReadinessService.tsx"; import { DISPLAY_APPOINTMENT_TASK_STATUSES } from "../../../utils/appointmentTasks"; import { readinessCountsTheVisitOnScreen } from "../../../utils/readinessCountsTheVisitOnScreen"; // Card 147 — READINESS IS ANSWERED BY THE SERVER NOW, and the evidence never comes here. // // What this used to do: to decide whether a patient had finished their intake, it fetched // AllergyIntolerance, MedicationStatement, List and RelatedPerson from the browser. This role holds NO // clinical scopes, so every one of those was refused - console 4 28 errors on opening a single // appointment - and the panel announced "Unable to verify the patient's profile and history." The // feature had never worked, once, for the role it was built for. Found by pointing a browser at it // (card 113's UI spec, S7); a green unit suite, a green API smoke and a code review all passed over it. // // Spencer ruled 2026-08-04 that a receptionist has no business seeing a clinical summary - so the // ANSWER moved to POST /R4/patient-readiness, which reads the records on a system token and returns // four scalars. The question was always legitimate front-desk work; only the method was wrong. // // The PATIENT's own home (HomePage/PatientHomeBanner.tsx) still builds this client-side, and must keep // doing so - a patient reading their own record is exactly what the scopes permit, that screen works, and // it already holds the data. Only the STAFF path moved. // Said `components/dashboard/Tasks.tsx` until 2026-08-14. That file stopped being the // patient dashboard on `276b9e2e` and now has NO mount at all - no import, no barrel entry, no route. // Its deletion is card 284's batch; this comment and the control in the sibling test were the two things // still pointing at it. /** * THREE OUTCOMES, NOT TWO - and flattening them re-creates the defect this card exists to remove. * `readinessErrorsByPatientId` carries a REFUSAL or a FAILURE, and neither may be drawn as an * unfinished intake: "you may not see this" and "we could not check" are different facts from "this * patient has done nothing", and only the last one is about the patient */ export type ReceptionistAppointmentSupport = { appointmentTasksById: Record; /** * EVERY task the patient has, not just the ones attached to an appointment (Spencer, 2026-08-15). * * The buckets above answer *what was asked for THIS visit*; this answers *what does this patient still * owe at all*. Both are needed on one row: "2 of 5 tasks - 3 due by this visit". * **It costs NOTHING extra.** This hook already reads every one of the patient's tasks to fill the * buckets - the unattached ones were simply being discarded on the way, so a patient's other * outstanding work was invisible to the person standing in front of them. */ patientTaskTotalsById: Record; taskErrorsByAppointmentId: Record; patientReadinessById: Record; readinessErrorsByPatientId: Record; loading: boolean; refresh: () => Promise; }; /** Wording matters here: each says which of the two non-answers it is, and neither implies a malfunction. */ const READINESS_DENIED_MESSAGE = "You do not have access to this patient's record."; const READINESS_UNAVAILABLE_MESSAGE = "Couldn't check this patient's setup just now. Try again."; export type ReceptionistPatientAppointments = { appointments: Appointment[]; loading: boolean; error?: string; refresh: () => Promise; }; export const getAppointmentPatientId = (appointment: Appointment): string | undefined => { const reference = appointment.participant?.find((participant) => participant.actor?.reference?.startsWith("Patient/"), )?.actor?.reference; return reference?.split("/")[1]; }; export const useReceptionistAppointmentSupport = ( appointments: Appointment[], ): ReceptionistAppointmentSupport => { const { fetchPatient } = usePatientServiceContext(); const { fetchTasks } = useTaskServiceContext(); const { fetchPatientReadiness } = usePatientReadinessService(); const { unpackBundle } = useFhirServiceContext(); const [appointmentTasksById, setAppointmentTasksById] = useState>({}); const [patientTaskTotalsById, setPatientTaskTotalsById] = useState< Record >({}); const [taskErrorsByAppointmentId, setTaskErrorsByAppointmentId] = useState>({}); const [patientReadinessById, setPatientReadinessById] = useState< Record >({}); const [readinessErrorsByPatientId, setReadinessErrorsByPatientId] = useState< Record >({}); const [loading, setLoading] = useState(false); const appointmentsRef = useRef(appointments); const requestSequenceRef = useRef(0); useEffect(() => { appointmentsRef.current = appointments; }, [appointments]); const appointmentsKey = useMemo( () => appointments // Status is in the key so a cancel or a rebook on screen asks readiness again. .map((appointment) => `${appointment.id || ""}:${getAppointmentPatientId(appointment) || ""}:${appointment.status || ""}`) .sort() .join("|"), [appointments], ); const refresh = useCallback(async () => { const requestSequence = ++requestSequenceRef.current; const currentAppointments = appointmentsRef.current.filter( (appointment): appointment is Appointment & { id: string } => Boolean(appointment.id), ); if (currentAppointments.length === 0) { setAppointmentTasksById({}); setTaskErrorsByAppointmentId({}); setPatientReadinessById({}); setReadinessErrorsByPatientId({}); setLoading(false); return; } setLoading(true); const appointmentsByPatient = new Map>(); const taskErrors: Record = {}; const readinessErrors: Record = {}; currentAppointments.forEach((appointment) => { const patientId = getAppointmentPatientId(appointment); if (!patientId) { taskErrors[appointment.id] = "The appointment does not include a patient reference."; return; } const patientAppointments = appointmentsByPatient.get(patientId) || []; patientAppointments.push(appointment); appointmentsByPatient.set(patientId, patientAppointments); }); // ONE readiness request for the whole day view, not one per patient. The endpoint takes a batch // precisely because this hook already groups appointments by patient; the service chunks to the // server's cap so a long day cannot 400 the lot. const readinessByPatient = await fetchPatientReadiness( Array.from(appointmentsByPatient.keys()), ); const patientResults = await Promise.all( Array.from(appointmentsByPatient.entries()).map(async ([patientId, patientAppointments]) => { // Readiness is resolved independently of the task fetch below, so a Task failure can no longer // take readiness down with it (and vice versa) - they are separate questions with separate // permissions, and the old shared try/catch conflated them. const readinessResult = readinessByPatient[patientId]; if (readinessResult?.status === "denied") { readinessErrors[patientId] = READINESS_DENIED_MESSAGE; } else if (!readinessResult || readinessResult.status !== "ok") { readinessErrors[patientId] = READINESS_UNAVAILABLE_MESSAGE; } const readiness = readinessResult?.status === "ok" ? readinessCountsTheVisitOnScreen(readinessResult.readiness, patientAppointments) : undefined; const emptyAppointmentTasks = (): Record => Object.fromEntries(patientAppointments.map((appointment) => [appointment.id, [] as Task[]])); try { // Tasks are a SEPARATE half of this hook and this role may legitimately read them // (org-scoped-read under the card-044a affiliation grant). Only the clinical reads moved. const patient = await fetchPatient(patientId, false, undefined, true); const tasks = unpackBundle(await fetchTasks(patient)) as Task[]; const appointmentTasks = emptyAppointmentTasks(); // Counted over the SAME display statuses the appointment buckets use, so the whole-patient // total and this visit's share can never disagree about what counts as a task. `cancelled`, // `rejected` and `entered-in-error` are withdrawn work and are absent from both. const displayed = tasks.filter((task) => DISPLAY_APPOINTMENT_TASK_STATUSES.has(task.status)); const taskTotals = { total: displayed.length, completed: displayed.filter((task) => task.status === "completed").length, }; tasks.forEach((task) => { const resolvedAppointment = resolveTaskAppointment(task, patientAppointments); if (resolvedAppointment?.id && appointmentTasks[resolvedAppointment.id]) { appointmentTasks[resolvedAppointment.id].push(task); } }); return { patientId, appointmentTasks, readiness, taskTotals }; } catch (error) { console.error( `Failed to load receptionist appointment tasks for Patient/${patientId}:`, error, ); patientAppointments.forEach((appointment) => { taskErrors[appointment.id] = "Unable to load tasks for this appointment."; }); // Readiness is deliberately NOT downgraded here. A Task read failing says nothing about // whether the readiness answer we already hold is true, and overwriting a good answer with an // error would put "we could not check" on screen for a patient we successfully checked. // NO TALLY on a failed read - absent means "we were not told", which the shared vocabulary // renders as nothing. A zeroed tally would tell the front desk this patient owes nothing. return { patientId, appointmentTasks: emptyAppointmentTasks(), readiness, taskTotals: undefined }; } }), ); if (requestSequence !== requestSequenceRef.current) { return; } const nextAppointmentTasks: Record = {}; const nextPatientReadiness: Record = {}; const nextPatientTaskTotals: Record = {}; patientResults.forEach((result) => { Object.assign(nextAppointmentTasks, result.appointmentTasks); if (result.readiness) { nextPatientReadiness[result.patientId] = result.readiness; } if (result.taskTotals) { nextPatientTaskTotals[result.patientId] = result.taskTotals; } }); currentAppointments.forEach((appointment) => { if (!nextAppointmentTasks[appointment.id]) { nextAppointmentTasks[appointment.id] = []; } }); setAppointmentTasksById(nextAppointmentTasks); setPatientTaskTotalsById(nextPatientTaskTotals); setTaskErrorsByAppointmentId(taskErrors); setPatientReadinessById(nextPatientReadiness); setReadinessErrorsByPatientId(readinessErrors); setLoading(false); }, [ fetchPatient, fetchPatientReadiness, fetchTasks, unpackBundle, ]); useEffect(() => { void refresh(); return () => { requestSequenceRef.current += 1; }; }, [appointmentsKey, refresh]); return { appointmentTasksById, patientTaskTotalsById, taskErrorsByAppointmentId, patientReadinessById, readinessErrorsByPatientId, loading, refresh, }; }; export const useReceptionistPatientAppointments = ( appointment?: Appointment, enabled = true, ): ReceptionistPatientAppointments => { const { fetchAppointments } = useAppointmentServiceContext(); const { unpackBundle } = useFhirServiceContext(); const patientId = appointment ? getAppointmentPatientId(appointment) : undefined; // Every answer is stamped with the patient it was read for, and only the current patient's is shown: // one patient's history never renders under another while the second read is in flight or failed. const [read, setRead] = useState<{ patientId?: string; appointments: Appointment[]; error?: string }>({ appointments: [] }); const [loading, setLoading] = useState(false); const requestSequenceRef = useRef(0); const refresh = useCallback(async () => { const requestSequence = ++requestSequenceRef.current; if (!enabled || !patientId) { setRead({ appointments: [] }); setLoading(false); return; } setLoading(true); try { const bundle = await fetchAppointments({ resourceType: "Patient", id: patientId, } as Patient); const patientAppointments = unpackBundle(bundle) as Appointment[]; const dedupedAppointments = Array.from( new Map( patientAppointments.map((patientAppointment, index) => [ patientAppointment.id || `${patientAppointment.start || "no-start"}:${patientAppointment.end || "no-end"}:${index}`, patientAppointment, ]), ).values(), ); if (requestSequence !== requestSequenceRef.current) { return; } setRead({ patientId, appointments: dedupedAppointments }); } catch (appointmentsError) { console.error( `Failed to load appointment history for Patient/${patientId}:`, appointmentsError, ); if (requestSequence !== requestSequenceRef.current) { return; } setRead({ patientId, appointments: [], error: "Unable to load the patient's other appointments." }); } finally { if (requestSequence === requestSequenceRef.current) { setLoading(false); } } }, [enabled, fetchAppointments, patientId, unpackBundle]); useEffect(() => { void refresh(); return () => { requestSequenceRef.current += 1; }; }, [refresh]); const current = Boolean(patientId) && read.patientId === patientId; return { appointments: current ? read.appointments : [], loading: Boolean(enabled && patientId) && (loading || !current), error: current ? read.error : undefined, refresh, }; };