import { useMemo } from "react"; import { useNavigate } from "react-router-dom"; import { ChevronRight } from "lucide-react"; import { OrgBadge } from "../../../components/ui/org-badge"; import type { Appointment } from "fhir/r4"; import { useFhirAppointments, useFhirLocations, useFhirPractitioners } from "../../../hooks"; import { useBookAppointmentDialog } from "../../../components/BookAppointmentDialog/BookAppointmentDialogProvider.tsx"; import { Skeleton } from "../../../components/ui/skeleton"; import { resolveAppointmentPractitionerLabel } from "../../../utils/appointmentParticipants.ts"; import { categorizePatientAppointments } from "../../../utils/patientAppointmentCategorization.ts"; import { appointmentLocationId, formatLocationAddress } from "../../../utils/appointmentDirections.ts"; import { viewerZoneLabelFor } from "../../../utils/practiceTimezone"; import { useAppointmentPractices } from "../../../hooks/useAppointmentPractices"; import { isAwaitingPractice, AWAITING_PRACTICE_LABEL, } from "../../../utils/appointmentAwaitingPractice"; /** * NEXT APPOINTMENT - the reference column's first card, from the approved overview deck. * * ONE APPOINTMENT, NOT A LIST. This replaced the "Upcoming Appointments" widget, and the deck names * that swap as the one place where what got built diverged from what was drawn. The widget carried a * booking button, a photo, a specialty printed twice and an empty "No instructions provided", and on a * laptop it left most of the reference column blank. A patient wants one fact here - when am I next * due, and where - and everything else is a tap away behind the card. * * REFERENCE, NEVER QUEUE. It is something you look at. Nothing on it is a task, which is why the * whole card opens rather than offering controls of its own. * * WHAT MOVED RATHER THAN WENT: booking is the frame header's standing "Schedule appointment" - always * on screen, which the in-page button never was. What is due before the visit is the ranked queue on * the other column, which is the surface that exists to say what needs the patient. */ /** * "Wednesday, 13 August - 9:00 AM EDT", in the reader's own timezone. Never UTC. * * THE ZONE LABEL IS THE HALF THAT MAKES THE CONVERSION SAFE. Spencer ruled on 2026-08-10 that a * patient sees times on their own clock rather than the practice's - the case that settled it was a * west-coast patient booking an east-coast therapist, where a video visit has no physical site for * "local" to mean anything. The conversion was here and the label was not, so a patient in California * read a Central appointment as a Californian time with nothing on screen to say a conversion had * happened. A number with no clock beside it simply looks like the appointment. * * `zoneLabel` is null whenever the two clocks agree, and a suffix on every time everywhere is noise - * noise is how somebody stops reading the one that matters. */ const whenOf = (iso?: string, zoneLabel?: string | null): string => { if (!iso) return ""; const at = new Date(iso); if (Number.isNaN(at.getTime())) return ""; const day = at.toLocaleDateString(undefined, { weekday: "long", month: "long", day: "numeric" }); const time = at.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" }); return `${day} · ${time}${zoneLabel ? ` ${zoneLabel}` : ""}`; }; export const NextAppointment = () => { const navigate = useNavigate(); const { openBookAppointment } = useBookAppointmentDialog(); const { loading, error, answered, listIsThisPatients, loadAppointments, appointmentsList } = useFhirAppointments(); const { practitionersList } = useFhirPractitioners(); const { locationsList } = useFhirLocations(); const next: Appointment | undefined = useMemo( () => (listIsThisPatients ? categorizePatientAppointments(appointmentsList ?? []).upcoming[0] : undefined), [appointmentsList, listIsThisPatients], ); const shown = useMemo(() => (next ? [next] : []), [next]); const practiceOf = useAppointmentPractices(shown, practitionersList ?? [], locationsList ?? []); const practice = next ? practiceOf(next) : undefined; // Measured at the appointment's instant against its own practice's clock; none when unproven. const zoneLabel = next?.start ? viewerZoneLabelFor(new Date(next.start), practice?.zone) : null; const organizationName = practice?.organization?.name?.trim() || ""; if ((loading || !answered) && !next) { return (
Next appointment
); } // A failed read is not "Nothing booked": the card keeps its place and says it could not check. if (error && !next) { return (
Next appointment Couldn’t check your appointments
); } // A DOOR THAT STAYS PUT. The card keeps its place and its shape when nothing is booked, and it // opens onto booking instead - the reference column must not change shape from one patient to the // next, or nobody can learn where anything is. if (!next) { return ( ); } // RESOLVED LIVE, never `actor.display`. The name is copied into the appointment when it is booked, // so a clinician renamed since keeps their old name on every booking made before the change - which // is how a corrupted surname stayed on screen after the record itself had been repaired. // Empty rather than "Unknown Practitioner": a placeholder where a clinician's name goes reads as a name. const practitionerName = resolveAppointmentPractitionerLabel(next, practitionersList ?? [], "").trim(); const reason = next.serviceType?.[0]?.text?.trim() || next.appointmentType?.text?.trim(); const locationId = appointmentLocationId(next); const address = formatLocationAddress( locationId ? locationsList?.find((candidate) => candidate.id === locationId) : undefined, ); return ( ); }; export default NextAppointment;