import { useState, useCallback, createContext, useContext, useEffect, useRef, ReactNode } from 'react'; import { Bundle, Appointment, Patient, Slot, AppointmentParticipant, CodeableConcept, Reference } from 'fhir/r4'; import { environment } from '../environments/environment.ts'; import { useFhirServiceContext } from './FhirService.tsx'; import { useHttpService } from './HttpService.tsx'; import { fetchAllBundlePages } from '../utils/fetchAllBundlePages'; import { fetchAllPagesAsBundle } from '../utils/fetchAllPagesAsBundle'; import { searchPageSize } from '../utils/pageSizeOverride'; import { useCacheOwner } from '../utils/cacheOwner'; import { holdWrite, markSearchStarted, overlayHeld, overlayHeldBundle, sameViewerSince, startSearch, startWrite, writtenSince } from '../utils/writeThroughHold'; import { appointmentInQuery } from '../utils/appointmentHoldScope'; import { usePatientServiceContext } from './PatientService.tsx'; import { useFollowAppointmentChanges } from './useFollowAppointmentChanges'; import { ifMatch } from '../utils/recordChanged'; import { knownVisitRecordsHeader, visitScopeOf } from '../utils/knownVisitRecords'; import { APPOINTMENT_FOLLOW_UP_HEADER, asAppointmentChanged, parseAppointmentFollowUps, visitEncounterOutcomeOf, withVisitEncounterOutcome } from '../utils/appointmentVersion'; import type { SaveTransport } from '../utils/saveOperation'; import { BOOKING_OPERATION_HEADER, BookingUnconfirmedError, isBookingUnconfirmedAnswer, type BookingCheck } from '../utils/booking/bookingOperation'; import type { MyVisitInvitation, VisitInviteeProjection, VisitInvitationRequest, VisitInvitationView, VisitInvitationWriteResult, } from '../types/visitInvitation.ts'; const API_URL = environment.fhirBaseApi; // The booking operation check is served beside the request-answer settle route, where its stack has room. const BOOKING_CHECK_API_URL = environment.orgAuthorizationApi; // Coming back to the page reads the list again only when it is at least this old, unless notices may have been missed. export const RETURN_REREAD_FLOOR_MS = 30 * 1000; export interface AppointmentUpdateOptions { /** The version of the Appointment this edit was made against (see appointmentVersion). */ basedOn: string; /** The visit's own practice, sent for this request where no practice is selected. */ organizationId?: string; /** Told what the server saved but could not finish (X-Appointment-Follow-Up); every caller says so. */ onFollowUp: (followUps: string[]) => void; /** The save this write belongs to: admitted before it is sent, cancelled when that save is retired. */ transport?: SaveTransport; } const PATIENT_SELF_BOOKING_BLOCKING_STATUSES = new Set([ 'proposed', 'pending', 'booked', 'arrived', 'checked-in', 'waitlist' ]); /** * `system|code` for a service's first coding, or undefined. The CODE, never the label: an appointment * records only the coding, and a practice may rename a catalogue entry after a booking was made. */ const serviceCodingKeyOf = (concept?: CodeableConcept): string | undefined => { const coding = concept?.coding?.[0]; return coding?.code ? `${coding.system ?? ''}|${coding.code}` : undefined; }; export const findActiveFutureAppointmentForPractitioner = ( appointments: Appointment[], practitionerId: string, practitionerRoleIds: string[] = [], now: Date = new Date(), /** * THE PRACTICE'S CAP FOR THE SERVICE BEING BOOKED, and undefined means UNCAPPED. * * Ruled 2026-09-06: no cap on a patient's future appointments by default; an optional practice * setting per service may limit them. This function refused on the FIRST appointment it found, * which capped every service at one whether the practice had asked for that or not - so a patient * holding one appointment with each practitioner who offers a service could not book it at all and * was told to telephone. * * A MIRROR, NEVER A GATE. The server counts the same thing before it writes; this exists so the * picker does not offer a booking the server will refuse. When the two disagree the server wins, * and the honest failure here is offering a choice that is then declined - never the reverse. */ futureAppointmentLimit?: number, /** `system|code` of the service being booked. Without it a per-service cap has nothing to match. */ serviceCodingKey?: string ): Appointment | undefined => { // NO CAP CONFIGURED, NO REFUSAL. The default is the whole of the ruling. if (futureAppointmentLimit === undefined || !serviceCodingKey) { return undefined; } const practitionerReferences = new Set([ `Practitioner/${practitionerId}`, ...practitionerRoleIds.map(roleId => `PractitionerRole/${roleId}`) ]); const nowTimestamp = now.getTime(); const held = appointments .filter(appointment => PATIENT_SELF_BOOKING_BLOCKING_STATUSES.has(appointment.status)) .filter(appointment => { const startTimestamp = appointment.start ? Date.parse(appointment.start) : Number.NaN; return Number.isFinite(startTimestamp) && startTimestamp >= nowTimestamp; }) .filter(appointment => appointment.participant?.some( participant => participant.actor?.reference && practitionerReferences.has(participant.actor.reference) )) // A per-service cap counts only this service. An appointment for a DIFFERENT service with the // same practitioner is not what the practice limited. .filter(appointment => serviceCodingKeyOf(appointment.serviceType?.[0]) === serviceCodingKey) .sort((left, right) => Date.parse(left.start || '') - Date.parse(right.start || '')); // `>=` because a limit of one is reached by the first. The EARLIEST is returned, because that is // the visit the refusal names and the one the patient will recognise. return held.length >= futureAppointmentLimit ? held[0] : undefined; }; export const useAppointmentService = () => { const { get, post, put, delete: del, writeWithHeaders } = useHttpService(); const { patientState } = usePatientServiceContext(); const { transformToCodeableConcept, transformFromCodeableConcept, createReferenceObject, unpackBundle, formatToFhirInstant, transformFromIdentifier, transformToReferenceCollection, transformFromReferenceCollection, addFhirPeriodIfApplicable, addPeriodControlsIfApplicable } = useFhirServiceContext(); const [appointmentsList, setAppointmentsList] = useState([]); const [shouldReload, setShouldReload] = useState(false); // The patient whose last appointment read failed, so every reader can tell failed from empty. const [appointmentsFailedFor, setAppointmentsFailedFor] = useState(); const [lastUpserted, setLastUpserted] = useState(null); const [unsaved, setUnsaved] = useState(false); const [appointmentsBundle, setAppointmentsBundle] = useState | null>(null); const appointmentsBundleRef = useRef | null>(null); const lastFetchTimeRef = useRef(0); useEffect(() => { appointmentsBundleRef.current = appointmentsBundle; }, [appointmentsBundle]); // WHOSE APPOINTMENTS ARE THESE? The reset below fires only when there is NO patient, which moving // between two charts never is - so the previous patient's bundle survived the switch and was served // as a cache hit, with no request and nothing on screen to say the list was not theirs. const cacheOwner = useCacheOwner('Appointment', () => { setLastUpserted(null); appointmentsBundleRef.current = null; setAppointmentsBundle(null); setAppointmentsList([]); setAppointmentsFailedFor(undefined); lastFetchTimeRef.current = 0; }); useEffect(() => { if (!patientState) { setAppointmentsBundle(null); setAppointmentsList([]); lastFetchTimeRef.current = 0; cacheOwner.release(); } }, [patientState, cacheOwner.release]); // `transport` admits every page to the operation that asked, so no page is sent once it has retired or run out of time. const fetchAppointments = useCallback(async ( patient: Patient, start?: string, end?: string, organizationId?: string, transport?: SaveTransport, ): Promise> => { const searched = await fetchAllPagesAsBundle((pageParam) => { let params = new URLSearchParams(); params.append('_count', searchPageSize('100')); params.append('patient', patient.id as string); if (start) { params.append('start', `ge${start}`); } if (end) { params.append('start', `le${end}`); } if (pageParam) params.set('page', pageParam); return get>(`${API_URL}/Appointment?${params.toString()}`, { organizationId, ...(transport ?? {}) }); }); return overlayHeldBundle('Appointment', searched, (appointment) => appointmentInQuery(appointment, { patientId: patient.id, organizationId, start, end })) as Bundle; }, [get]); // Org-scoped fetch by status (no patient filter - the organization is applied via request headers). // Loads held self-bookings (proposed requests / pending reviews) for the receptionist overview, // including slot-less time-window requests that the slot-based schedule query cannot reach. // // - the day board's OTHER source, and the one whose truncation is least visible. These // are held self-bookings: slot-less time-window requests that the per-schedule slot query cannot // reach at all. A request that falls off page one is not a late row on a board - it is a patient // who asked for an appointment and whom nobody is shown. `proposed`/`pending` accumulate until staff // act on them, so this list grows precisely when the practice is behind, which is exactly when it // must not silently shorten. // Honours probe; it was hardcoded at 100. A bigger `_count` is not the fix. // asSearched hands back the search alone, for a caller that lays the hold over several reads together, once. const fetchAppointmentsByStatus = useCallback(async (statuses: string[], asSearched = false): Promise> => { const started = startSearch(); const searched = await fetchAllPagesAsBundle((pageParam) => { const params = new URLSearchParams(); params.append('_count', searchPageSize('100')); if (statuses.length > 0) { params.append('status', statuses.join(',')); } if (pageParam) params.set('page', pageParam); return get>(`${API_URL}/Appointment?${params.toString()}`); }); // Under another viewer by now, the answer is empty, raw or overlaid. if (!sameViewerSince(started)) return { ...searched, entry: [], ...(searched.total === undefined ? {} : { total: 0 }) }; markSearchStarted(searched, started); if (asSearched) return searched; return overlayHeldBundle('Appointment', searched, (appointment) => appointmentInQuery(appointment, { statuses })) as Bundle; }, [get]); // Org-scoped fetch of appointments overlapping a day window (no patient filter - org applied via headers). // Used by the clinician overview; the caller filters to the logged-in practitioner client-side. // // pre-merge fix: this hits the org-wide branch (no `patient`), which forwards params // verbatim into an Appointment search (handlers/Appointment/get.ts's getAppointmentsForOrganization). // `start` is a SLOT search param, not an Appointment one - per this datastore's live // CapabilityStatement, Appointment supports `date`, not `start`. Do NOT change this back to `start`; // that was silently ignored by HealthLake (returning the whole org's appointments, filtered // client-side) until added a guard that now throws on any ignored param. Contrast with // fetchAppointments above, which passes a `patient` and so routes to the patient/Slot branch, where // `start` IS the correct param - the two are not interchangeable. // // - IT PAGES NOW, and this is the read the card was raised about. The comment on // `fetchAllAppointmentsForOrgRange` below has described this exact line as *"caps at `_count=100` // with no `next` follow"* since - **naming a defect is not fixing it**, and what made it // worse than a short calendar is what the day board DERIVES from it: *"Nothing needs you and nobody // is waiting"* is a claim about the whole day, computed from whatever this returned. // *"Fine for one day"* was the reasoning, and it is the wrong bound to reason against - one busy // practice's day, or a probe at `_count=1`, both exceed it. A bigger `_count` moves the cliff. // The page size is UNCHANGED at 100 unless probe is set. const fetchTodaysAppointmentsForOrg = useCallback(async (startISO: string, endISO: string): Promise> => { const searched = await fetchAllPagesAsBundle((pageParam) => { const params = new URLSearchParams(); params.append('_count', searchPageSize('100')); params.append('date', `ge${startISO}`); params.append('date', `le${endISO}`); if (pageParam) params.set('page', pageParam); return get>(`${API_URL}/Appointment?${params.toString()}`); }); return overlayHeldBundle('Appointment', searched, (appointment) => appointmentInQuery(appointment, { start: startISO, end: endISO })) as Bundle; }, [get]); /** * - EVERY appointment the server holds for a range, cancelled included, with no slot required. * * WHY THIS EXISTS RATHER THAN REUSING THE SLOT PATH. The calendar sourced its appointments from * `Slot?...&_revinclude=Appointment:slot`, and a revinclude on `Appointment:slot` returns **only * appointments that reference a slot**. Measured on Great River: **28 appointments, 13 reachable** - * the calendar was silently missing 15, of which 14 were cancelled. * Spencer's ruling, 2026-08-14: the **calendar always shows cancelled** (it is a RECORD surface - a * 10am gap means something different if it was cancelled this morning, and repeat cancellations are * only visible if cancellations are), while the **day board keeps excluding them** (a queue's whole * value is that everything in it is actionable). * * IT PAGES, and that is not defensive padding. `fetchTodaysAppointmentsForOrg` caps at `_count=100` * with no `next` follow - fine for one day, and silently truncating for the month and year views this * calendar offers. Replacing a slot-shaped incompleteness with a page-shaped one would leave the card's * own title ("silently incomplete") still true. This is the exact defect that had just been fixed * server-side, where a one-page role lookup returned 10 of 11 and every schedule in the org vanished. * * The 50-page bound mirrors `BillingOverviewPage`'s precedent, and means the same thing: if it ever * trips, that is the signal to move this aggregation server-side - **not** to raise the cap. It reports * `truncated` rather than throwing, because a calendar that renders 5,000 appointments and says so is * more useful than one that renders an error. */ const fetchAllAppointmentsForOrgRange = useCallback(async ( startISO: string, endISO: string, asSearched = false, ): Promise<{ appointments: Appointment[]; truncated: boolean }> => { const started = startSearch(); const { resources, truncated } = await fetchAllBundlePages((pageParam) => { const params = new URLSearchParams(); // - unchanged at 100 unless a probe is set. This one is the CONTROL for that // probe: it DOES follow `link[next]`, so a small page size must still yield every appointment. params.append('_count', searchPageSize('100')); // `date`, not `start` - per this datastore's CapabilityStatement Appointment supports `date`. // `start` is a SLOT param and was silently IGNORED here until guard began throwing // on an ignored param, which is why it is spelled out in both fetchers. params.append('date', `ge${startISO}`); params.append('date', `le${endISO}`); if (pageParam) params.set('page', pageParam); return get>(`${API_URL}/Appointment?${params.toString()}`); }); if (!sameViewerSince(started)) return { appointments: [], truncated }; if (asSearched) return { appointments: resources, truncated }; const appointments = overlayHeld('Appointment', resources, (appointment) => appointmentInQuery(appointment, { start: startISO, end: endISO }), Date.now(), !truncated, started); return { appointments, truncated }; }, [get]); /** * - every appointment filed under any of these participant references, for the deactivation * dialog's count. * * IT PASSES EVERY SHAPE AT ONCE, and that is measured rather than defensive. On staging **171 of * 254** appointments name only a `Practitioner/`, **2** name only a `PractitionerRole/` and **80** name * both - so a caller that searched one shape would miss real appointments. `actor` takes a * comma-separated list as an OR, which is why this is one request and not one per reference. * * NO `date` BOUND, deliberately. A patient's pending time-window REQUEST has no `date` at all and * cannot satisfy a window, so a `date=ge` filter would drop precisely the appointments that are * already hardest to see. The caller splits past from upcoming in code (`staffAppointmentImpact`), where * "has no date yet" can mean "still to come" instead of "expired". * * IT PAGES, and reports `truncated` rather than throwing. An undercount is the defect is * about; a count the dialog can label "at least" is honest, and a thrown error would block a * deactivation the admin may legitimately need to complete. */ const fetchAppointmentsForActors = useCallback(async ( actorReferences: string[], ): Promise<{ appointments: Appointment[]; truncated: boolean }> => { if (actorReferences.length === 0) return { appointments: [], truncated: false }; const started = startSearch(); const { resources, truncated } = await fetchAllBundlePages((pageParam) => { const params = new URLSearchParams(); params.append('_count', '100'); params.append('actor', actorReferences.join(',')); if (pageParam) params.set('page', pageParam); return get>(`${API_URL}/Appointment?${params.toString()}`); }); if (!sameViewerSince(started)) return { appointments: [], truncated }; const appointments = overlayHeld('Appointment', resources, (appointment) => appointmentInQuery(appointment, { actors: actorReferences }), Date.now(), !truncated, started); return { appointments, truncated }; }, [get]); /** * `status` IS DELETED FROM THIS SIGNATURE RATHER THAN WIRED UP, because it was never a status. * * It was passed straight into `fetchAppointments(patient, status)` - whose second parameter is * `start`. So a status would have become `start=ge`, a nonsense date filter, and the * symptom would have been an EMPTY LIST rather than an error: the shape that reads as "this patient * has no appointments" instead of "that parameter is wrong". * No caller ever passed one - `useFhirAppointments` is the only caller and passes the patient alone - * so nothing is lost, and an unused parameter sitting in the wrong slot is a trap left armed for * whoever first tries to use it. */ const fetchAppointmentsWithPolling = useCallback(async (patient: Patient, forceReload = false, transport?: SaveTransport): Promise> => { const now = Date.now(); const cacheAge = now - lastFetchTimeRef.current; const cacheFresh = cacheAge < 5 * 60 * 1000; // cacheOwner.isFor is the load-bearing clause: a fresh bundle belonging to the PREVIOUS patient // satisfies every other condition here and would be returned as a hit. // // The freshness clause reads the REF, not the bundle state beside it. That distinction cost a // card: this callback is a useCallback whose dependencies do not include the bundle, so the // state it closes over is from whichever render last rebuilt it and can be arbitrarily old. // Reading it made the cache miss on lists it was holding, and - in the narrow window where the // closure held a real bundle and state had moved on - answer with the older of the two. if (!forceReload && !shouldReload && !writtenSince('Appointment', lastFetchTimeRef.current) && appointmentsBundleRef.current && cacheFresh && cacheOwner.isCurrentFor(patient.id)) { return appointmentsBundleRef.current; } // One read; what this viewer just wrote is laid over it by the shared hold, so nothing waits on the index. const operation = cacheOwner.begin(); let bundle: Bundle; try { bundle = await fetchAppointments(patient, undefined, undefined, undefined, transport); } catch (failure) { if (cacheOwner.publishes(operation)) setAppointmentsFailedFor(patient.id); throw failure; } if (!cacheOwner.publishes(operation)) return cacheOwner.answerFor(operation, bundle); appointmentsBundleRef.current = bundle; setAppointmentsFailedFor(undefined); setAppointmentsBundle(bundle); setAppointmentsList(unpackBundle(bundle)); setLastUpserted(null); setShouldReload(false); lastFetchTimeRef.current = now; // Stamped with the data, never before it: on a throw the owner still names the list in state. cacheOwner.claim(patient.id, operation); return bundle; // `lastUpserted` is no longer read here - the hold replaced the wait for it - so it is not a // dependency. It is still SET by a write, because it is what the write has to say. }, [fetchAppointments, unpackBundle, shouldReload]); // `fresh`: the stored Appointment read by id, never the cached list copy - what is saved now, after a refused or // unconfirmed save; `transport` admits that read to the save it belongs to. const fetchAppointment = useCallback(async (id: string, fresh = false, transport?: SaveTransport): Promise => { const cachedAppointment = !fresh && cacheOwner.isCurrent(cacheOwner.claimed) ? appointmentsList.find(appointment => appointment.id === id) : undefined; if (cachedAppointment) { return cachedAppointment; } return get(`${API_URL}/Appointment/${id}`, { ...(transport ?? {}) }); }, [get, appointmentsList]); const fetchAppointmentHistory = useCallback(async (id: string): Promise> => { return get>(`${API_URL}/Appointment/${id}/_history`); }, [get]); // S4e-2 - `ctaToken` is a TRANSIENT hint only, carried as a query-string param, never // written onto `appointment` itself. The server reads it off the query string, counts it, and // discards it (handlers/Appointment/post.ts) - nothing about a campaign is ever persisted on // the Appointment resource. /** * A WRITE HAS TO TELL THE READER, and until this existed none of them did. * * THE DEFECT IT CLOSES, walked end to end 2026-08-26: a patient booked an appointment, was told * "Appointment created successfully", and it appeared NOWHERE in their portal - not the Appointments * page, not the home's scheduled count, not the home's Upcoming card, and their task deadlines went * on saying "Due by: your next appointment", the wording that means none is booked. * The write was perfect. Read straight out of HealthLake: right patient, right start, status * `booked`, correct organization tag, structurally identical to appointments the same patient's * portal was displaying. `createAppointment` simply POSTed and returned, so `fetchAppointmentsWithPolling` * went on serving its five-minute cache - a list assembled BEFORE the booking existed. * * `waitFor` NAMED THE RESOURCE THE READER WAS OWED, and it used to arm a retry that waited for that * id to turn up in a search. That wait is gone: the lag is minutes and the loop gave up at thirty * seconds, so it timed out and served a list without the appointment nearly every time. The * just-created resource is HELD instead - see `fetchAppointmentsWithPolling`. * * WHAT STAYS IS THE INVALIDATION, and it is the half that was always load-bearing: a write must tell * the reader its cached list is out of date, or the reader serves a list assembled before the write. * * DELETE PASSES NO `waitFor`, deliberately and still correctly: there is no created resource to hold, * and a cancelled appointment is one the search should stop returning rather than start. */ const markAppointmentsStale = useCallback((waitFor?: string) => { setLastUpserted(waitFor ?? null); setShouldReload(true); // BOTH, AND THE REF IS THE ONE THAT ACTUALLY WORKS FOR THE CALLER THAT MATTERS. // // `shouldReload` is React state, so `fetchAppointmentsWithPolling` reads whatever value its // closure captured BEFORE this render. A caller that writes and then reads in the same tick - // which is exactly what booking does - therefore still sees `shouldReload === false` and a // five-minute-fresh cache, and is handed the list from before its own write. // // Measured in a browser 2026-08-27: the patient booked, `POST /R4/Appointment` returned 201, // and their own portal said "0 scheduled appointments - No Upcoming Appointments" until a hard // reload. Nothing failed. The write was perfect and the read was stale. // // Zeroing the ref is synchronous, so the freshness check below fails immediately no matter // where React is in its cycle. The state flag stays because it is what survives into the NEXT // render for consumers that re-read then; neither one covers both cases alone. lastFetchTimeRef.current = 0; }, []); // A change may have been made elsewhere while no notice could reach this page: only a list this patient // already has is read again, and within the floor only when notices may have been missed. const rereadAfterAbsence = useCallback((missed = false) => { if (!patientState?.id || !cacheOwner.isCurrentFor(patientState.id)) return; if (!missed && Date.now() - lastFetchTimeRef.current < RETURN_REREAD_FLOOR_MS) return; markAppointmentsStale(); }, [patientState?.id, cacheOwner.isCurrentFor, markAppointmentsStale]); const createAppointment = useCallback( async ( appointment: Appointment, ctaToken?: string | null, /** * The appointment this booking REPLACES, when the patient is moving one rather than adding one. * * The server's self-booking guard refuses a second active appointment with the same practitioner, and * when somebody is MOVING one, the appointment being moved is itself the match - so the commonest * reschedule there is, same clinician and a different time, was refused by the very thing it replaces. * * IT IS A HINT ABOUT INTENT AND NOT AN EXEMPTION. Both server guards fetch that appointment and satisfy * themselves it is this patient's, with this practitioner, and active and in the future before the id is * read at all; naming anything else changes nothing. */ replacesAppointmentId?: string, organizationId?: string, // The booking operation kept on this device before the send; a retry of it can never book twice. operationId?: string, ): Promise => { const params = new URLSearchParams(); if (ctaToken) params.set('cta', ctaToken); if (replacesAppointmentId) params.set('replaces', replacesAppointmentId); const query = params.toString() ? `?${params.toString()}` : ''; const created = await post(`${API_URL}/Appointment${query}`, appointment, operationId ? { organizationId, headers: { [BOOKING_OPERATION_HEADER]: operationId } } : { organizationId }); if (isBookingUnconfirmedAnswer(created)) throw new BookingUnconfirmedError(operationId); // The shared hold keeps what the server answered until a search has it. markAppointmentsStale(created?.id); return created; }, [post, markAppointmentsStale], ); // What became of a booking whose answer was lost, asked by its operation alone: no payload, no new Slot. const checkBookingOperation = useCallback(async (operationId: string, organizationId?: string): Promise> => { let answer: Appointment; try { answer = await get(`${BOOKING_CHECK_API_URL}/Appointment/_booking-operation/${encodeURIComponent(operationId)}`, organizationId ? { organizationId } : {}); } catch (error) { return { kind: 'failed', error }; } if (isBookingUnconfirmedAnswer(answer)) return { kind: 'unconfirmed' }; markAppointmentsStale(answer?.id); return { kind: 'booked', appointment: answer }; }, [get, markAppointmentsStale]); // Names the version the edit was made against, and, where no practice is selected, the visit's own practice for // this request. A refusal is a RecordChangedError and nothing was saved. What the server saved but could not // finish is handed to `onFollowUp`, which every caller must say something about. const updateAppointment = useCallback(async (appointment: Appointment, options: AppointmentUpdateOptions): Promise => { let written: Awaited>>; try { written = await writeWithHeaders('PUT', `${API_URL}/Appointment/${appointment.id}`, appointment, { // The visit's records this viewer already knows of, so the server reads them by id before it ends, // advances or creates one: a search can miss an old one. headers: { ...ifMatch(options.basedOn), ...knownVisitRecordsHeader(visitScopeOf(appointment)) }, ...(options.organizationId ? { organizationId: options.organizationId } : {}), ...(options.transport ?? {}), }); } catch (error) { // What the failed save did to the visit's record rides on whatever shape the failure takes. throw withVisitEncounterOutcome(asAppointmentChanged(error), visitEncounterOutcomeOf(error)); } const followUps = parseAppointmentFollowUps(written.header(APPOINTMENT_FOLLOW_UP_HEADER)); if (followUps.length > 0) options.onFollowUp(followUps); markAppointmentsStale(written.data?.id ?? appointment.id); return written.data; }, [writeWithHeaders, markAppointmentsStale]); // Visits the server changed on its own, as a consent withdrawal cancels them. Each is read by id, // which does not wait on the search index, and held as a write of ours would be. const holdServerChangedAppointments = useCallback(async (ids: string[], transport?: SaveTransport): Promise => { if (ids.length === 0) return []; // Numbered before the reads, so an answer that arrives after the viewer changed is not held. const startedIn = startWrite(); const current = await Promise.all(ids.map((id) => get(`${API_URL}/Appointment/${id}`, { ...transport }))); // A read-back its caller retired while it was answering publishes nothing to the shared list. transport?.admit(); for (const appointment of current) holdWrite(appointment, Date.now(), startedIn); markAppointmentsStale(ids[ids.length - 1]); return current; }, [get, markAppointmentsStale]); const deleteAppointment = useCallback(async (appointment: Appointment): Promise => { const deleted = await del(`${API_URL}/Appointment/${appointment.id}`); // The shared hold keeps it hidden until a complete search proves it gone. markAppointmentsStale(); return deleted; }, [del, markAppointmentsStale]); const getActiveFutureAppointmentForPractitioner = useCallback(( practitionerId: string, practitionerRoleIds: string[] = [], appointments: Appointment[] = appointmentsList, // The practice's cap for the service being booked, and the coding it applies to. Absent means // uncapped, which is the ruled default - so a caller that knows no service refuses nothing. futureAppointmentLimit?: number, serviceCodingKey?: string ): Appointment | undefined => { return findActiveFutureAppointmentForPractitioner( appointments, practitionerId, practitionerRoleIds, new Date(), futureAppointmentLimit, serviceCodingKey ); }, [appointmentsList]); const transformToFhirAppointment = useCallback((item: any, slot?: Slot, participants?: AppointmentParticipant[]): Appointment => { const { status, cancelationReason, description, comment, reasonCode, reasonReference, appointmentType, patientInstruction, participant, start, end, serviceCategory, serviceType, specialty } = item; const transformedReasonReference = transformToReferenceCollection(reasonReference); const appointment: Appointment = { resourceType: 'Appointment', status: status || 'booked', ...(description && { description: description }), ...(comment && { comment: comment }), ...(patientInstruction && { patientInstruction: patientInstruction }), ...(reasonCode && reasonCode.length !== 0 && { reasonCode: transformToCodeableConcept(reasonCode) }), ...(transformedReasonReference && transformedReasonReference.length !== 0 && { reasonReference: transformedReasonReference }), ...(appointmentType && { appointmentType: transformToCodeableConcept(appointmentType) }), ...(cancelationReason && { cancelationReason: transformToCodeableConcept(cancelationReason) }), ...(slot && { slot: [createReferenceObject('Slot', slot.id as string)] }), ...(slot && slot.start && { start: formatToFhirInstant(slot.start) }), ...(start && { start: formatToFhirInstant(start) }), ...(slot && slot.end && { end: formatToFhirInstant(slot.end) }), ...(end && { end: formatToFhirInstant(end) }), ...(slot && slot.serviceCategory && slot.serviceCategory.length !== 0 && { serviceCategory: slot.serviceCategory }), ...(serviceCategory && serviceCategory.length !== 0 && { serviceCategory: transformToCodeableConcept(serviceCategory) }), ...(slot && slot.serviceType && slot.serviceType.length !== 0 && { serviceType: slot.serviceType }), ...(serviceType && serviceType.length !== 0 && { serviceType: transformToCodeableConcept(serviceType) }), ...(slot && slot.specialty && slot.specialty.length !== 0 && { specialty: slot.specialty }), ...(specialty && specialty.length !== 0 && { specialty: transformToCodeableConcept(specialty) }), participant: participants ? participants : participant.map((p: any) => { const transformedActor: Reference[] = transformToReferenceCollection(p.actor); const appointmentParticipant: AppointmentParticipant = { status: p.status, ...(p.type && p.type.length !== 0 && { type: transformToCodeableConcept(p.type) }), ...(p.required && { required: p.required }), ...(transformedActor && transformedActor.length !== 0 && { actor: transformedActor[0] }), }; return addFhirPeriodIfApplicable(appointmentParticipant, p, 'period'); }) }; // return addFhirPeriodIfApplicable(appointment, item, 'requestedPeriod'); return appointment; }, [transformToCodeableConcept, formatToFhirInstant, createReferenceObject, transformToReferenceCollection, addFhirPeriodIfApplicable]); const transformFromFhirAppointment = useCallback((appointment: Appointment): any => { const { status, appointmentType, serviceCategory, serviceType, specialty, reasonCode, reasonReference, priority, description, start, end, minutesDuration, comment, patientInstruction, participant, cancelationReason } = appointment; const formData = { status: status, cancelationReason: transformFromCodeableConcept(cancelationReason), serviceCategory: transformFromCodeableConcept(serviceCategory ?? []), serviceType: transformFromCodeableConcept(serviceType ?? []), specialty: transformFromCodeableConcept(specialty ?? []), appointmentType: transformFromCodeableConcept(appointmentType), reasonCode: transformFromCodeableConcept(reasonCode ?? []), reasonReference: transformFromReferenceCollection(reasonReference ?? []), priority: priority ?? 0, description: description ?? '', start: start ? new Date(new Date(start).getTime() - new Date(start).getTimezoneOffset() * 60000).toISOString().slice(0, 16) : '', end: end ? new Date(new Date(end).getTime() - new Date(end).getTimezoneOffset() * 60000).toISOString().slice(0, 16) : '', minutesDuration: minutesDuration ?? 0, comment: comment ?? '', patientInstruction: patientInstruction ?? '', participant: participant.map((p: AppointmentParticipant) => { const appointmentParticipant: any = { status: p.status, type: transformFromCodeableConcept(p.type), required: p.required, actor: transformFromReferenceCollection(p.actor ? [p.actor] : []) }; return addPeriodControlsIfApplicable(appointmentParticipant, p, 'period'); }) }; // return addPeriodControlsIfApplicable(formData, appointment, 'requestedPeriod'); return formData; }, [transformFromCodeableConcept, transformFromIdentifier, transformFromReferenceCollection, addPeriodControlsIfApplicable]); const transformAppointmentToFullCalendarEvent = useCallback((appointment: Appointment) => { const patientParticipant = appointment.participant?.find(p => p.actor?.reference?.startsWith('Patient/')); const patientId = patientParticipant?.actor?.reference?.split('/')[1]; const patientName = patientParticipant?.actor?.display; const getEventColor = (status?: string) => { switch (status) { case 'proposed': return '#ffc107'; case 'pending': return '#17a2b8'; case 'booked': return '#28a745'; case 'arrived': return '#007bff'; case 'fulfilled': return '#6f42c1'; case 'cancelled': return '#dc3545'; case 'noshow': return '#fd7e14'; case 'entered-in-error': return '#6c757d'; default: return '#2271b1'; } }; /** * A CANCELLED APPOINTMENT RECEDES. Spencer, 2026-08-23: *"They should be less emphasised than * something that needs their focus more right now since there's really no action they need to take * on a cancelled appointment."* * * What it looked like before: every status painted a SOLID saturated fill with white text, so * `cancelled` (#dc3545, the loudest red in the set) carried the same visual weight as `booked` and * MORE than `arrived`. Measured on Great River's August: 27 of 53 appointments cancelled, so the * month grid read as a wall of alarm. * * THE MAPPING WAS INVERTED, and that is the actual defect: colour encoded WHAT KIND of thing an * appointment is, while a receptionist reads weight as HOW MUCH IT MATTERS. A cancelled slot needs * no action at all - it is freed time - and red is the vocabulary of *problem, act now*. * * IT STILL RENDERS, AND THAT IS A RULING, NOT A PREFERENCE. `appointmentRangeSource.test.tsx`: * *"The calendar keeps the cancelled ones. That is the ruling, not an implementation detail"* - * asserted against Great River, where a calendar showing 13 of 28 is the FAILING state. * De-emphasis is the ONLY move available here. Never filter them out, never hide them. * * Every other status is untouched on purpose. This changes ONE status, and the recessive palette * still passes normal-text contrast (#6c757d on #f1f3f5 ≈ 4.7:1), so receding never means unreadable. */ const isCancelled = appointment.status === 'cancelled'; const eventPalette = isCancelled ? { background: '#f1f3f5', border: '#ced4da', text: '#6c757d' } : { background: getEventColor(appointment.status), border: getEventColor(appointment.status), text: '#ffffff', }; const appointmentType = appointment.appointmentType?.text || appointment.appointmentType?.coding?.[0]?.display || 'Appointment'; const title = patientName ? `${patientName} - ${appointmentType}` : appointmentType; const start = appointment.start ? new Date(appointment.start) : null; const end = appointment.end ? new Date(appointment.end) : null; let duration = appointment.minutesDuration; if (!duration && start && end) { duration = Math.round((end.getTime() - start.getTime()) / (1000 * 60)); } return { id: appointment.id, title, start: start?.toISOString(), end: end?.toISOString(), backgroundColor: eventPalette.background, borderColor: eventPalette.border, textColor: eventPalette.text, // The class carries the strike-through. Colour alone is not a signal every reader gets - // this way "not happening" survives greyscale, a colour-vision deficiency and a printed day sheet. classNames: isCancelled ? ['ahr-appointment--cancelled'] : [], editable: false, extendedProps: { type: 'appointment', appointmentId: appointment.id, patientId: patientId, patientName, status: appointment.status, appointmentType: appointmentType, description: appointment.description, comment: appointment.comment, priority: appointment.priority, duration: duration, reasonCode: appointment.reasonCode, participant: appointment.participant, slot: appointment.slot } }; }, []); //: collaboration-team visit invitations // Three routes, all on fhirBaseApi (they live in the 02-fhir-base stack beside appointment-policy- // settings). Pointing these at apiUrl would 404 silently - see. // // The READ is what makes the checklist possible at all: `GET /R4/collaboration-team` returns the // CALLER'S own teams, so staff have no other source for the patient's team members. const fetchVisitInvitation = useCallback( (appointmentId: string): Promise => get( `${API_URL}/appointment-visit-invitation?appointmentId=${encodeURIComponent(appointmentId)}`, ), [get], ); // The invitee's own list - the piece that breaks the discovery circularity. Self-scoped: it takes no // parameter, because the server derives the subject from the caller's own auth context. const fetchMyVisitInvitations = useCallback( async (): Promise => { const response = await get<{ invitations: MyVisitInvitation[] }>( `${API_URL}/my-visit-invitations`); return response.invitations ?? []; }, [get], ); // The same list with the server's own limit said: `truncated` when it stopped before reading every page, so a // shorter list is never taken for the whole one. const fetchMyVisitInvitationsPage = useCallback( async (): Promise<{ invitations: MyVisitInvitation[]; truncated: boolean }> => { const response = await get<{ invitations?: MyVisitInvitation[]; truncated?: boolean }>(`${API_URL}/my-visit-invitations`); return { invitations: response.invitations ?? [], truncated: response.truncated === true }; }, [get], ); // The caller's ACCEPTED team video visits in a date range, for their own calendar (team video slice 0 contract, // section 6): the same self-scoped route, so a practitioner from another organization or with none sees the visit. const fetchMyVisitCalendar = useCallback( async (from: string, to: string): Promise => { const params = new URLSearchParams({ view: 'calendar', from, to }); const response = await get<{ invitations: MyVisitInvitation[] }>(`${API_URL}/my-visit-invitations?${params.toString()}`); return response.invitations ?? []; }, [get], ); // propose / approve / withdraw / set-allowed. The server derives WHICH party the caller is from their // auth context - the client never asserts it, and a receptionist's delegation is recorded server-side // as acting on the practitioner's behalf rather than as the practitioner consenting. // `admit` is asked before the request leaves and again before its answer is used; `onSend` marks the moment it leaves, // so a caller can stop a change it no longer stands by without losing one that was already sent. const updateVisitInvitation = useCallback( (request: VisitInvitationRequest, transport?: { admit?: () => void; onSend?: () => void }): Promise => put( `${API_URL}/appointment-visit-invitation`, request, transport ? { admit: transport.admit, onSend: transport.onSend } : undefined), [put], ); // The invitee's own answer. Deliberately carries NO ref: the server takes the responder from the auth // context, so this call cannot answer for anyone else. // Accepting sends the revision the person saw; a decline may carry a short reason. The result is the caller's own // projection, whose `reservation` says whether the time is actually held. const respondToVisitInvitation = useCallback( ( appointmentId: string, response: 'accepted' | 'declined', extras: { revision?: string; declineReason?: string } = {}, ): Promise => put( `${API_URL}/appointment-visit-invitation-response`, { appointmentId, response, ...(extras.revision ? { revision: extras.revision } : {}), ...(response === 'declined' && extras.declineReason ? { declineReason: extras.declineReason } : {}), }), [put], ); const getParticipantByType = useCallback((appointment: Appointment | undefined, type: 'Patient' | 'PractitionerRole') => { const participant = appointment?.participant?.find(p => p.actor?.reference?.startsWith(`${type}/`)); const participantId = participant?.actor?.reference?.split('/')[1]; const participantDisplay = participant?.actor?.display; const participantType = participant?.type; return { participant, participantId, participantDisplay, participantType }; }, []); return { // WHO THE CACHED LIST IS FOR, so a consumer can ask whether it belongs to the patient on screen // rather than trusting an app-wide context with no patient in its name. cachedForPatientId: cacheOwner.ownerId, appointmentsFailedFor, appointmentsBundle, appointmentsList, setAppointmentsList, shouldReload, setShouldReload, lastUpserted, setLastUpserted, unsaved, setUnsaved, fetchAppointments, fetchAppointmentsByStatus, fetchTodaysAppointmentsForOrg, fetchAllAppointmentsForOrgRange, fetchAppointmentsForActors, fetchAppointmentsWithPolling, fetchAppointment, fetchAppointmentHistory, createAppointment, checkBookingOperation, updateAppointment, holdServerChangedAppointments, rereadAfterAbsence, deleteAppointment, getActiveFutureAppointmentForPractitioner, fetchVisitInvitation, fetchMyVisitInvitations, fetchMyVisitInvitationsPage, fetchMyVisitCalendar, updateVisitInvitation, respondToVisitInvitation, transformToFhirAppointment, transformFromFhirAppointment, getParticipantByType, transformAppointmentToFullCalendarEvent }; }; const AppointmentServiceContext = createContext | undefined>(undefined); export const AppointmentServiceProvider: React.FC<{ children: ReactNode }> = ({ children }) => { const appointmentService = useAppointmentService(); const { patientState } = usePatientServiceContext(); useFollowAppointmentChanges(appointmentService, patientState?.id); return ( {children} ); }; export const useAppointmentServiceContext = () => { const context = useContext(AppointmentServiceContext); if (context === undefined) { throw new Error('useAppointmentServiceContext must be used within an AppointmentServiceProvider'); } return context; };