import { ArrowRight, CalendarCheck2, Clock4, Plus, Stethoscope, } from "lucide-react"; import { Card, CardContent, CardDescription, CardTitle } from "../ui/card"; import { Button } from "../ui/button"; import { Link } from "react-router-dom"; import { useCallback, useMemo, useState } from "react"; import { Skeleton } from "../ui/skeleton"; import { Dialog, DialogContent, DialogDescription, DialogTitle, DialogTrigger, } from "../ui/dialog"; import PatientBookAppointment from "../../pages/DashboardPage/PatientsPage/PatientPage/PatientBookAppointment/PatientBookAppointment.tsx"; import { useFhirAppointmentBroadcast, useFhirAppointments, useFhirPractitioners, useFhirTasks, } from "../../hooks"; import { Appointment, Practitioner } from "fhir/r4"; import { useAppointmentServiceContext, useAuthServiceContext, } from "../../services"; import { getFhirPhotoUrl } from "../../utils/fhirPhoto.ts"; import { formatFhirHumanName } from "../../utils/fhirHumanName.tsx"; import { categorizePatientAppointments, isAppointmentHappeningNow } from "../../utils/patientAppointmentCategorization.ts"; import PatientAppointmentDialog from "../appointments/PatientAppointmentDialog.tsx"; import AppointmentTaskList from "../appointments/AppointmentTaskList.tsx"; import { tasksByAppointmentId } from "../../utils/appointmentTasks.ts"; import doctorImage from "../../../assets/doctor1.jpg"; import { ORG_LOGO_EXTENSION_URL as ORGANIZATION_LOGO_EXTENSION_URL } from "../../config/organizationExtensions"; import { appointmentSubjectLine } from '../../utils/appointmentSubject'; // THE STATUS SET, THE TITLE RULE AND THE TASK BLOCK MOVED OUT. This was the ONE surface of // three that showed a patient what was due before a visit - the dialog you get by clicking this card // showed none, and the appointments list never had it at all. Spencer's rule is that it belongs to the // appointment ROW wherever it appears, so it now lives in `utils/appointmentTasks.ts` + // `components/appointments/AppointmentTaskList.tsx` and all three read it. // Do not reintroduce a local copy here; this file's job is the card, not the rule. const normalizePractitionerReference = (reference?: string): string | undefined => { if (!reference) { return undefined; } const match = reference.match( /(?:^|\/)(Practitioner|PractitionerRole)\/([^/?#]+)/, ); return match ? `${match[1]}/${match[2]}` : undefined; }; const AppointmentCard = () => { const { wpOrganization } = useAuthServiceContext(); const { loading, error, appointmentsList } = useFhirAppointments(); const { practitionersList } = useFhirPractitioners(); const { tasksList } = useFhirTasks(); const { setAppointmentsList } = useAppointmentServiceContext(); const [showBookingModal, setShowBookingModal] = useState(false); // R6 / - the appointment a patient clicked, opened HERE rather than navigated to. const [openedAppointment, setOpenedAppointment] = useState(); const organization = wpOrganization.current; const organizationLogo = organization?.extension?.find( (extension) => extension.url === ORGANIZATION_LOGO_EXTENSION_URL, )?.valueAttachment; const organizationImageUrl = getFhirPhotoUrl( organizationLogo, "organization", ); const organizationName = organization?.name?.trim() || "Healthcare Organization"; const practitionersByReference = useMemo(() => { const practitionerMap = new Map(); practitionersList.forEach(({ practitioner, roles }) => { if (practitioner.id) { practitionerMap.set(`Practitioner/${practitioner.id}`, practitioner); } roles.forEach((role) => { if (role.id) { practitionerMap.set(`PractitionerRole/${role.id}`, practitioner); } }); }); return practitionerMap; }, [practitionersList]); const upsertAppointment = useCallback( (appointment: Appointment) => { setAppointmentsList((currentAppointments) => { const appointmentExists = currentAppointments.some( (currentAppointment) => currentAppointment.id === appointment.id, ); if (!appointmentExists) { return [...currentAppointments, appointment]; } return currentAppointments.map((currentAppointment) => currentAppointment.id === appointment.id ? appointment : currentAppointment, ); }); }, [setAppointmentsList], ); const removeAppointment = useCallback( (appointment: Appointment) => { setAppointmentsList((currentAppointments) => currentAppointments.filter( (currentAppointment) => currentAppointment.id !== appointment.id, ), ); }, [setAppointmentsList], ); useFhirAppointmentBroadcast({ onCreate: upsertAppointment, onUpdate: upsertAppointment, onDelete: removeAppointment, }); const categorizedAppointments = useMemo( () => categorizePatientAppointments(appointmentsList), [appointmentsList], ); const appointmentTasksByAppointmentId = useMemo( () => tasksByAppointmentId(tasksList ?? [], appointmentsList ?? []), [appointmentsList, tasksList], ); const formatDate = (dateString: string | undefined): string => { if (!dateString) return "No date available"; const date = new Date(dateString); return date.toLocaleDateString("en-US", { weekday: "long", year: "numeric", month: "long", day: "numeric", }); }; const formatTime = (dateString: string | undefined): string => { if (!dateString) return "No time available"; const date = new Date(dateString); return date.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", hour12: true, }); }; const getDaysDifference = (dateString: string | undefined): string => { if (!dateString) return "No date available"; const appointmentDate = new Date(dateString); const now = new Date(); // Compare calendar days (midnight-to-midnight), not raw time - otherwise an appointment later *today* rounds // up to "Tomorrow". const appointmentDayStart = new Date( appointmentDate.getFullYear(), appointmentDate.getMonth(), appointmentDate.getDate(), ); const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()); const diffDays = Math.round( (appointmentDayStart.getTime() - todayStart.getTime()) / (1000 * 60 * 60 * 24), ); if (diffDays === 0) return "Today"; if (diffDays === 1) return "Tomorrow"; if (diffDays > 1) return `In ${diffDays} days`; if (diffDays === -1) return "Yesterday"; return `${Math.abs(diffDays)} days ago`; }; const getPractitionerParticipant = ( participants: Appointment["participant"], ) => participants.find( (participant) => participant.actor?.reference?.startsWith("Practitioner/") || participant.actor?.reference?.startsWith("PractitionerRole/") || participant.type?.[0]?.text === "primary performer", ); const getPractitionerName = ( participants: Appointment["participant"], ): string => { const participant = getPractitionerParticipant(participants); // Resolve the name live from the loaded practitioner record (same map that drives the photo); // the denormalized actor.display is only a fallback, since it goes stale after a rename. const reference = normalizePractitionerReference( participant?.actor?.reference, ); const practitioner = reference ? practitionersByReference.get(reference) : undefined; return formatFhirHumanName(practitioner?.name, { emptyText: participant?.actor?.display?.trim() || "Unknown Practitioner", }); }; const getPractitionerImageUrl = (appointment: Appointment): string => { const practitionerReference = normalizePractitionerReference( getPractitionerParticipant(appointment.participant)?.actor?.reference, ); const practitionerPhoto = practitionerReference ? practitionersByReference.get(practitionerReference)?.photo?.[0] : undefined; return practitionerPhoto ? getFhirPhotoUrl(practitionerPhoto, "practitioner") : doctorImage; }; const getSpecialty = (appointment: Appointment): string => { return ( appointment.specialty?.[0]?.text || appointment.serviceCategory?.[0]?.text || "Practitioner" ); }; // WHAT THE VISIT IS FOR, from the one shared helper - see utils/appointmentSubject. The fallback here // used to name a check-up nobody had booked, on any appointment that carried no type at all. const getAppointmentType = (appointment: Appointment): string => appointmentSubjectLine(appointment); const renderAppointmentHeader = (className: string) => ( // / B10 - the section carries the heading, not the empty-state message inside it.
Upcoming Appointments
Book Appointment Select a practitioner, appointment type, date, and time.
setShowBookingModal(false)} />
); if (loading) return ( {renderAppointmentHeader( "text-start flex justify-between items-center gap-3", )} {Array.from({ length: 2 }).map((_, i) => (
))}
); if (error) { return ( {renderAppointmentHeader( "text-start flex justify-between items-center gap-3", )}

Could not load appointments

Try again later.

); } return (
{categorizedAppointments.upcoming.length === 0 ? ( {renderAppointmentHeader( "text-start flex justify-between items-center gap-3", )}
{/* Card 240 / B10 — was an

, which put "what you do not have" into the document outline while the section itself had no heading at all. Styling unchanged. */}

No Upcoming Appointments

Book an appointment to get started with your care.

) : ( {renderAppointmentHeader( "border-b pb-4 text-start px-3 flex justify-between items-center gap-3", )} {categorizedAppointments.upcoming.map((appointment) => { const appointmentTasks = appointment.id ? appointmentTasksByAppointmentId.get(appointment.id) || [] : []; const practitionerName = getPractitionerName( appointment.participant, ); const practitionerImageUrl = getPractitionerImageUrl(appointment); return ( setOpenedAppointment(appointment)} onKeyDown={(event) => { if (event.target !== event.currentTarget) { return; } if (event.key === "Enter" || event.key === " ") { event.preventDefault(); setOpenedAppointment(appointment); } }} >

{formatDate(appointment?.start)}

{/* 🚨 Card 301 — "Today" is TRUE for a visit that is running and is not the useful thing to say about it. This list is the DOOR to a video visit: you join during the appointment, so the one fact worth surfacing at that moment is that it is happening. ⭐ Reuses the existing badge rather than inventing a treatment — the colour decisions are settled elsewhere and this needs none of them. */}

{isAppointmentHappeningNow(appointment) ? 'Happening now' : getDaysDifference(appointment?.start)}

{formatTime(appointment?.start)}

{getAppointmentType(appointment)}

{`${organizationName}

{organizationName}

Organization

{`${practitionerName} { event.currentTarget.onerror = null; event.currentTarget.src = doctorImage; }} />

Practitioner

{practitionerName}

{getSpecialty(appointment)}

Practitioner Instruction

{appointment.description || "No instructions provided."}

{/* The same block, now shared with the dialog this card opens and with the appointments list — see the note at the top of this file. */}
); })}
{/* ⭐ R5's DELIBERATE EXIT. A row click now opens the appointment in place; THIS is the control that takes you to the full page, and the count is what stops a patient wondering where their other appointments went. */} View all ({categorizedAppointments.upcoming.length})
)} !next && setOpenedAppointment(undefined)} appointment={openedAppointment} practitionerName={ openedAppointment ? getPractitionerName(openedAppointment.participant) : undefined } organizationName={organization?.name} />
); }; export default AppointmentCard;