import { useCallback, useEffect, useState } from "react"; import { useBookAppointmentDialog } from "../../../../components/BookAppointmentDialog/BookAppointmentDialogProvider"; import type { Appointment } from "fhir/r4"; import { usePatientServiceContext } from "../../../../services/PatientService.tsx"; import { useAppointmentServiceContext } from "../../../../services/AppointmentService.tsx"; import { categorizePatientAppointments, type CategorizedAppointments } from "../../../../utils/patientAppointmentCategorization.ts"; import { OrgSection } from "./OrgSection.tsx"; import { AppSpinner } from "../../../../components"; import { sectionReadFailure, type SectionReadFailure } from "../../../../utils/sectionReadRefusal"; import { SectionReadNotice } from "./SectionReadNotice.tsx"; import { formatShortDate, formatTimeOfDay } from "../../../../utils/dateUtils.ts"; import { AWAITING_PRACTICE_LABEL, isAwaitingPractice } from "../../../../utils/appointmentAwaitingPractice.ts"; import { DECLINED_BY_PRACTICE_LABEL, wasDeclinedByPractice } from "../../../../utils/appointmentCancellation.ts"; export const OrgAppointmentsSection = ({ orgId, organizationName }: { orgId: string; organizationName?: string }) => { const { patientState } = usePatientServiceContext(); const { fetchAppointments } = useAppointmentServiceContext(); const { bookingsMade, latestBooking } = useBookAppointmentDialog(); // Search can lag a write by seconds, so the visit just booked here is added by id until it catches up, // and only for the patient and practice it was booked for. const bookedHere = latestBooking?.organizationId === orgId && latestBooking.patientId === patientState?.id ? latestBooking.appointment : undefined; const [cats, setCats] = useState({ upcoming: [], past: [], notConfirmed: [] }); // THE WORST OF THE THREE, BECAUSE THE SUBJECT IS THE PATIENT'S OWN RECORD. The read is scoped to this // patient, so discarding a refusal and falling through to the empty sentence told a patient they had // no appointments at a practice when the truth was that we were not allowed to look. There was // nothing on the screen to doubt. const [failure, setFailure] = useState(null); const [loading, setLoading] = useState(true); const practice = organizationName || "This practice"; const load = useCallback(() => { if (!patientState) return undefined; let cancelled = false; setLoading(true); setFailure(null); fetchAppointments(patientState, undefined, undefined, orgId) .then(b => { if (cancelled) return; const read = (b.entry ?? []).map(e => e.resource as Appointment); const missing = bookedHere?.id && !read.some(a => a.id === bookedHere.id); setCats(categorizePatientAppointments(missing ? [...read, bookedHere] : read)); }) .catch(readError => { if (!cancelled) setFailure(sectionReadFailure(readError)); }) .finally(() => { if (!cancelled) setLoading(false); }); return () => { cancelled = true; }; }, [orgId, patientState, fetchAppointments, bookedHere]); // Read again after a booking made from this card, or the visit just booked is missing from it. useEffect(() => load(), [load, bookingsMade]); /** The display on the first participant of one of these types, or nothing. */ const participantName = (a: Appointment, types: string[]): string | undefined => { const actor = a.participant?.find((p) => types.some( (type) => p.actor?.reference?.startsWith(`${type}/`), ))?.actor; const display = actor?.display?.trim(); return display || undefined; }; /* * WHAT THE VISIT IS, BEFORE WHEN IT IS. * * These rows used to be a bare timestamp and nothing else - "Sep 9, 2026, 09:30 AM CDT" - eight of * them under a heading, identical apart from the digits. A patient scanning their practice for what * is coming cannot tell a physical from a blood draw, which is the only question the list answers. * * The deck draws the visit's NAME first, then who and where, with the date beside it, and that order * is the reading order: what, then who, then when. * * A missing part is simply absent rather than filled with a placeholder - "Unknown practitioner" on a * lab draw that legitimately has none would be worse than the space. The NAME always resolves, * falling back through the service, the appointment type and the practice's own description to the * plain word, so a row can never be nameless. */ const row = (a: Appointment) => { const name = a.serviceType?.[0]?.text?.trim() || a.serviceType?.[0]?.coding?.find((c) => c.display?.trim())?.display?.trim() || a.appointmentType?.text?.trim() || a.description?.trim() || "Visit"; const who = [participantName(a, ["Practitioner", "PractitionerRole"]), participantName(a, ["Location"])] .filter(Boolean) .join(" · "); return (
{name} {who && {who}} {/* * THE SAME PREDICATE THE OTHER THREE SURFACES READ, because this card was the fourth and * read none of it. `appointmentAwaitingPractice` exists so that "a row, a card and a panel * cannot disagree about the same appointment", and this row disagreed: a booking still * awaiting the practice drew here with the name, the people and the time and nothing else - * identical to one the practice had agreed to. * * THIS IS THE WORST SURFACE TO LEAVE OUT, which is why it is worth a comment rather than * just an import. It is the only patient surface that lists a DIFFERENT practice's * appointments, and a second practice is exactly where a held request lands for somebody * whose own practice books direct - so the one row a patient could not check anywhere else * was the one row that said nothing. */} {isAwaitingPractice(a) && ( {AWAITING_PRACTICE_LABEL} )} {/* * THE OUTCOME SITS WHERE THE WAIT SAT - same element, same place under the row, same token. * The two are mutually exclusive (one is `pending`, the other `cancelled`) and they are the * only two things this card says about an appointment beyond its name and its time, so a * patient learns to read one spot rather than two. * * INSIDE THE ROW RATHER THAN UNDER IT, and that is not cosmetic. The first version of this * hung the sentence off a wrapper outside the row, where it picked up none of the row's * padding: its bottom edge landed at exactly the y of the "Upcoming" heading below, so the * decline read as a caption on the list underneath it. Measured at 0px, not guessed. */} {wasDeclinedByPractice(a) && ( {DECLINED_BY_PRACTICE_LABEL}. )}
{/* THE PRODUCT'S OWN FORMATTER, NOT toLocaleString. The default printed "9/7/2026, 10:00:00 AM" - a machine timestamp on a patient's screen, and the seconds were the give-away: an appointment is booked to a minute, so :00 asserts a precision the record does not carry. The time names its zone, which a bare time on a patient screen otherwise leaves them to assume; the date sits above it, as the deck stacks them. */}

{a.start ? <>{formatShortDate(a.start)}
{formatTimeOfDay(a.start)} : "—"}

); }; return ( /* * THE COUNT IS IN THE LABEL - "4 UPCOMING VISITS", not a heading over a list the reader tallies. * It counts UPCOMING rather than everything, because that is the question a patient opened the * practice to ask; the past ones are below and are not what the number is about. */ {loading ? (
) : failure ? ( { load(); }} /> ) : (cats.upcoming.length + cats.notConfirmed.length === 0) ? (

No upcoming visits at this practice.

) : ( <> {/* * THE OUTCOME, ON THE CARD THAT REPORTED THE WAIT. * * A request the practice DECLINED is hidden from upcoming and from past both - correctly, it * is neither - so on this card it was hidden from everywhere. The row simply stopped being * there and the heading counted one fewer, which is an absence carrying a message: the * patient reads "I imagined it", never "they said no". * * THIS IS THE SURFACE WHERE THAT MATTERS MOST, for the same reason the awaiting marker above * belongs here. It is the only patient surface listing a DIFFERENT practice's appointments, * and a second practice is exactly where a held request lands for somebody whose own * practice books direct - so the one answer they could not find anywhere else was the one * this card dropped silently. * * SAME SENTENCE AS THE APPOINTMENTS PAGE, from the same constant, because two screens * describing one appointment differently is the defect this is fixing in another costume. * It ages out on its own once the requested time has passed; there is nothing to dismiss. */} {cats.notConfirmed.length > 0 && (

Not confirmed

{cats.notConfirmed.map(row)}
)} {/* Upcoming only, under the count, as the deck draws it: past visits live in My visits & results, and on a practice they buried what is coming under everything that came. */} {cats.upcoming.length > 0 &&
{cats.upcoming.map(row)}
} )}
); };