import './PatientBookAppointment.css'; import { useCallback, useEffect, useState, useMemo, useRef, } from "react"; import { PractitionerRole, Practitioner, Schedule, Slot, Reference, AppointmentParticipant, Appointment, CodeableConcept, Organization, // Coding, } from "fhir/r4"; import { getScheduleSlotIncrement, slotStartTimes, windowEndOnDay, } from "../../../../../config/scheduleSlotIncrement"; import { getServiceFutureAppointmentLimit } from "../../../../../config/serviceFutureAppointmentLimit"; import { SUGGESTION_CONFIDENCE_FLOOR } from "../../../../../config/suggestionFloor"; import type { ServiceClarifyingQuestion } from "../../../../../services/BookingMatchService"; import { useForm, FormProvider, type Resolver } from "react-hook-form"; import { useAppointmentServiceContext, useAuthServiceContext, useEncounterServiceContext, useFhirServiceContext, usePractitionerRoleServiceContext, usePractitionerServiceContext, useScheduleServiceContext, useWebSocketServiceContext, usePatientServiceContext, } from "../../../../../services"; import { useFhirAppointments, useFhirLocations, useFhirPractitionerRoleBroadcast, useFhirPractitioners, useFhirScheduleBroadcast, useFhirSchedules, useRouteAuthorization, } from "../../../../../hooks"; import { useServiceCatalogueBasis } from "../../../../../hooks/useServiceCatalogueBasis"; import { useAwayBookingCatalog } from "../../../../../hooks/useAwayBookingCatalog"; import { useKeyedRead } from "../../../../../hooks/useKeyedRead"; import { formatFhirHumanName } from "../../../../../utils/fhirHumanName.tsx"; import { getCampaignCtaAttribution, clearCampaignCtaAttribution, } from "../../../../../utils/campaignCtaAttribution"; import PatientTimeWindowRequest from "./PatientTimeWindowRequest"; import SelfBookingAuthorization, { SelfBookingAuthorizationRefusal, selfBookingAuthorizationSatisfied, type SelfBookingAuthorizationGate, type SelfBookingAuthorizationHandle, } from "./SelfBookingAuthorization.tsx"; // New UI components import { Button } from "../../../../../components/ui/button"; import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectTrigger, SelectValue, } from "../../../../../components/ui/select"; import { ArrowBigLeftDash, CalendarDays, Check, Clock4, Phone, } from "lucide-react"; import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; import { Skeleton } from "../../../../../components/ui/skeleton.tsx"; import { formatDateForDisplay } from "../../../../../lib/utils.ts"; import { toast } from "sonner"; import { getScheduleBookingFlags } from "../../../../../services/ScheduleService.tsx"; import { getScheduleLocationReferences } from "../../../../../utils/scheduleLocations"; import { scheduleSiteFor } from '../../../../../utils/booking/physicalSite'; import { offerableLocationOptions, videoVisitFirst } from "../../../../../utils/scheduleTelehealth"; import { resolveServiceTelehealthEligible } from "../../../../../config/serviceTelehealthEligible"; import { resolveOrgAppointmentPolicy } from "../../../../../config/appointmentPolicy"; import { resolveAppointmentDurationMinutes } from "../../../../../config/appointmentDuration"; import { getOrganizationPolicyFlags } from "../../../../../services/OrganizationService"; import { useBookingMatchService } from "../../../../../services/BookingMatchService"; import { mapSuggestionsToBookableServices, type BookableSuggestion, recommendedSuggestionId, } from "../../../../../utils/booking/matchServiceSuggestions"; import { dateHasBookableWindow } from "../../../../../utils/booking/bookingDateAvailability"; import { qualifiesForRecommendation, mapPractitionerRankings, type ApiPractitionerRanking, type BookablePractitioner, type PractitionerRecommendation, } from "../../../../../utils/booking/matchPractitionerSuggestions"; import { reasonNamesALocation } from "../../../../../utils/booking/reasonNames"; import { buildBookablePractitioners, type BookablePractitionerCandidate, } from "../../../../../utils/booking/bookablePractitioners"; import { selfBookingGuardText } from "../../../../../utils/booking/selfBookingGuardMessage"; import { canBeBookedAsClinician } from "../../../../../utils/booking/clinicianSeatGate"; import { useSeatStanding } from '../../../../../utils/seatStanding'; import { schedulePractitionerName } from "../../../../../utils/booking/schedulePractitionerName"; import { schedulePractitionerId } from "../../../../../utils/booking/schedulePractitioner"; import { matchSchedules, evaluateScheduleMatch, offeredServices, type MatchContext } from "../../../../../utils/booking/matchSchedules"; import { softConstraints } from "../../../../../utils/booking/softConstraints"; import { deadEndText } from "../../../../../utils/booking/explainDeadEnd"; import { describeNothingToOffer, type NothingToOffer } from "../../../../../utils/booking/nothingToOffer"; import { availabilityWindowsFor } from "../../../../../utils/booking/scheduleYieldsTimes"; import { offerablePractitionerSchedules, opensFrom } from "../../../../../utils/booking/offerablePractitioners"; import { resolveScheduleSelfBookableServices } from "../../../../../utils/booking/scheduleSelfBookableServices"; import { resolveFlowState, clearFrom, isAnswered, lockedServiceFilter, STEP_ORDER, type BookingAnswers, type BookingStepId, } from "../../../../../utils/booking/bookingFlowSteps"; import { readPatientTypeFromEncounters } from "../../../../../utils/booking/patientBookingHistory"; import PractitionerRecommendationCallout from "./PractitionerRecommendationCallout"; import AnsweredLine from "./AnsweredLine"; import { getPractitionerRolePrimaryCoding, getPractitionerRolePrimaryDisplay, } from "../../../../../utils/staffRoleCatalog"; import { isVirtualLocation, onlySites, onlyVirtual } from "../../../../../utils/roomLocation"; import { resolveBookingLocationRef, withLocationDisplay } from "../../../../../utils/booking/bookingAppointmentLocation"; import { isOrgWideRole } from "../../../../../utils/locationAssignments"; import { intervalEndDate, resolveBookingTimeZone, viewerTimeZone, } from "../../../../../utils/practiceTimezone"; import { slotReadWindow } from "../../../../../utils/booking/slotReadWindow"; import { heldRangesOnDate, pickedTimeInstant } from "../../../../../utils/booking/patientPickedTime"; import { heldAsBusy, nextSendStanding, pickedTimeStanding, readBusyTimes, type SendStanding } from "../../../../../utils/booking/busyTimes"; import { returnLostPickToTimeChoice } from "../../../../../utils/booking/lostPick"; import { withSelectedServiceRef } from "../../../../../utils/booking/selectedServiceRef"; import { visitZoneLabel } from "../../../../../utils/booking/visitZoneLabel"; import { asDenial, readAppointmentGuardOutcome, readSlotReservationCode } from "../../../../../utils/booking/appointmentGuardOutcome"; import { HttpError } from "../../../../../services/HttpService.tsx"; import { bookingFailure, bookingRefusal } from "./bookingFailure"; import { StepRefusal } from "./StepRefusal"; import { STEP_NAMES, useStepFocus } from "./useStepFocus"; import { CheckingYourBooking, isBookingRetired, isBookingUnconfirmed, useBookingOperation, } from "../../../../../components/booking/CheckingYourBooking"; import type { BookingOperationEntry } from "../../../../../utils/booking/bookingOperation"; import { isNewPatientConsultationType } from "../../../../../config/newPatientExam"; import { reviewServiceLabel } from "../../../../../utils/booking/reviewServiceLabel"; import { withAppointmentLength } from "../../../../../utils/appointmentLength"; import { buildUnionAvailability, type AvailabilityWindow, type BookingBlock, type TaggedAvailabilityWindow, type UnionAvailability, } from "../../../../../utils/bookingAvailabilityUnion"; // Shape of the calendar events getScheduleAvailabilityEvents returns (Date-based). We convert these into // the string-typed AvailabilityWindow the union util consumes; typing them here avoids the untyped `any` // the previous inline filter used. interface RawScheduleAvailabilityEvent { start: Date; end: Date; extendedProps?: { type?: string }; } interface FhirCloudEvent { data: T; type: string; } const codeableConceptSchema = z.custom(); function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null; } function getErrorMessage(error: unknown, fallback: string): string { if (error instanceof Error && error.message.trim()) { return error.message; } if (!isRecord(error)) { return fallback; } if (typeof error.message === "string" && error.message.trim()) { return error.message; } const response = isRecord(error.response) ? error.response : undefined; const responseData = response?.data; if (typeof responseData === "string" && responseData.trim()) { return responseData; } if ( isRecord(responseData) && typeof responseData.message === "string" && responseData.message.trim() ) { return responseData.message; } return fallback; } // Schema for form validation const appointmentSchema = z.object({ practitionerId: z.string().min(1, "Please select a practitioner"), appointmentType: z.string().min(1, "Please select an appointment type"), date: z.string().min(1, "Please select a date"), startTime: z.string().min(1, "Please select a time slot"), endTime: z.string().min(1, "Please select a time slot"), description: z.string().optional(), comment: z.string().optional(), reasonForVisit: z .string() .trim() .min(1, "Please tell us the reason for your visit"), serviceCategory: z.array(codeableConceptSchema).optional(), serviceType: z.array(codeableConceptSchema).optional(), specialty: z.array(codeableConceptSchema).optional(), priority: z.number().optional(), patientInstruction: z.string().optional(), }); // react-hook-form builds its field-path union by walking every nested property of the form type. // CodeableConcept is deep enough that three such fields push tsc past its type-instantiation limit // and crash the compiler outright (RangeError: Map maximum size exceeded) instead of reporting an // error - which is why nothing in this repo type-checks. None of these three is bound to an input: // they are set programmatically and read once at submit, so no path types are needed for them. // Keeping them opaque here costs a cast at each read site and takes the file from a 3-minute crash // to a 6-second check. The zod schema above still validates them as CodeableConcept at runtime. type FormOpaqueCodings = unknown[]; type AppointmentFormData = Omit< z.infer, "serviceCategory" | "serviceType" | "specialty" > & { serviceCategory?: FormOpaqueCodings; serviceType?: FormOpaqueCodings; specialty?: FormOpaqueCodings; }; // THE HAND-TYPED HALF-HOUR LADDER USED TO LIVE HERE, and it is deleted rather than left unused. // It was a 24-hour table from 06:00 to 05:30 that the patient's time picker filtered by availability, // so every practice was offered thirty-minute slots whatever increment it had chosen, at hours it does // not work. The grid is now generated from each schedule block's own hours and increment - see // `config/scheduleSlotIncrement`. Left in the file it would be reached for again. // Radix throws on an empty-string value, so "Any location" - an explicit, active choice // meaning "no location preference, show every site" - uses this sentinel instead of ''. '' is reserved // for "nothing chosen yet" (the Select shows its placeholder); ANY_LOCATION is a real, distinct selection // that schedulesMatching treats the same as "no location constraint" when narrowing schedules. const ANY_LOCATION = "__all__"; function formatDateOnly(date: Date | null) { return date ? date.getFullYear() + "-" + String(date.getMonth() + 1).padStart(2, "0") + "-" + String(date.getDate()).padStart(2, "0") : ""; } // Local-time ISO string ("YYYY-MM-DDTHH:mm:ss.SSS", NO trailing Z) for a Date. The whole availability // pipeline is LOCAL-time: isTimeSlotAvailable and the union resolver both build the slot instant with // `new Date(`${dateStr}T${HH:mm}:00`)` (local). Serializing the Date-based schedule windows with // toISOString() would emit UTC and offset against that local slot instant - so we serialize the local // components instead, and `new Date(localIso)` reproduces the exact same local instant. function toLocalIso(date: Date): string { return ( date.getFullYear() + "-" + String(date.getMonth() + 1).padStart(2, "0") + "-" + String(date.getDate()).padStart(2, "0") + "T" + String(date.getHours()).padStart(2, "0") + ":" + String(date.getMinutes()).padStart(2, "0") + ":" + String(date.getSeconds()).padStart(2, "0") + "." + String(date.getMilliseconds()).padStart(3, "0") ); } // Helper function to check if a time slot is within availability events function isTimeSlotAvailable( slotTime: string, dateStr: string, availabilityEvents: AvailabilityWindow[], durationMinutes: number = 30, ): boolean { if (!dateStr || availabilityEvents.length === 0) return false; const now = new Date(); const slotDateTime = new Date(`${dateStr}T${slotTime}:00`); const slotEndDateTime = new Date(slotDateTime.getTime() + durationMinutes * 60000); return availabilityEvents.some((event) => { // Windows are local-ISO strings (see toLocalIso); parse them back to the same local instant the slot // is built in, so this comparison stays timezone-consistent (never mix a UTC basis in here). const eventStart = new Date(event.start); const eventEnd = new Date(event.end); // The whole appointment [start, start+duration) must fit inside a single availability window. return ( slotDateTime >= eventStart && slotEndDateTime <= eventEnd && slotDateTime >= now ); }); } // Helper function to check if a time slot is booked function isTimeSlotBooked( slotTime: string, busyRanges: { startMin: number; endMin: number }[], durationMinutes: number, ): boolean { if (busyRanges.length === 0) return false; // The candidate appointment [start, start+duration) must not overlap any busy range (minutes-of-day). const [h, m] = slotTime.split(":").map(Number); const candStart = h * 60 + m; const candEnd = candStart + durationMinutes; return busyRanges.some((r) => candStart < r.endMin && r.startMin < candEnd); } // Main Component interface PatientBookAppointmentProps { onSuccess?: (message: string, appointment?: Appointment) => void; onClose?: () => void; /** * Rendered inside a dialog rather than as the `book-appointment` ROUTE. * * This exists for one reason: the route guard below. It answers "may this person be AT this URL", * and the answer for the patient self-booking route is patients only - but the same component is * also opened as a dialog from the patient's chart, where the viewer is STAFF by definition. The * guard therefore fired on the person legitimately using it and redirected them off the chart. * * DEFAULTS TO FALSE SO THE GUARD STAYS ON, deliberately. A future DIALOG site that forgets this * reproduces a visible redirect; a future ROUTE that forgot it would be an unguarded route. Of the * two ways to be wrong, only one is silent. */ embedded?: boolean; /** Another practice the patient is booking with from this portal; absent means the portal's own. */ bookingOrganization?: Organization; } const PatientBookAppointment = ({ onSuccess, onClose, embedded = false, bookingOrganization, }: PatientBookAppointmentProps) => { const { USER_ROLES, wpOrganization, userRole } = useAuthServiceContext(); const viewerIsPatient = [USER_ROLES.PATIENT, USER_ROLES.RELATED_PERSON].includes(userRole as string); const { patientState } = usePatientServiceContext(); // Booking with another practice reads and writes under that practice, so its own rules decide; the // portal's branding and shared lists are left as they are. The portal's own practice books as before. const awayOrganization = bookingOrganization?.id && bookingOrganization.id !== wpOrganization.current?.id ? bookingOrganization : undefined; const bookingOrg: Organization | undefined = awayOrganization ?? wpOrganization.current ?? undefined; const orgOverride = awayOrganization?.id; const away = useAwayBookingCatalog(awayOrganization, patientState ?? undefined); const { subscribe, unsubscribe } = useWebSocketServiceContext(); const { transformFromFhirPractitionerRole } = usePractitionerRoleServiceContext(); const { schedulesList: sharedSchedulesList, getScheduleAvailabilityEvents, fetchSlots, fetchHeldTimes, createSlot, transformToFhirSlot, } = useScheduleServiceContext(); const schedulesList = away ? away.schedules : sharedSchedulesList; const { practitionersList: sharedPractitionersList } = usePractitionerServiceContext(); const practitionersList = away ? away.practitioners : sharedPractitionersList; // Org locations - used to surface bookable sites for a practitioner whose role is scoped to // "All locations" (an empty PractitionerRole.location), which otherwise contributes no options. const { locationsList: sharedLocationsList } = useFhirLocations({ autoLoad: !awayOrganization }); const locationsList = away ? away.locations : sharedLocationsList; // Only services a whole current catalogue verifies are offered: the away practice's own basis, or the portal's. // A practice whose services cannot be verified offers none and says so; it is never read as offering nothing. const primaryServicesBasis = useServiceCatalogueBasis(); const servicesBasis = away ? away.servicesBasis : primaryServicesBasis; const retryServices = away ? away.retryServices : primaryServicesBasis.retry; const healthcareServicesList = servicesBasis.services; const healthcareServicesLoading = servicesBasis.state === 'checking'; const servicesUnverified = servicesBasis.state !== 'complete'; const { filterAndUnpackBundle, extractReferenceType, extractReferenceId, // unpackBundle, createReferenceObject, } = useFhirServiceContext(); // const { getAdjustedHeight } = useConfigServiceContext(); const { fetchEncounters } = useEncounterServiceContext(); const { createAppointment, fetchAppointments, setAppointmentsList, getActiveFutureAppointmentForPractitioner: activeFutureFromCache, } = useAppointmentServiceContext(); // The booking's operation outlives this dialog: a lost answer is checked here, not booked again. const booking = useBookingOperation({ opening: "patient-booking", practice: bookingOrg?.id, patient: patientState?.id, }); booking.onBooked((appointment) => { const pending = appointment.status === "proposed" || appointment.status === "pending"; onSuccess?.(pending ? "Your appointment has been submitted and is pending confirmation by the practice." : "Appointment created successfully.", appointment); }); const awayAppointments = away?.appointments; const refreshAwayAppointments = away?.refreshAppointments; const getActiveFutureAppointmentForPractitioner = useCallback( (practitionerId: string, roleIds?: string[], appointments?: Appointment[], limit?: number, codingKey?: string) => activeFutureFromCache(practitionerId, roleIds, appointments ?? awayAppointments, limit, codingKey), [activeFutureFromCache, awayAppointments], ); // Reason-for-visit -> suggested service ("Find my service") and -> ranked practitioners. // Both fail open to [] on any error, so they can only ever degrade to the existing manual pickers // below, never block booking. const { matchService, matchPractitioner, matchingAvailable = true } = useBookingMatchService(); // const navigate = useNavigate(); // State management const [submitting, setSubmitting] = useState(false); // const [success, setSuccessState] = useState(); // const [error, setError] = useState(); const [selectedSchedule, setSelectedSchedule] = useState( null, ); const [practitionerRole, setPractitionerRole] = useState(null); const [practitioner, setPractitioner] = useState(); // Availability is now the UNION of every location-scoped block the selected practitioner works that // matches the current filters, so each window is tagged with its source block for slot->block // resolution and site labels. String-typed (local-ISO) - see toLocalIso / buildUnionAvailability. const [availabilityEvents, setAvailabilityEvents] = useState< TaggedAvailabilityWindow[] >([]); const [reviewStep, setReviewStep] = useState(1); // 1: selection, 2: review, 3: confirmation const stepBodyRef = useStepFocus(reviewStep); /* * WHAT WAS BOOKED, KEPT SO THE CONFIRMATION CAN SAY IT. * * Step 3 existed as a number and had no screen: the code that would have reached it was commented out, * the render had no branch for it, and the button block returned an empty string. So a patient who * confirmed got a toast and a wiped form, and the last step of the first thing they ever do in the * product acknowledged nothing. * * This is a SNAPSHOT rather than a read of live state, because clearing the form is what has to happen * next - the practitioner, date, slot and service are all reset, and a confirmation rendered from them * would empty itself out from under the patient. */ const [bookingConfirmation, setBookingConfirmation] = useState<{ practitionerName?: string; dateLabel: string; timeLabel: string; locationLabel: string; serviceLabel: string; pendingPracticeConfirmation: boolean; } | null>(null); /** * Why the last Confirm Booking did not book, shown INSIDE the dialog. * * Not a duplicate of the toast beside it. A toast is position:fixed inside a provider tree mounted * into a practice's own WordPress page, so any transformed ancestor positions it against that ancestor * instead of the viewport - where it can land off-screen. That is what a browser run on 2026-08-20 saw: * a 403 from the authorization step, and no message of any kind on the screen. */ const [confirmRefusal, setConfirmRefusal] = useState(); // Why a refusal sent the patient back to the first step; shown there, announced and focused. const [stepOneRefusal, setStepOneRefusal] = useState(); useEffect(() => { if (reviewStep !== 1) setStepOneRefusal(undefined); }, [reviewStep]); const [selectedPractitioner, setSelectedPractitioner] = useState<{ name: string; role: string; image: string; } | null>(null); // const [encounter, setEncounter] = useState(); // const [encounterLoading, setEncounterLoading] = useState(false); const [formDataForSubmission, setFormDataForSubmission] = useState(null); const [selectedDate, setSelectedDate] = useState(null); const [checkingSelfBookingGuard, setCheckingSelfBookingGuard] = useState(false); const selfBookingGuardRequestRef = useRef(0); // The union resolver (Task 1) for the currently selected practitioner: maps a picked (date, HH:mm) slot // back to the location-scoped block that owns it. Held in a ref because booking/loadSlots read it // imperatively and it must not trigger a re-render; rebuilt whenever a practitioner is (re)selected. const resolveBlockForSlotRef = useRef< UnionAvailability["resolveBlockForSlot"] | null >(null); // The clinical-authorization step rendered in review Step 2 below. handleCreateAppointment awaits // recordAnswer() first thing inside its try block, before anything is created - and that call // REJECTS unless the patient has actually answered, so a failure aborts the booking instead of // producing an appointment the patient believes they authorized. // // The ref is the gate; the state below is only the button's view of it. The ref reads the // component's own current state, so it is right even in the render between mounting and the first // gate callback - which is exactly the window a state-only gate would leave open. const selfBookingAuthorizationRef = useRef(null); // Defaults to "not-required" on purpose: the step is mounted only in review Step 2, and on the // paths that never mount it (request-only / time-window bookings) nothing should be gated. The // component pushes "checking" the moment it mounts. const [selfBookingAuthorizationGate, setSelfBookingAuthorizationGate] = useState("not-required"); const [selectedServiceFilter, setSelectedServiceFilter] = useState(''); const [selectedLocationFilter, setSelectedLocationFilter] = useState(''); // "Find my service" (reason -> suggested service) affordance state - additive to, never a // replacement for, the manual Care-type Select below. const [suggestions, setSuggestions] = useState([]); const [suggestLoading, setSuggestLoading] = useState(false); const [suggestTried, setSuggestTried] = useState(false); // One question the server offers when more than five services cleared the floor - a list too long to // choose from. Answering is OPTIONAL: the long list is worse, not forbidden, so nothing here gates // the booking and "See all services" stays reachable throughout. const [clarifyQuestion, setClarifyQuestion] = useState(); const [clarifyAnswer, setClarifyAnswer] = useState(""); // How many services matched above the floor. Bigger than what is drawn whenever the list was // long, which is exactly when a reader needs to be told so before they start scrolling. const [matchedCount, setMatchedCount] = useState(0); // Undefined when the top two are too close to separate - the screen then marks nothing and // says nothing that implies a preference, which is the honest reading of a near tie. const recommendedId = useMemo(() => recommendedSuggestionId(suggestions), [suggestions]); // Escape hatch for the now-required "Find my service" assist (Task 7) - set true by either fallback // door (assist failed / assist succeeded but nothing fit), reveals the manual Care-type Select. const [showAllServices, setShowAllServices] = useState(false); // "Recommended for your reason" - reason + chosen service -> ranked practitioners. The RAW // server ranking depends only on (reason, serviceId); it is location-INVARIANT, so we cache it here and // derive the display recommendations (incl. the location-dependent crossLocation flag) with a pure memo // below - a location toggle then re-maps without re-invoking Bedrock. Empty = the fail-open default. const [practitionerRankings, setPractitionerRankings] = useState([]); /* * DECLARED HERE BECAUSE THE MEMO BELOW READS IT, and a component body runs top to bottom. * * Both of these used to sit about a thousand lines further down, after * `activePractitionerAppointment`. Reading a `const` above its own declaration in the same scope * is a temporal dead zone, so choosing a service killed the whole booking screen with * `Cannot access '_t' before initialization` - the minified name - and took the error boundary * with it. * * IT HID BEHIND AN EARLY RETURN. `activePractitionerAppointment` returns before touching this * when no practitioner is resolved, which is every render until a service is picked. Picking one * resolves a practitioner, the guard stops stopping it, and the read happens for the first time - * so the crash landed on the step AFTER the change that caused it, on all three entry points that * mount this screen. */ const serviceById = useMemo( () => new Map((healthcareServicesList ?? []).map((s) => [s.id, s])), [healthcareServicesList], ); /** * THE PRACTICE'S CAP FOR THE SERVICE BEING BOOKED, and its coding. * * Ruled 2026-09-06: no cap by default, an optional per-service limit. Both are undefined until a * service is chosen, which is correct rather than incidental - there is no per-service rule to apply * before the patient has picked one, and refusing then is the defect this replaced. * * A MIRROR OF THE SERVER'S RULE, and it decides only whether the picker offers a booking the server * would refuse. The server counts the same thing again before it writes. */ const chosenServiceCap = useMemo(() => { const service = selectedServiceFilter ? serviceById.get(selectedServiceFilter) : undefined; const coding = service?.type?.[0]?.coding?.[0]; return { limit: getServiceFutureAppointmentLimit(service), codingKey: coding?.code ? `${coding.system ?? ''}|${coding.code}` : undefined, }; }, [selectedServiceFilter, serviceById]); const activePractitionerAppointment = useMemo(() => { if (!practitioner?.id) { return undefined; } return getActiveFutureAppointmentForPractitioner( practitioner.id, practitionerRole?.id ? [practitionerRole.id] : [], undefined, chosenServiceCap.limit, chosenServiceCap.codingKey, ); }, [ getActiveFutureAppointmentForPractitioner, practitioner?.id, practitionerRole?.id, ]); // const visibleRangeRef = useRef<{ start: Date; end: Date } | null>(null); // // Check if user is patient // const isPatient = [USER_ROLES.PATIENT, USER_ROLES.RELATED_PERSON].includes( // userRole as string, // ); // Form setup const methods = useForm({ // The schema still describes the three coded fields as CodeableConcept; AppointmentFormData // deliberately hides that shape from react-hook-form (see FormOpaqueCodings). Same values, // two views of them, so the resolver is restated in the form's view at that boundary. resolver: zodResolver(appointmentSchema) as Resolver, defaultValues: { practitionerId: "", appointmentType: "ROUTINE", date: "", startTime: "", endTime: "", description: "", comment: "", reasonForVisit: "", serviceCategory: [], serviceType: [], specialty: [], priority: 0, patientInstruction: "", }, mode: "onChange", }); const { // handleSubmit, reset, watch, setValue, trigger, getValues, formState: { isValid, errors }, } = methods; // Watched values const watchStartTime = watch("startTime"); // const watchEndTime = watch("endTime"); const watchDate = watch("date"); const watchPractitionerId = watch("practitionerId"); const watchAppointmentType = watch("appointmentType"); const watchReasonForVisit = watch("reasonForVisit"); const selectedPractitionerName = selectedPractitioner?.name || "this practitioner"; const organizationName = bookingOrg?.name || "the organization"; const organizationPhone = bookingOrg?.telecom?.find( (contact) => contact.system === "phone" && contact.value, )?.value; const orgPolicy = getOrganizationPolicyFlags(bookingOrg); /* * THE SERVICE'S OWN DURATION, resolved exactly as the front desk resolves it. * * This read the org default and nothing else, under a comment saying patient self-booking has "no * per-service picker in this flow". That was true when it was written and is not true now - the patient * picks a service, or the booking assist picks one for them. The comment is why it went unnoticed for * so long: it reads as considered, so a reader takes it as current instead of checking it. * * ONE VALUE FEEDS THREE READS, and that is what made the failure total. The date-has-a-window check, * the slot filter and the saved appointment all take this number. So a practice that set a 45-minute * new-patient exam was offered 30-minute slots AND had a 30-minute appointment written - the offer and * the write were wrong from one cause, which is also why one line fixes both. * * The last slot of the day comes out right for free: `isTimeSlotAvailable` bounds a slot by whether the * duration fits before close, so a 45-minute service stops being offered at 4:30 PM against a 5 PM * close once the number is correct. */ const selectedServiceForDuration = useMemo( () => selectedServiceFilter ? (healthcareServicesList ?? []).find((service) => service.id === selectedServiceFilter) : undefined, [healthcareServicesList, selectedServiceFilter], ); const appointmentDurationMinutes = resolveAppointmentDurationMinutes( selectedServiceForDuration, orgPolicy.defaultAppointmentDurationMinutes, ); // Request-only: a patient self-booking becomes a `proposed` request a receptionist confirms. const isRequestOnlyBooking = orgPolicy.bookingMode === "request-only"; // Review: a direct self-booking is held as `pending` for staff approval (same booking UI as direct). const isReviewBooking = orgPolicy.bookingMode === "review"; // The policy holds only a patient's own booking; the server books staff directly, so the chart says Book. const asksAsRequest = viewerIsPatient && isRequestOnlyBooking; const heldForReview = viewerIsPatient && (isReviewBooking || isRequestOnlyBooking); // Time-window request: a slot-less request (no practitioner/slot) - rendered as a dedicated form. const isTimeWindowRequest = isRequestOnlyBooking && orgPolicy.requestStyle === "time-window"; // New-patient-exam gate applies only to DIRECT self-booking - in request-only the receptionist // handles the new-patient flow on confirm (mirroring the server, which skips the gate there). const requireNewPatientExamGate = orgPolicy.requireNewPatientExamFirst && !isRequestOnlyBooking; // HAS THIS PATIENT BEEN SEEN HERE? Spencer, 2026-09-09: "New patient should be enforced on 'seen' // meaning they've actually had an encounter, not just appointments." An appointment is an intention - // it can be for next week, cancelled or missed - so this reads Encounters, filtered to this // organization because "new" belongs to the patient-practice relationship rather than to the person. // // The server reads the same record and is the boundary; this only decides what the patient is // OFFERED. The two use the same statuses and the same organization test deliberately, so the list a // patient sees and the booking the server will accept cannot disagree. // // There is no third "unknown" answer any more, and so no question to ask the patient. The old read // could not tell established from unrecorded, so the flow asked; a patient's answer could never // override a record we hold, and now we hold it. const [derivedPatientType, setDerivedPatientType] = useState< "existing" | "new" | "loading" >("loading"); useEffect(() => { if (!requireNewPatientExamGate || !patientState?.id) { setDerivedPatientType("existing"); return; } let cancelled = false; void readPatientTypeFromEncounters( () => fetchEncounters({ patient: patientState, organizationId: orgOverride }), bookingOrg?.id, ).then((type) => { if (!cancelled) setDerivedPatientType(type); }); return () => { cancelled = true; }; }, [requireNewPatientExamGate, patientState, fetchEncounters, orgOverride, bookingOrg?.id]); // Single answers object for the guided flow (Tasks 7-9 render the remaining steps off this). const [flowAnswers, setFlowAnswers] = useState({}); // handleEditStep is defined further down (after clearSelectedPractitioner, which it now calls) - see // that definition for the full rationale. Kept out of this spot because it needs clearSelectedPractitioner // in its dependency array, and that callback isn't declared until later in this component. // THE RECORD DECIDES THIS, NOT THE PATIENT. It used to read flowAnswers.patientType - what the // patient said about themselves in step 0. const restrictToNewPatientExam = requireNewPatientExamGate && derivedPatientType === "new"; // The patient no longer picks an appointment type (it's receptionist-owned). A self-booking online is // a scheduled/routine visit (the "how they came in" modality) - always ROUTINE; the receptionist can // refine to walk-in/emergency on confirm. serviceType is written from the chosen service's own type. useEffect(() => { setValue("appointmentType", "ROUTINE", { shouldValidate: true }); setValue("serviceType", [], { shouldValidate: true }); }, [setValue]); // HOW MANY OTHER PRACTITIONERS THE PATIENT COULD STILL BOOK IS AN ARGUMENT, NOT A CLOSURE. // The candidate list is derived several hundred lines below this, so closing over it here would put // a const in this dependency array before its own declaration has run - a temporal dead zone error // on every render, not a subtle one. Both call sites are below that list and pass it in. const buildSelfBookingGuardMessage = useCallback( (appointment: Appointment | undefined, otherPractitionersAvailable: number): string => { const appointmentStart = appointment?.start ? new Date(appointment.start) : undefined; const appointmentDate = appointmentStart && !Number.isNaN(appointmentStart.getTime()) ? appointmentStart.toLocaleString(undefined, { weekday: "long", year: "numeric", month: "long", day: "numeric", hour: "numeric", minute: "2-digit", }) : undefined; return selfBookingGuardText({ practitionerName: selectedPractitionerName, existingStartLabel: appointmentDate, organizationName, organizationPhone, otherPractitionersAvailable, }); }, [organizationName, organizationPhone, selectedPractitionerName], ); const showSelfBookingGuardMessage = useCallback( (appointment: Appointment | undefined, otherPractitionersAvailable: number) => { toast.error(buildSelfBookingGuardMessage(appointment, otherPractitionersAvailable), { position: "top-right", duration: 8000, }); }, [buildSelfBookingGuardMessage], ); const refreshSelfBookingConflict = useCallback( async ( practitionerId: string | undefined = practitioner?.id, practitionerRoleIds: string[] = practitionerRole?.id ? [practitionerRole.id] : [], ): Promise => { if (!patientState || !practitionerId) { return undefined; } const latestAppointments = refreshAwayAppointments ? await refreshAwayAppointments() : filterAndUnpackBundle(await fetchAppointments(patientState), "Appointment") as Appointment[]; if (!refreshAwayAppointments) setAppointmentsList(latestAppointments); return getActiveFutureAppointmentForPractitioner( practitionerId, practitionerRoleIds, latestAppointments, chosenServiceCap.limit, chosenServiceCap.codingKey, ); }, [ fetchAppointments, filterAndUnpackBundle, getActiveFutureAppointmentForPractitioner, patientState, practitioner?.id, practitionerRole?.id, setAppointmentsList, refreshAwayAppointments, ], ); // A ROUTE guard, and it only makes sense on the route. Mounted as a dialog from a patient's chart // the caller is staff, no workspace of theirs is in this list, and the hook redirected to '/' - so // clicking "Book Appointment" inside a patient record threw the user out of the record entirely, // four pushes deep, landing on their own dashboard with no dialog and no explanation. useRouteAuthorization({ allowedRoles: [USER_ROLES.PATIENT, USER_ROLES.RELATED_PERSON], enabled: !embedded, }); const { loading: sharedSchedulesLoading } = useFhirSchedules({ autoLoad: !awayOrganization }); useFhirScheduleBroadcast(); const { loading: sharedPractitionersLoading } = useFhirPractitioners({ autoLoad: !awayOrganization }); useFhirPractitionerRoleBroadcast(); useFhirAppointments({ autoLoad: !awayOrganization }); const schedulesLoading = away ? away.loading : sharedSchedulesLoading; const practitionersLoading = away ? away.loading : sharedPractitionersLoading; // Hold the selection cascade behind a single skeleton while the schedule + practitioner data loads, so the // fields appear together instead of Reason/Practitioner popping in a second before Care type/Location. const isBookingDataLoading = schedulesLoading || practitionersLoading; // useFhirAppointmentBroadcast({ // onCreate: (createdAppointment) => { // setAppointmentsList((prev) => [...prev, createdAppointment]); // }, // onUpdate: (updatedAppointment) => { // setAppointmentsList((prev) => // prev.map((p) => // p.id === updatedAppointment.id ? updatedAppointment: p, // ), // ); // }, // onDelete: (deletedAppointment) => { // setAppointmentsList((prev) => // prev.filter((p) => p.id !== deletedAppointment.id), // ); // }, // }); // The distinct booking Schedules whose union availability falls on a given date. A multi-location // practitioner booked under "Any location" can have a different location-scoped Schedule per date (or // even an AM/PM split across two sites on one date), so busy-Slot loading must target the block(s) that // OWN the selected date - not the single representative schedule. Derived from the tagged union events; // falls back to the representative schedule defensively if the tags didn't resolve. const getSchedulesForDate = useCallback( (dateStr: string): Schedule[] => { const scheduleIds = new Set(); for (const event of availabilityEvents) { if (formatDateOnly(new Date(event.start)) !== dateStr) continue; if (event.block.scheduleId) scheduleIds.add(event.block.scheduleId); } const result: Schedule[] = []; for (const id of scheduleIds) { const found = schedulesList.find((s) => s.id === id); if (found) result.push(found); } if (result.length === 0 && selectedSchedule) result.push(selectedSchedule); return result; }, [availabilityEvents, schedulesList, selectedSchedule], ); // Load busy Slots for the selected date across every block that owns that date (see getSchedulesForDate), // merging them - under the non-overlapping-block invariant the merged busy ranges are correct (an AM // site's busy slot only overlaps AM-available grid times, and vice versa). // Each block's busy Slots and the practitioner's held time anywhere, read together and keyed on the blocks, the // date, the patient's clock and the practice: an answer for an earlier choice is never this one's, and a failed // read offers nothing. A refresh reads the same key again. const [busyAttempt, setBusyAttempt] = useState(0); const busySchedules = useMemo( () => (watchDate ? getSchedulesForDate(watchDate) : []), [watchDate, getSchedulesForDate], ); const busyKey = watchDate && selectedSchedule && busySchedules.length > 0 ? `${busySchedules.map((schedule) => schedule.id).join(",")}|${watchDate}|${viewerTimeZone()}|${orgOverride ?? ""}` : undefined; const readBusy = useCallback( async (key: string) => { // The patient's list draws the date on their own clock, and heldRangesOnDate reads the holds on it, so // the read is that local day: a UTC day ended at 7 PM in Chicago and dropped the evening's holds. const [, date, clock] = key.split("|"); const { start, end } = slotReadWindow(date, [clock]); try { return await readBusyTimes(busySchedules, start, end, { fetchSlots, fetchHeldTimes, unpack: (bundle) => filterAndUnpackBundle(bundle, "Slot") as Slot[], }, orgOverride); } catch (error) { console.error("Failed to load slots:", error); throw error; } }, [busySchedules, fetchSlots, fetchHeldTimes, filterAndUnpackBundle, orgOverride], ); const busyRead = useKeyedRead(busyKey, readBusy, busyAttempt); const slots = useMemo(() => (busyRead.status === "ready" ? busyRead.value.slots : []), [busyRead]); const heldTimes = useMemo(() => (busyRead.status === "ready" ? busyRead.value.held : []), [busyRead]); const loading = busyRead.status === "pending"; const loadSlots = useCallback((_date?: string) => setBusyAttempt((attempt) => attempt + 1), []); // Handle schedule change (practitioner selection). `options.silent` skips the `flowAnswers.practitioner` // write - used only by the single-remaining-practitioner auto-select effect below, so an auto-locked step // stays locked (bookingFlowSteps.ts resolves `answered, editable: true` the moment `answers.practitioner` // is truthy, which would otherwise flip the lock off and drop its stated reason on the very next render). const handleScheduleChange = ( scheduleId: string, options?: { silent?: boolean }, ) => { const schedule = schedulesList.find((s) => s.id === scheduleId); if (!schedule) return; const practitionerRoleRef: Reference | undefined = schedule?.actor?.find( (ref: Reference) => extractReferenceType(ref) === "PractitionerRole", ); if (!practitionerRoleRef?.reference) { toast.error("Practitioner role not found.", { position: "top-right" }); return; } const practitionerRoleId = extractReferenceId(practitionerRoleRef); const practitionerWithRoles = practitionersList.find( (practitionerWithRoles) => practitionerWithRoles.roles.some( (role) => role.id === practitionerRoleId, ), ); if (!practitionerWithRoles) { toast.error("Practitioner role not found.", { position: "top-right" }); return; } const selectedPractitionerRole = practitionerWithRoles.roles.find( (role) => role.id === practitionerRoleId, ) as PractitionerRole; const selectedPractitioner = practitionerWithRoles.practitioner; setSelectedSchedule(schedule); setPractitionerRole(selectedPractitionerRole); setPractitioner(selectedPractitioner); if (!options?.silent) { setFlowAnswers((prev) => ({ ...prev, practitioner: scheduleId })); } // A practitioner change invalidates any previously selected date/time and review state. setValue("practitionerId", scheduleId, { shouldValidate: true }); setValue("date", "", { shouldValidate: true }); setValue("startTime", "", { shouldValidate: true }); setValue("endTime", "", { shouldValidate: true }); setSelectedDate(null); setFormDataForSubmission(null); setReviewStep(1); // Update selected practitioner display const fullName = formatFhirHumanName(selectedPractitioner.name, { emptyText: '' }); setSelectedPractitioner({ name: fullName, role: practitionerRoleRef.display || "Practitioner", image: `https://i.pravatar.cc/100?u=${selectedPractitioner.id}`, }); // Availability is the UNION of every location-scoped block this practitioner works that still matches // the current service/location filters (, Finding B). Under "Any location" a practitioner can // have one Schedule per site; pinning availability to the picked representative block hid the other // sites' dates/times. Gather all matching blocks (same service/location/new-patient-exam scope the // practitioner list itself uses), convert each block's Date availability windows to local-ISO, union // them, and tag each window with its block for slot->block resolution (Task 4) + site labels (Task 5). // Filter to 'availability' events only: getScheduleAvailabilityEvents also returns 'not-available' // (time-off) background events, which must NOT count as bookable. The representative // selectedSchedule/Role stay set above for the single-schedule scaffolding (display, loadSlots fallback). const practitionerId = selectedPractitioner.id; const matchingBlockSchedules = practitionerId ? schedulesMatching({ service: selectedServiceFilter, location: selectedLocationFilter, practitionerId, }) : []; // Defensive: always union at least the picked block, even if the match set somehow came back empty. const blockSchedules = matchingBlockSchedules.length > 0 ? matchingBlockSchedules : [schedule]; const perBlock = blockSchedules .map((blockSchedule) => { const blockRole = getScheduleRole(blockSchedule); const events = blockWindowsFor(blockSchedule); if (!blockRole?.id || !events) return undefined; // Resolve the block's location ref ( review, Findings A+B). Today's model is one // location per block, so `[0]` and the filter-matched entry coincide in practice - but when a // specific site is selected, prefer the entry that actually matches it (hardens against a // legacy multi-location role with no Schedule Location actor) rather than blindly taking the // first. Then backfill a display the same way locationSiteOptions does (ref display -> org // location name by id -> bare id): downstream readers (isMultiSiteAnyLocation, availableDates, // availableTimeOptions, the review-step site row) all read `block.locationRef?.display` with no // fallback of their own, so an actor ref with no `display` would otherwise blank the site label // everywhere even though the booking's `reference` is still correct. const blockLocationRefs = getScheduleLocationRefs(blockSchedule, blockRole); const chosenLocationRef = selectedLocationFilter && selectedLocationFilter !== ANY_LOCATION ? (blockLocationRefs.find( (l) => l.reference?.split("/").pop() === selectedLocationFilter, ) ?? blockLocationRefs[0]) : blockLocationRefs[0]; const block: BookingBlock = { scheduleId: blockSchedule.id as string, practitionerRoleId: blockRole.id, locationRef: chosenLocationRef ? { reference: chosenLocationRef.reference, display: chosenLocationRef.display || locationsList.find( (loc) => loc.id === chosenLocationRef.reference?.split("/").pop(), )?.name || chosenLocationRef.reference?.split("/").pop(), } : undefined, serviceRef: selectedServiceFilter ? getScheduleSelfBookableServices(blockSchedule).find( (svc) => svc.id === selectedServiceFilter, ) : undefined, }; return { block, events }; }) .filter( ( entry, ): entry is { block: BookingBlock; events: AvailabilityWindow[] } => entry !== undefined, ); const union = buildUnionAvailability(perBlock); setAvailabilityEvents(union.events); resolveBlockForSlotRef.current = union.resolveBlockForSlot; // The self-booking guard must check EVERY location's role, not just the representative one, so an // existing appointment booked at another site is still caught. (findActiveFutureAppointmentForPractitioner // matches on Practitioner OR any of these role refs; the Practitioner id alone already catches // self-bookings, but passing all roles keeps the role-only match path complete too.) const matchingRoleIds = Array.from( new Set( blockSchedules .map((blockSchedule) => getScheduleRole(blockSchedule)?.id) .filter((id): id is string => Boolean(id)), ), ); const guardRequestId = selfBookingGuardRequestRef.current + 1; selfBookingGuardRequestRef.current = guardRequestId; setCheckingSelfBookingGuard(true); void refreshSelfBookingConflict( selectedPractitioner.id, matchingRoleIds.length > 0 ? matchingRoleIds : selectedPractitionerRole.id ? [selectedPractitionerRole.id] : [], ) .catch((error) => { if (selfBookingGuardRequestRef.current !== guardRequestId) { return; } console.error("Failed to verify existing appointments:", error); toast.error( "Unable to verify your existing appointments. They will be checked again before booking.", { position: "top-right" }, ); }) .finally(() => { if (selfBookingGuardRequestRef.current === guardRequestId) { setCheckingSelfBookingGuard(false); } }); }; // WebSocket subscriptions useEffect(() => { const handleScheduleEvent = (cloudEvent: FhirCloudEvent) => { const schedule: Schedule = cloudEvent.data; if (schedule.id === selectedSchedule?.id) { // Update schedule if needed const index = schedulesList.findIndex((s) => s.id === schedule.id); if (index !== -1) { // Schedule updated, you may want to refresh the list } } }; subscribe( "com.akashichealthrecords.fhir.schedule.updated", handleScheduleEvent, ); return () => { unsubscribe( "com.akashichealthrecords.fhir.schedule.updated", handleScheduleEvent, ); }; }, [subscribe, unsubscribe, selectedSchedule, schedulesList]); useEffect(() => { const handlePractitionerRoleEvent = ( cloudEvent: FhirCloudEvent, ) => { const practitionerRolePayload: PractitionerRole = cloudEvent.data; if (practitionerRolePayload.id === practitionerRole?.id) { setPractitionerRole(practitionerRolePayload); // Reload slots if date is selected if (watchDate) { loadSlots(watchDate); } } }; subscribe( "com.akashichealthrecords.fhir.practitionerrole.updated", handlePractitionerRoleEvent, ); return () => { unsubscribe( "com.akashichealthrecords.fhir.practitionerrole.updated", handlePractitionerRoleEvent, ); }; }, [subscribe, unsubscribe, practitionerRole, loadSlots, watchDate]); useEffect(() => { const handleSlotEvent = (cloudEvent: FhirCloudEvent) => { const slot: Slot = cloudEvent.data; // const eventType = cloudEvent.type; const slotScheduleId = extractReferenceId(slot.schedule); // A multi-location practitioner's selected date can be owned by a non-representative block, so // refresh when the slot belongs to ANY block that owns the current date (not just selectedSchedule). if ( watchDate && getSchedulesForDate(watchDate).some((s) => s.id === slotScheduleId) ) { // Refresh slots for the current date loadSlots(watchDate); } }; subscribe("com.akashichealthrecords.fhir.slot.created", handleSlotEvent); subscribe("com.akashichealthrecords.fhir.slot.updated", handleSlotEvent); subscribe("com.akashichealthrecords.fhir.slot.deleted", handleSlotEvent); return () => { unsubscribe( "com.akashichealthrecords.fhir.slot.created", handleSlotEvent, ); unsubscribe( "com.akashichealthrecords.fhir.slot.updated", handleSlotEvent, ); unsubscribe( "com.akashichealthrecords.fhir.slot.deleted", handleSlotEvent, ); }; }, [ getSchedulesForDate, extractReferenceId, subscribe, unsubscribe, watchDate, loadSlots, ]); // Sync selectedDate with watchDate useEffect(() => { if (watchDate && !selectedDate) { // Parse the YYYY-MM-DD as a LOCAL date. `new Date("YYYY-MM-DD")` parses as UTC midnight, which in // US timezones lands on the PREVIOUS day - that made the first date pick compute times for the wrong // (non-working) day and show "no open times" until the date was re-picked. const [y, mo, d] = watchDate.split("-").map(Number); setSelectedDate(new Date(y, mo - 1, d)); } else if (!watchDate && selectedDate) { // If watchDate is cleared but selectedDate exists, clear selectedDate setSelectedDate(null); } }, [watchDate, selectedDate]); // Busy [startMin, endMin) ranges (minutes-of-day) for the selected date. Interval-based, NOT start-keyed, // so a longer appointment (e.g. 60 min booked at 9:00) blocks every grid start it overlaps (9:15/9:30/9:45), // not just 9:00. const getBusyRangesForDate = useMemo(() => { if (!watchDate) return [] as { startMin: number; endMin: number }[]; // On the patient's clock and the patient's day, the same as the list they are checked against. The // practitioner's held time anywhere counts as busy beside this practice's own Slots. return heldRangesOnDate([...slots, ...heldAsBusy(heldTimes)], watchDate); }, [slots, heldTimes, watchDate]); // Site labels in the date/time picker ( §3.3.1) are only meaningful when the patient chose "Any // location" AND the practitioner's union of blocks actually spans more than one distinct site - a // specific-location pick or a single-site practitioner would make every label redundant noise. Gate // once here (identity = block.locationRef.display, matching what the labels themselves show) and reuse // it in both availableDates and availableTimeOptions below. const isMultiSiteAnyLocation = useMemo(() => { if (selectedLocationFilter !== ANY_LOCATION) return false; const distinctSites = new Set(); for (const event of availabilityEvents) { const site = event.block.locationRef?.display; if (site) distinctSites.add(site); } return distinctSites.size > 1; }, [selectedLocationFilter, availabilityEvents]); // Review step (Step 3, §3.3.1): reconfirm the resolved site + service for the picked slot - // belt-and-suspenders for the picker labels above, and the only correct source under "Any location" // since block.locationRef is the SITE the slot actually belongs to (wpOrganization is the org, not a // site). Recomputed whenever the picked date/time changes. Undefined (falls through to the org name / // selected-service-filter display below) when no block resolves - the pre-Task-3 single-block path, or // before a date/time is chosen - so single-schedule bookings render exactly as before. const reviewResolvedBlock = useMemo( () => watchDate && watchStartTime ? resolveBlockForSlotRef.current?.(watchDate, watchStartTime) : undefined, [watchDate, watchStartTime], ); // Distinct future dates the practitioner is available (from the schedule's availability windows), for the // Date select. availabilityEvents are already filtered to real availability, so time-off days and // non-working weekdays never appear. const availableDates = useMemo(() => { if (availabilityEvents.length === 0) return []; const todayStart = new Date(); todayStart.setHours(0, 0, 0, 0); const now = new Date(); // Group by date value in a single pass so the site label below can look at EVERY event on a date, not // just the first one encountered for it. const dayByDate = new Map(); const sitesByDate = new Map>(); for (const event of availabilityEvents) { const day = new Date(event.start); day.setHours(0, 0, 0, 0); if (day < todayStart) continue; const value = formatDateOnly(day); // - A DAY WHOSE HOURS ARE OVER IS NOT AN AVAILABLE DATE. // // Observed at ~22:30: this list offered today as its FIRST selectable date, while the staff // picker had already ruled the same day out. The test above is the only one that used to run, and // it asks whether the date is in the PAST - nothing here ever consulted the clock WITHIN today, so // a 9-to-5 day stayed on offer all evening. A patient picked it and every time came back empty. // // THIS IS THE MOST HARMFUL OF THE THREE SURFACES, which is why it is the one that changed // behaviour rather than wording: the patient-facing list was the most permissive, so it is the one // that invites an appointment nobody can honour. // // It calls the SAME rule the staff picker calls, deliberately. Two date pickers deriving // "bookable" separately is the whole of this card, and `reassignFeasibility` already keeps that rule // identical on purpose. if (!dateHasBookableWindow(value, availabilityEvents, { now, durationMinutes: appointmentDurationMinutes })) { continue; } if (!dayByDate.has(value)) dayByDate.set(value, day); const site = event.block.locationRef?.display; if (site) { if (!sitesByDate.has(value)) sitesByDate.set(value, new Set()); sitesByDate.get(value)!.add(site); } } const dates: { value: string; label: string }[] = []; for (const [value, day] of dayByDate) { let label = day.toLocaleDateString(undefined, { weekday: "short", month: "short", day: "numeric", year: "numeric", }); // Only under "Any location" + a genuinely multi-site practitioner: single-site dates get the site // name, dates spanning more than one site defer to the per-time labels (Step 2 below). if (isMultiSiteAnyLocation) { const siteList = Array.from(sitesByDate.get(value) ?? []); if (siteList.length === 1) { label += ` · ${siteList[0]}`; } else if (siteList.length > 1) { label += " · multiple sites"; } } dates.push({ value, label }); } dates.sort((a, b) => a.value.localeCompare(b.value)); return dates; }, [availabilityEvents, isMultiSiteAnyLocation]); // Bookable start times for the selected date, for the Time select: the 30-minute grid filtered to slots // that fit an availability window (duration-aware) and are not already busy - the same rule the old pill // grid used. // Is the patient on a different clock from the practice, and what is their clock called? // // WITHOUT THIS THE CONVERSION IS MORE DANGEROUS THAN THE BUG IT FIXES. Every time on this screen // is now shown in the PATIENT's timezone (option B, Spencer 2026-08-10). A Californian booking an // Iowa practice sees "7:00 AM" - correct, and indistinguishable from the practice's 7:00 AM unless // we say which clock it is on. A silent conversion just moves the misunderstanding. // // Only shown when the clocks actually DIFFER, compared by offset rather than zone name: a notice // that appears for everyone is a notice nobody reads, and America/Detroit vs America/New_York is not // a difference worth interrupting anyone about. // Decided per visit, after the slot zones below are known: see viewerZoneNotice there. const availableTimeOptions = useMemo(() => { if (!selectedDate) return []; // Nothing is offered until the busy times are known: pending or failed is not free. if (busyRead.status !== "ready") return []; const dateStr = formatDateOnly(selectedDate); const to12h = (t: string) => { const [h, m] = t.split(":").map(Number); const period = h >= 12 ? "PM" : "AM"; const displayHours = h % 12 || 12; return `${displayHours}:${m.toString().padStart(2, "0")} ${period}`; }; // Site labels on individual times ( §3.3.1) are only worth showing when THIS date itself spans // more than one site - a single-site date already carries the site on its date label (Step 1 above), so // repeating it per time would be redundant. Determine that once, up front, from the date's own tagged // events (a subset of the overall isMultiSiteAnyLocation gate). let dateSpansMultipleSites = false; if (isMultiSiteAnyLocation) { const sitesForDate = new Set(); for (const event of availabilityEvents) { if (formatDateOnly(new Date(event.start)) !== dateStr) continue; const site = event.block.locationRef?.display; if (site) sitesForDate.add(site); } dateSpansMultipleSites = sitesForDate.size > 1; } /* * THE GRID IS GENERATED FROM THE BLOCKS, NOT FILTERED OUT OF A FIXED LADDER. * * This used to walk `TIME_SLOTS` - a hand-typed 24-hour table of half-hours from 06:00 to 05:30 - * and keep the ones that survived the availability check. Two things followed from that, and both * were measured on journey C1: every practice was offered THIRTY whatever it had chosen (23 slots on * an empty day, all 22 gaps exactly 30 minutes, zero quarter-hours, and identical across two bundles * because nothing in it could vary), and the candidate times were the ladder's rather than the * practice's own hours. * * Ruled 2026-09-06: the increment is set on the practitioner's SCHEDULE BLOCK - 10, 15, 20 or 30 - * appointment length comes from the service, and the practice default is 15. So the candidates come * from each window at ITS OWN block's increment, which is also why this is per-window rather than one * increment for the day: an "Any location" date can span two blocks that made different choices. * * THE FILTERS BELOW ARE UNCHANGED AND STILL DO THE WORK THEY DID. Generating a start time says only * that the block offers it; whether the SERVICE fits before the window closes, and whether the slot * is already taken, are the same two questions asked the same way. Nothing here is duration-aware - * that would quietly make the grid service-specific in a memo that does not depend on the service. */ const options: { value: string; label: string }[] = []; const seen = new Set(); for (const event of availabilityEvents) { if (formatDateOnly(new Date(event.start)) !== dateStr) continue; const block = schedulesList.find((schedule) => schedule.id === event.block?.scheduleId); const increment = getScheduleSlotIncrement(block); // Local-ISO strings by construction (see toLocalIso), so the wall-clock time is the substring - // parsing to a Date and back would reintroduce the timezone question this format exists to settle. const from = String(event.start).slice(11, 16); // Through windowEndOnDay so an all-day window, which closes at the NEXT midnight, is not // reduced to the same 00:00 it opened at and read as an inside-out range. const to = windowEndOnDay( dateStr, String(event.end).slice(0, 10), String(event.end).slice(11, 16), ); for (const slot of slotStartTimes(from, to, increment)) { // Two windows on one day can overlap - the same start time offered by two blocks is one // choice to a patient, not two identical rows. if (seen.has(slot)) continue; if (isTimeSlotBooked(slot, getBusyRangesForDate, appointmentDurationMinutes)) continue; if ( !isTimeSlotAvailable( slot, dateStr, availabilityEvents, appointmentDurationMinutes, ) ) continue; seen.add(slot); let label = to12h(slot); if (dateSpansMultipleSites) { const site = resolveBlockForSlotRef.current?.(dateStr, slot)?.locationRef?.display; if (site) label += ` · ${site}`; } options.push({ value: slot, label }); } } // The ladder was already in order; generated windows are not, because a day's blocks arrive in // whatever order the search returned them. options.sort((left, right) => left.value.localeCompare(right.value)); return options; }, [ selectedDate, busyRead.status, getBusyRangesForDate, availabilityEvents, appointmentDurationMinutes, isMultiSiteAnyLocation, schedulesList, ]); // A picked time is actionable only while the current read is ready and still offers it. A newer read that no // longer offers it retires the pick; a failed read retires the plan reviewed from it. Never during or after the send. const pickStanding = pickedTimeStanding(busyRead.status, watchStartTime, availableTimeOptions.map((option) => option.value)); const pickedTimeIsOffered = pickStanding === "actionable"; // The read and pick as the page stands now, for a Confirm already past its awaits. A new object on every change of // read, standing or pick, so a Confirm holding the old one is retired even when the same day and time come back. const busyEpoch = `${busyKey ?? ""}|${busyAttempt}`; const sendStandingRef = useRef({ epoch: busyEpoch, standing: pickStanding, picked: watchStartTime }); sendStandingRef.current = nextSendStanding(sendStandingRef.current, { epoch: busyEpoch, standing: pickStanding, picked: watchStartTime }); // The one way a pick is let go: the time, its end and the flow's time answer, keeping the date it was on. const clearPickedTime = useCallback(() => { setValue("startTime", "", { shouldValidate: true }); setValue("endTime", "", { shouldValidate: true }); setFlowAnswers((prev) => { const next = { ...prev }; delete next.time; return next; }); }, [setValue]); useEffect(() => { if (submitting || reviewStep === 3 || (pickStanding !== "retire-pick" && pickStanding !== "retire-plan")) return; if (pickStanding === "retire-pick") clearPickedTime(); setFormDataForSubmission(null); setReviewStep(1); }, [pickStanding, submitting, reviewStep, clearPickedTime]); const livePractitioners = useMemo( () => practitionersList.map((item) => item.practitioner), [practitionersList], ); const getScheduleRole = useCallback((schedule: Schedule): PractitionerRole | undefined => { const roleRef = schedule.actor?.find((a) => extractReferenceType(a) === "PractitionerRole"); if (!roleRef) return undefined; const roleIdRaw = extractReferenceId(roleRef); const roleId = Array.isArray(roleIdRaw) ? roleIdRaw[0] : roleIdRaw; if (!roleId) return undefined; for (const item of practitionersList) { const role = item.roles.find((r) => r.id === roleId); if (role) return role; } return undefined; }, [extractReferenceId, extractReferenceType, practitionersList]); const seatStanding = useSeatStanding(); const bookableSchedules = useMemo( () => schedulesList.filter((s) => { const flags = getScheduleBookingFlags(s); if (!(Boolean(s.id) && flags.allowBooking && flags.allowPatientSelfBooking)) return false; // Exclude schedules owned by a deactivated practitioner (their PractitionerRole.active === false). // The schedule itself is preserved and reappears automatically once they're reactivated. const role = getScheduleRole(s); if (role && role.active === false) return false; // AND EXCLUDE ANYBODY WITHOUT A CLINICIAN SEAT - the same gate the front desk now applies, from // the same helper. The per-service self-booking flag decides which SERVICES a patient may book // and never which PEOPLE can perform them, so this hole was patient-facing too. return canBeBookedAsClinician(s, role, seatStanding); }), [schedulesList, getScheduleRole, seatStanding], ); // The self-bookable services offered by a schedule's practitioner, read from the practitioner's // PractitionerRole.healthcareService (the Services Offered tab - where the org assigns services AND the // per-practitioner self-bookable override live). Self-bookable resolves from the ref-level override first // (always readable on the role the patient already has), falling back to the HealthcareService org default // ONLY when the catalog is loaded, else default-on - so the picker does NOT hard-depend on the patient being // able to fetch the HealthcareService catalog. Name comes from the catalog when loaded, else the ref display. // Delegates to the pure module (utils/booking/scheduleSelfBookableServices), which the staff-side // bookability context reads too - one implementation, so a staff surface can never resolve self-bookable // differently from what this screen offers a patient. const getScheduleSelfBookableServices = useCallback( (schedule: Schedule): { id: string; display: string }[] => servicesUnverified ? [] : resolveScheduleSelfBookableServices(schedule, getScheduleRole(schedule), serviceById, { catalogueComplete: true }), [getScheduleRole, serviceById, servicesUnverified], ); // Location a schedule is for: the Schedule's own Location actor (new model), falling back to the role's // location for schedules saved before location moved onto the Schedule. // - reads EVERY Location actor, not just the first. This used to be // `getScheduleLocationReference(schedule)` (a `.find`), wrapped in an array. A schedule can now carry // two locations - the physical site AND the org's virtual one when the block offers video - so taking // only the first meant a block that had explicitly opted into video reported just its physical site. // The telehealth gate then raised NO_VIRTUAL_LOCATION_ON_BLOCK against a block that HAD opted in, and // patient video booking was impossible. The staff context never had this bug: it already filtered all // Location actors, so the two halves of one contract disagreed. // // The role fallback is unchanged and still matters: blocks saved before location moved onto the // Schedule carry it on the PractitionerRole instead. const getScheduleLocationRefs = useCallback((schedule: Schedule, role?: PractitionerRole) => { const scheduleLocationRefs = getScheduleLocationReferences(schedule); return scheduleLocationRefs.length > 0 ? scheduleLocationRefs : (role?.location ?? []); }, []); // A service satisfies the new-patient gate by its SNOMED type, whatever the practice named it. const isConsultationService = useCallback( (serviceId: string): boolean => isNewPatientConsultationType(serviceById.get(serviceId)?.type), [serviceById], ); // Shared filter over bookableSchedules - service/location/practitioner are each applied only when // provided, so every derived option list can drop its OWN axis and narrow by the other two. This is // what makes the three selects bidirectionally cross-filtered instead of a fixed // service -> location -> practitioner chain: picking any one of them narrows what the other two offer, // in any order. `location` treats both '' (nothing chosen) and ANY_LOCATION (explicitly "no // preference") as "no location constraint" - the sentinel only matters to the UI, never here. // // Delegates to the pure matchSchedules module (utils/booking/matchSchedules), which applies the // new-patient-exam gate INSIDE the match - not just to the practitioner list, as before, but to every // derived option list (service, location, practitioner, and the card-040 ranker candidates) - so a // location or service that leads to zero bookable practitioners is never offered in the first place. const scheduleMatchContext = useMemo(() => ({ anyLocation: ANY_LOCATION, examGate: { restrictToNewPatientExam, isConsultationService, }, // - so the patient's option lists apply the SAME explicit-opt-in rule the resolver does: // the "all locations" wildcard must not cover the virtual location, or every org-wide block would // offer video without anyone opting in. Undefined when the org has no virtual Location, which makes // the whole thing inert. virtualLocationId: onlyVirtual(locationsList)[0]?.id, accessors: { selfBookableServices: getScheduleSelfBookableServices, roleId: (s) => getScheduleRole(s)?.id, locationIds: (s) => { const role = getScheduleRole(s); if (!role) return []; return getScheduleLocationRefs(s, role) .map((ref) => ref.reference?.split("/").pop()) .filter((id): id is string => Boolean(id)); }, // Its Practitioner actor, else its role's practitioner: a schedule naming only the role is still that clinician's. practitionerId: (s) => schedulePractitionerId(s, getScheduleRole(s)), allowsNewPatientGeneralExam: (s) => Boolean(getScheduleBookingFlags(s).allowNewPatientGeneralExam), }, }), [ restrictToNewPatientExam, isConsultationService, getScheduleSelfBookableServices, getScheduleRole, getScheduleLocationRefs, extractReferenceType, getScheduleBookingFlags, locationsList, ]); const schedulesMatching = useCallback( (criteria: { service?: string; location?: string; practitionerId?: string }): Schedule[] => matchSchedules(bookableSchedules, criteria, scheduleMatchContext), [bookableSchedules, scheduleMatchContext], ); // The practitioner currently selected, if any - read from the FHIR Practitioner resource set alongside // selectedSchedule in handleScheduleChange (Task 3's territory; this only READS that selection so the // service/location option lists narrow to it once a practitioner is picked before a service/location). const currentPractitionerId = practitioner?.id; // Real, bookable sites - narrowed by the OTHER two active selections (service/practitioner). Kept // separate from locationFilterOptions (below) so the "how many real choices are there" branching in the // render doesn't count the synthetic "Any location" entry. const sitesOfferingService = useCallback((serviceId: string | undefined) => { const matches = schedulesMatching({ service: serviceId, practitionerId: currentPractitionerId }); const seen = new Set(); const options: { id: string; display: string }[] = []; let hasAllLocationsRole = false; for (const schedule of matches) { const role = getScheduleRole(schedule); if (!role) continue; const roleLocations = getScheduleLocationRefs(schedule, role); // No explicit site refs anywhere (neither the schedule nor the role), so this practitioner is // bookable at the org's full location list below - but ONLY if the role actually says it covers // every site.: "nobody set this" and "covers everywhere" used to be the same value, and // offering a practitioner at every site because their record is incomplete is the booking-surface // form of that fail-open. `isOrgWideRole` still answers true for an empty array today, so this is // behaviour-identical until the backfill lands. if (roleLocations.length === 0) { if (isOrgWideRole(role)) hasAllLocationsRole = true; continue; } for (const locRef of roleLocations) { const locId = locRef.reference?.split("/").pop(); if (!locId || seen.has(locId)) continue; seen.add(locId); options.push({ id: locId, display: locRef.display || locId }); } } if (hasAllLocationsRole) { for (const loc of onlySites(locationsList)) { if (!loc.id || seen.has(loc.id)) continue; seen.add(loc.id); options.push({ id: loc.id, display: loc.name || loc.id }); } } // - a practice that does not offer video should not be offered "Video visit" and then // refused. Shared with the receptionist picker so the two cannot drift. // // - and neither should a service that cannot be DELIVERED by video. Resolved per candidate // role rather than from the org default alone: lets a practitioner override eligibility for // a service their practice defaults off, so keying on the default would hide video from the one // practitioner who explicitly enabled it. Same rule, same helper, same quantifier as the // receptionist picker - this is the drift the shared module exists to prevent. const selectedService = serviceId ? serviceById.get(serviceId) : undefined; const serviceAllowsVideo = selectedService !== undefined && matches.some((schedule) => resolveServiceTelehealthEligible(selectedService, getScheduleRole(schedule)), ); // Offered video reads as "Video visit", first, as the deck draws it - not as the virtual Location's own // name among the buildings, which is how a patient offered video concluded there was none. const virtualLocationId = onlyVirtual(locationsList)[0]?.id; const offerable = offerableLocationOptions( options, virtualLocationId, resolveOrgAppointmentPolicy(bookingOrg).telehealthEnabled, serviceAllowsVideo, ); return videoVisitFirst(offerable, virtualLocationId); }, [schedulesMatching, getScheduleRole, getScheduleLocationRefs, currentPractitionerId, locationsList, bookingOrg, serviceById]); // "What type of care?" axis = the distinct self-bookable services offered across schedules matching the // OTHER two active selections (design §4) - narrowed by location/practitioner when either is already // chosen, so this list stays consistent with whatever the patient picked, in any order. const serviceFilterOptions = useMemo(() => { const options = offeredServices( schedulesMatching({ location: selectedLocationFilter, practitionerId: currentPractitionerId }), scheduleMatchContext, ); /** * DOES THIS SERVICE REACH ANYWHERE IT CAN BE DELIVERED? * * A service whose only schedule block is the video block, and which cannot be done by video, narrows * to no site at all. The stripping is correct - a service that cannot be delivered by video must not * offer the virtual location - but until now the patient met the result of that correct rule three * steps later, as "Those options just changed. Please reselect.", with nothing on screen to reselect * and nothing having changed. Measured on the mount: two of this practice's six services did that. * * Answered HERE, where the patient chooses, and answered with the same function the location step * uses so the two steps cannot disagree. * * NOT FILTERED OUT. A patient who came to book an oral examination and cannot find it on the list * learns nothing and telephones to ask why - a screen that speaks by showing nothing cannot say which * of the reasons for nothing applies. It stays on the list, unpickable, saying why. */ return options.map((option) => ({ ...option, bookable: sitesOfferingService(option.id).length > 0, })); }, [schedulesMatching, selectedLocationFilter, currentPractitionerId, scheduleMatchContext, sitesOfferingService]); // One consult-typed service left for a patient not yet seen: the flow chooses it, under the practice's // own name, and books it as a real service so serviceType comes from that service's type. const pinnedConsultation = restrictToNewPatientExam && serviceFilterOptions.length === 1 ? serviceFilterOptions[0] : undefined; const pinnedServiceLabel = pinnedConsultation?.display; useEffect(() => { if (pinnedConsultation && selectedServiceFilter !== pinnedConsultation.id) { setSelectedServiceFilter(pinnedConsultation.id); } }, [pinnedConsultation, selectedServiceFilter]); /** * WHICH EMPTINESS THIS IS, when the service step has nothing on it. * * The screen used to answer all three with one sentence - "hasn't added any bookable services yet" - * and it was measured false at a practice whose catalogue was full: the first-visit gate had pinned * the choice to a new patient consultation that no open block offered. A patient was sent to * telephone a practice that had done nothing wrong, on the screen that exists to save that call. * * Read in the order the causes stack, so the patient is told the FIRST thing that is true rather than * the last: no block open to self-booking at all beats a block that lists no service, and the gate is * named ahead of both because when it binds it is the reason the other two look empty. * * The wording is in `nothingToOffer`, with the copy test that pins it out of office vocabulary. */ const nothingToOffer = useMemo(() => { if (restrictToNewPatientExam) return 'FIRST_VISIT_NOT_OFFERED'; if (bookableSchedules.length === 0) return 'NO_SELF_BOOKABLE_SCHEDULE'; return 'NO_SELF_BOOKABLE_SERVICE'; }, [restrictToNewPatientExam, bookableSchedules]); /** * THE SITES FOR THE SERVICE THE PATIENT HAS ACTUALLY CHOSEN. * * Asked of the same function the service list asks, and that is the whole point of the extraction: a * service shown as bookable on one step and dead on the next is two answers to one question. This used * to be the only caller, computing the answer inline for the selected service alone, which is why * nothing upstream could tell a patient a service had nowhere to be delivered. */ const locationSiteOptions = useMemo( () => sitesOfferingService(selectedServiceFilter), [sitesOfferingService, selectedServiceFilter], ); // The Location select's actual item list: an explicit "Any location" choice first (an active pick that // means "no location preference", replacing the old passive "All Locations" default), then the real // sites. const locationFilterOptions = useMemo( () => [{ id: ANY_LOCATION, display: "Any location" }, ...locationSiteOptions], [locationSiteOptions], ); /** * One block's bookable windows as local-ISO strings, or undefined when it names no role. The one reading * both the practitioner list and the chosen practitioner's availability use, so they cannot disagree. */ const blockWindowsFor = useCallback((blockSchedule: Schedule): AvailabilityWindow[] | undefined => { const blockRole = getScheduleRole(blockSchedule); if (!blockRole?.id) return undefined; // THE CLOCK THIS BLOCK'S HOURS ARE WRITTEN IN - its own PHYSICAL site's. `onlySites`, never `[0]`: a // video-enabled block also carries the org's virtual Location, which has no clock. Undefined for an // "all locations" block with no site actor; ScheduleService then falls back to the organization. const blockSite = scheduleSiteFor(blockSchedule, blockRole, locationsList); // availabilityWindowsFor is the SHARED filter (utils/booking/scheduleYieldsTimes): it drops 'not-available' // (time-off) background events, so a staff "no open times" explanation agrees with what is offered here. return availabilityWindowsFor( getScheduleAvailabilityEvents( blockSchedule, transformFromFhirPractitionerRole(blockRole), blockSite, bookingOrg, ) as RawScheduleAvailabilityEvent[], ).map((event) => ({ start: toLocalIso(event.start), end: toLocalIso(event.end), })); }, [getScheduleRole, locationsList, getScheduleAvailabilityEvents, transformFromFhirPractitionerRole, bookingOrg]); // For the practitioner dropdown, show each practitioner once even when they have multiple schedules // (a practitioner can now have one schedule per location). First schedule per practitioner is the // representative; a specific site is chosen via the Location filter above. Renamed from // dedupedPractitionerSchedules (Task 2, ); the multi-block availability union is Task 3. // // Derived from an explicit `schedulesMatching({ service, location })` with the PRACTITIONER axis dropped // (Task 3, resolving a Task-2 banked finding): its correctness no longer depends on the "this Select // only renders while no practitioner is selected" invariant. Once a practitioner IS picked, the union in // handleScheduleChange - not this list - drives availability, so pinning the option list to // service+location (not the selected practitioner) is exactly right. // A clinician is listed only when one of their matching blocks opens a window from today, the test the // "not currently available" notice applies once one is chosen; listing them first was a dead end. const practitionerOptions = useMemo(() => { const todayStart = new Date(); todayStart.setHours(0, 0, 0, 0); const matching = schedulesMatching({ service: selectedServiceFilter, location: selectedLocationFilter, }); return offerablePractitionerSchedules( matching, (schedule) => schedulePractitionerId(schedule, getScheduleRole(schedule)), (practitionerId) => schedulesMatching({ service: selectedServiceFilter, location: selectedLocationFilter, practitionerId, }).some((blockSchedule) => opensFrom(blockWindowsFor(blockSchedule) ?? [], todayStart)), ); }, [ schedulesMatching, selectedServiceFilter, selectedLocationFilter, extractReferenceType, blockWindowsFor, getScheduleRole, ]); /** * HOW MANY OTHER PRACTITIONERS OFFERING THIS VISIT THE PATIENT COULD STILL BOOK WITH. * * The refusal used to offer a telephone number and nothing else, in both of the two states it could * not tell apart: a colleague is free and the patient is one click away, or nobody is and the phone * really is the answer. It gave the second answer in both cases, which reads as a dead end in the * case that is not one. * * COUNTED FROM THE LIST THE PICKER ITSELF RENDERS, deliberately, so the sentence cannot promise a * choice the screen does not offer. Same guard, same patient appointments, so a colleague counted * here is a colleague who would actually be accepted. */ const unblockedOtherPractitionerCount = useMemo(() => { const seen = new Set(); for (const schedule of practitionerOptions) { const candidateId = schedulePractitionerId(schedule, getScheduleRole(schedule)); if (!candidateId || candidateId === practitioner?.id || seen.has(candidateId)) continue; if (getActiveFutureAppointmentForPractitioner(candidateId, [], undefined, chosenServiceCap.limit, chosenServiceCap.codingKey)) continue; seen.add(candidateId); } return seen.size; }, [ practitionerOptions, extractReferenceType, getScheduleRole, practitioner?.id, getActiveFutureAppointmentForPractitioner, ]); // The guided flow's step machine (bookingFlowSteps.ts) - the single source of truth for which of the // seven questions is on screen, which are answered/locked-with-a-reason, and which haven't been reached // yet. Tasks 7-9 render the later steps off this same memo; the reason step is wired here. const flowState = useMemo( () => resolveFlowState({ answers: flowAnswers, options: { service: serviceFilterOptions, location: locationSiteOptions, practitioner: practitionerOptions.map((s) => ({ id: s.id as string, display: schedulePractitionerName(s, livePractitioners, "Unknown Practitioner", getScheduleRole(s)), })), }, servicePinnedTo: pinnedConsultation ? { id: pinnedConsultation.id, display: pinnedConsultation.display } : undefined, }), [ flowAnswers, serviceFilterOptions, locationSiteOptions, practitionerOptions, livePractitioners, requireNewPatientExamGate, derivedPatientType, pinnedConsultation, extractReferenceType, getScheduleRole, ], ); // The only service left is committed as the filter, so the location and practitioner steps narrow for it. const autoServiceRef = useRef(); useEffect(() => { const next = lockedServiceFilter({ service: flowState.steps.service, options: serviceFilterOptions, selected: selectedServiceFilter, committed: flowAnswers.service, autoSet: autoServiceRef.current, }); if (next === undefined) return; autoServiceRef.current = next || undefined; setSelectedServiceFilter(next); }, [flowState.steps.service, serviceFilterOptions, selectedServiceFilter, flowAnswers.service]); const clearSelectedPractitioner = useCallback(() => { selfBookingGuardRequestRef.current += 1; setCheckingSelfBookingGuard(false); setSelectedPractitioner(null); setSelectedSchedule(null); setPractitionerRole(null); setPractitioner(undefined); setFormDataForSubmission(null); setReviewStep(1); setSelectedDate(null); setAvailabilityEvents([]); resolveBlockForSlotRef.current = null; setValue("practitionerId", "", { shouldValidate: true }); setValue("date", "", { shouldValidate: true }); setValue("startTime", "", { shouldValidate: true }); setValue("endTime", "", { shouldValidate: true }); }, [setValue]); // Re-opens a step and clears every answer after it (clearFrom) - the edit affordance every // AnsweredLine's onEdit calls. Two things must cascade identically to clearFrom's STEP_ORDER slice, or // a re-opened step silently disagrees with what's on screen: // 1. showAllServices - reset whenever `service` itself, or anything AT OR BEFORE it, is reopened // (Task 7 review finding), otherwise the required-assist escape hatch never comes back. // 2. Every step's CONTROLLING value (the Select's `value` prop / the RHF field it reads). Radix's // controlled Select only fires `onValueChange` when the new pick differs from the current `value` // prop (@radix-ui/react-use-controllable-state). `clearFrom` clears the ANSWER, but if the // Select/field still shows the old pick, re-clicking that SAME option is a silent no-op - the step // never reflects as answered again, and because resolveFlowState hides everything after an // unanswered step (bookingFlowSteps.ts), every step downstream vanishes with it until the patient // happens to pick something different first. (Task 9 review finding: this reproduced the exact // "state active, UI disagrees" bug the whole redesign exists to remove, one level down - on Service // and Location, and, less severely since submission reads `watch()` directly, on Date and Time.) // `service`/`location` reset their own local filter state directly. `practitioner`, and everything // that cascades into it (date, time), route through `clearSelectedPractitioner` - the SAME reset // `handleScheduleChange` already uses for a live practitioner swap, not a second competing path; // its own onEdit no longer calls it separately (see that call site). `reason` needs no // controlling-value reset: it // commits on blur from the RHF field itself, which already re-fires on any focus-out regardless of // whether the text changed. const handleEditStep = useCallback((step: BookingStepId) => { setFlowAnswers((prev) => clearFrom(prev, step)); const idx = STEP_ORDER.indexOf(step); if (idx <= STEP_ORDER.indexOf('reason')) { // "Find my service" results are keyed on the reason TEXT they were computed from, not on the step // machine's cursor position - nothing else expires them (runSuggest is the only writer). Reopening // Reason (or anything before it) invalidates any prior text, so both must clear here or the patient // sees the PREVIOUS reason's suggestion cards (or its "no match" notice) rendered against a reason // they've since changed or haven't re-typed. Keyed at 'reason', not 'service': reopening step 2 with // the SAME reason still intentionally shows its own results. setSuggestions([]); setSuggestTried(false); } if (idx <= STEP_ORDER.indexOf('service')) { setShowAllServices(false); setSelectedServiceFilter(''); } if (idx <= STEP_ORDER.indexOf('location')) { setSelectedLocationFilter(''); } if (idx <= STEP_ORDER.indexOf('practitioner')) { // Superset reset - also clears date/startTime/endTime/selectedDate, so the two branches below only // run when the cascade does NOT reach back this far (i.e. editing 'date' or 'time' alone). clearSelectedPractitioner(); } else if (step === 'date') { // clearFrom clears BOTH 'date' and 'time' from flowAnswers here (time is after date in // STEP_ORDER), so both controlling fields must reset together or 'time' would re-render answered // from a flowAnswers.time that no longer exists. setValue('date', '', { shouldValidate: true }); setValue('startTime', '', { shouldValidate: true }); setValue('endTime', '', { shouldValidate: true }); setSelectedDate(null); } else { // step === 'time': clearFrom clears ONLY 'time' here - flowAnswers.date is untouched, so the date // field must NOT be reset, or flowAnswers.date (still "answered") would desync from a blanked // `date` RHF field, exactly the bug this whole function exists to prevent. setValue('startTime', '', { shouldValidate: true }); setValue('endTime', '', { shouldValidate: true }); } }, [clearSelectedPractitioner, setValue]); // "Any location" is a real choice meaning "no preference" - but once the chosen date+time resolve to a // concrete block, the visit HAS a site: that block's locationRef is exactly what the booking submits as // its Location participant (see the submit path's `block?.locationRef`). Leaving the summary saying // "Any location" for a visit that is now at a named clinic tells the patient something untrue. // DERIVED FOR DISPLAY ONLY - deliberately NOT written into flowAnswers.location. Writing it would // narrow the option lists the patient chose to leave open, make a derived value indistinguishable from a // chosen one, and add a fourth place that has to stay mirrored - the exact class behind three separate // defects in this build. Deriving also means it reverts to "Any location" for free when the time is // edited, with no reset logic to keep in step. const anyLocationResolvedSite = selectedLocationFilter === ANY_LOCATION ? reviewResolvedBlock?.locationRef?.display : undefined; // Display-only, like the derivation above, and read from the Location the patient actually chose. // `isVirtualLocation` reads the resource's own physicalType coding, which is what makes this // independent of whatever the practice named the site. const answeredLocationIsRemote = (() => { const chosenId = flowAnswers.location; if (!chosenId) return false; const chosen = locationsList.find((location) => location.id === chosenId); return chosen ? isVirtualLocation(chosen) : false; })(); // Every answered step, in STEP_ORDER, rendered as ONE responsive grid above the live question. Each // answered line used to be emitted inline at its own step's position, which made the summary a single // stacked column interleaved with the current question and left Practitioner in a half-width grid cell // while the others were full-width. Driving it from STEP_ORDER keeps the order right by construction. const answeredSteps = STEP_ORDER.flatMap((id) => { const step = flowState.steps[id]; if (!isAnswered(step)) return []; const common = { key: id, onEdit: step.editable ? () => handleEditStep(id) : undefined, lockReason: step.editable ? undefined : step.reason, }; switch (id) { case "reason": return [{ ...common, label: "Reason", value: step.value }]; case "service": return [{ ...common, label: "Care type", value: step.value }]; case "location": return [{ ...common, label: "Location", value: anyLocationResolvedSite ?? step.value, // THE CONFIRMING STEP IS THE ONE THAT MATTERS. The patient was told the visit is remote in // the picker and told again once it was booked, and not at the moment they agreed to it - // the last screen before a commitment is where somebody checks what they are agreeing to. // // Asked of the LOCATION rather than of its name, through the same helper the dialog and the // intent check already use, so a practice that calls its virtual location "Room 3 Video" or // its own brand is still identified. A second definition of what counts as virtual is how // two screens come to disagree about the same visit. subtitle: answeredLocationIsRemote ? "No address - you join from here" : anyLocationResolvedSite ? "set from the time you selected" : undefined, }]; case "practitioner": return [{ ...common, label: asksAsRequest ? "Preferred practitioner" : "Practitioner", value: step.value, subtitle: selectedSchedule?.specialty?.[0]?.text || selectedSchedule?.specialty?.[0]?.coding?.[0]?.display || undefined, }]; case "date": return [{ ...common, label: "Date", value: step.value }]; case "time": // The length the booking writes as the appointment's minutesDuration, as the deck's review row shows it. return [{ ...common, label: "Time", value: withAppointmentLength(step.value, { minutesDuration: appointmentDurationMinutes }), }]; default: return []; } }); // Free-order cross-filtering: picking a service no longer force-clears an already-made location choice // (Task 2, ) - the Location select's own items are always narrowed by the current service, so // anything the patient could have picked there stays valid. Only the already-selected PRACTITIONER can // go stale (they were locked in before this change), so that's the one thing reconciled here. // // Also the single funnel for BOTH ways a patient can pick a service (a suggestion tap or the manual // Select), so it is the one place that commits `flowAnswers.service` (Task 7) - the guided flow's step // machine only advances step 2 once this lands, never on option count. const handleServiceFilterChange = useCallback((serviceId: string) => { setSelectedServiceFilter(serviceId); if (serviceId) { setFlowAnswers((prev) => ({ ...prev, service: serviceId })); } if (serviceId && selectedSchedule && !getScheduleSelfBookableServices(selectedSchedule).some((svc) => svc.id === serviceId)) { clearSelectedPractitioner(); } }, [clearSelectedPractitioner, getScheduleSelfBookableServices, selectedSchedule]); // "Find my service" - suggest-not-autobook: this only populates the suggestion cards below; a // tap on a suggestion routes through handleServiceFilterChange (same as the manual Select), so // the downstream practitioner/location/time funnel is completely untouched by this feature. // matchService already fails open to [] on any error, so no separate error branch is needed here. const runSuggest = useCallback(async () => { const reason = watch("reasonForVisit")?.trim(); if (!reason) return; setSuggestLoading(true); setSuggestTried(true); const api = await matchService(reason, clarifyAnswer || undefined, orgOverride); setSuggestions( mapSuggestionsToBookableServices(api.suggestions, serviceFilterOptions, { minConfidence: SUGGESTION_CONFIDENCE_FLOOR, maxSuggestions: 4, }), ); // Only offered when more than five services cleared the floor - a list too long to choose from. // Advisory throughout: undefined is by far the commonest answer and every failure produces it too. setClarifyQuestion(api.question); setMatchedCount(api.matchedCount); setSuggestLoading(false); }, [watch, matchService, serviceFilterOptions, clarifyAnswer]); // Answering the question re-runs the same assist with the answer attached. Deliberately an effect on // the answer rather than work inside the click: runSuggest already closes over clarifyAnswer, so // calling it from the handler would send the PREVIOUS answer - the state has not been applied yet at // that point. The guard keeps it from firing on first render, when there is no answer to send. useEffect(() => { if (!clarifyAnswer) return; void runSuggest(); // runSuggest is intentionally the only other dependency; it is a useCallback keyed on the answer. }, [clarifyAnswer, runSuggest]); // Mirrors handleServiceFilterChange: only reconciles an already-selected practitioner. Choosing the // explicit ANY_LOCATION option only widens the constraint, so it can never invalidate the current // practitioner - only a concrete site can. Routed through schedulesMatching (rather than the old // ad-hoc roleLocations.some(...) check) so an "All locations" practitioner (empty role.location) is // correctly treated as matching any site, instead of always being cleared. const handleLocationFilterChange = useCallback((locationId: string) => { setSelectedLocationFilter(locationId); if (locationId) { // "Any location" is a real, deliberate answer (it means "no site preference", not "unset") but its // sentinel id has no entry in locationSiteOptions - options.location, by design (see that memo's own // comment above), so the single-remaining-site auto-lock never counts the synthetic entry. Store the // display text directly for that one case so resolveFlowState's raw-string fallback // (`match?.display ?? String(answer)`, bookingFlowSteps.ts) renders "Any location" instead of the raw // "__all__" sentinel. setFlowAnswers((prev) => ({ ...prev, location: locationId === ANY_LOCATION ? "Any location" : locationId, })); } if ( locationId && locationId !== ANY_LOCATION && selectedSchedule && !schedulesMatching({ location: locationId }).some((schedule) => schedule.id === selectedSchedule.id) ) { clearSelectedPractitioner(); } }, [clearSelectedPractitioner, schedulesMatching, selectedSchedule]); // Pre-answer the Location question when the reason names one of the sites that offer the chosen // service. Routed through handleLocationFilterChange, so a pre-answer behaves exactly like picking the // site from the dropdown - the flow answer, the practitioner reconciliation and the availability // narrowing all follow the one path. It runs only while nothing is selected, so it can put an answer // in front of the patient but can never overrule one, and locationSiteOptions is already narrowed to // the sites this service is offered at, so it can never name a site the manual picker would refuse. useEffect(() => { if (selectedLocationFilter) return; const named = reasonNamesALocation(locationSiteOptions, watchReasonForVisit ?? ""); if (named) handleLocationFilterChange(named); }, [ watchReasonForVisit, selectedLocationFilter, locationSiteOptions, handleLocationFilterChange, ]); // ORG-WIDE candidate set for the practitioner ranker (, Step 1): one BookablePractitioner per // distinct practitioner-role that offers the chosen service, across EVERY location. Service-filtered // ONLY on the LOCATION axis - selectedLocationFilter deliberately does NOT prune here (location frames // the manual list but must never narrow the specialty ranker); it is passed only as `preferLocationId` so // a role that serves the service at several sites displays the selected one (the same tie-break // handleScheduleChange uses), which keeps the mapper's crossLocation flag accurate. // It DOES apply the new-patient exam gate (now inside schedulesMatching -> matchSchedules) - the same // gate the manual `practitionerOptions` and handleScheduleChange's availability union apply - so a // self-attested new patient can never be recommended a practitioner the manual picker hides from them // (the exam gate is a no-op unless the org requires a new-patient exam first). Empty until a service is // chosen. const bookablePractitioners = useMemo(() => { if (!selectedServiceFilter) return []; const locationNameById = new Map(); for (const loc of locationsList) { if (loc.id) locationNameById.set(loc.id, loc.name || loc.id); } const matchingForRank = schedulesMatching({ service: selectedServiceFilter, }); const candidates: BookablePractitionerCandidate[] = matchingForRank.map((schedule) => { const role = getScheduleRole(schedule); return { practitionerRoleId: role?.id, // Same source the manual practitioner