import { useState, useCallback, createContext, useContext, ReactNode, useEffect, useRef } from 'react'; import type { SaveTransport } from '../utils/saveOperation'; import { Appointment, Bundle, CodeableConcept, Extension, Location, Organization, Practitioner, PractitionerRole, Reference, Schedule, Slot } from 'fhir/r4'; import { environment } from '../environments/environment.ts'; import { useFhirServiceContext } from './FhirService.tsx'; import { searchPageSize } from '../utils/pageSizeOverride'; import { fetchAllPagesAsBundle } from '../utils/fetchAllPagesAsBundle'; import { addCalendarDays, dayOfWeekOf, fhirCalendarDate, resolveBookingTimeZone, viewerTimeZone, zonedWallClockToInstant, } from '../utils/practiceTimezone'; import { useHttpService } from './HttpService.tsx'; import { formatFhirHumanName } from '../utils/fhirHumanName.tsx'; import { usePractitionerRoleServiceContext } from './PractitionerRoleService.tsx'; import { useAuthServiceContext } from './AuthService.tsx'; import { formatFhirPeriod } from '../utils/fhirPeriod.tsx'; import { asRecordChanged, ifMatch } from '../utils/recordChanged'; import { markSearchStarted, overlayHeldBundle, overlayHeldPage, sameViewerSince, startSearch, writtenSince } from '../utils/writeThroughHold'; import { isBookingHoldId } from '../utils/booking/bookingHold'; import type { HeldTime } from '../utils/booking/busyTimes'; const API_URL = environment.fhirBaseApi; // --------------------------------------------------------------------------- // Schedule booking flag extension URLs // --------------------------------------------------------------------------- export const SCHEDULE_ALLOW_BOOKING_EXTENSION_URL = 'https://akashichealthrecords.com/fhir/StructureDefinition/schedule-allow-booking'; export const SCHEDULE_ALLOW_PATIENT_SELF_BOOKING_EXTENSION_URL = 'https://akashichealthrecords.com/fhir/StructureDefinition/schedule-allow-patient-self-booking'; export const SCHEDULE_ALLOW_NEW_PATIENT_GENERAL_EXAM_EXTENSION_URL = 'https://akashichealthrecords.com/fhir/StructureDefinition/schedule-allow-new-patient-general-exam'; // FHIR R4 Schedule has no native weekly-hours field, so a schedule's per-practitioner weekly hours // live here as an Akashic extension mirroring the PractitionerRole.availableTime / notAvailable shape. // This is authoring data only - the interoperable, bookable surface remains native Slot resources. export const SCHEDULE_AVAILABLE_TIME_EXTENSION_URL = 'https://akashichealthrecords.com/fhir/StructureDefinition/schedule-available-time'; export const SCHEDULE_NOT_AVAILABLE_EXTENSION_URL = 'https://akashichealthrecords.com/fhir/StructureDefinition/schedule-not-available'; // Recurring daily breaks (lunch, etc.) - uniform across the block's working days, HH:MM local. Subtracted from the // day's availability windows so break time is neither bookable nor shown as available. export const SCHEDULE_BREAK_EXTENSION_URL = 'https://akashichealthrecords.com/fhir/StructureDefinition/schedule-break'; // Pure utility - safe to call outside hook context (e.g. in booking modals). export const getScheduleBookingFlags = ( schedule?: Schedule, ): { allowBooking: boolean; allowPatientSelfBooking: boolean; allowNewPatientGeneralExam: boolean } => { const exts = schedule?.extension ?? []; const find = (url: string) => exts.find((e) => e.url === url); return { allowBooking: find(SCHEDULE_ALLOW_BOOKING_EXTENSION_URL)?.valueBoolean ?? true, allowPatientSelfBooking: find(SCHEDULE_ALLOW_PATIENT_SELF_BOOKING_EXTENSION_URL)?.valueBoolean ?? true, // Explicit designation (no back-compat default) - only consulted when an org // enables "require a new patient consultation first". allowNewPatientGeneralExam: find(SCHEDULE_ALLOW_NEW_PATIENT_GENERAL_EXAM_EXTENSION_URL)?.valueBoolean ?? false, }; }; // The "Visit service" concepts offered by a schedule block (FHIR Schedule.serviceType). export const getScheduleServiceTypeConcepts = (schedule?: Schedule): CodeableConcept[] => schedule?.serviceType ?? []; // --------------------------------------------------------------------------- // Schedule availability (weekly hours) + location - isolation layer // --------------------------------------------------------------------------- // The weekly hours and location that used to live on a per-block PractitionerRole now live on the // Schedule itself: location as a native Schedule.actor Location reference, hours as the extensions // above. These pure helpers are the single seam readers/writers go through, so the storage shape can // change in one place. `getScheduleAvailability` returns the same field shape the availability // generator + calendars already consume (availableTime / notAvailable), so callers can pass a // Schedule's hours straight into generateAvailabilityEvents. type ScheduleAvailableTime = NonNullable[number]; type ScheduleNotAvailable = NonNullable[number]; export const buildScheduleAvailabilityExtensions = ( availableTime: ScheduleAvailableTime[] = [], notAvailable: ScheduleNotAvailable[] = [], ): Extension[] => [ ...availableTime.map((entry) => ({ url: SCHEDULE_AVAILABLE_TIME_EXTENSION_URL, extension: [ ...(entry.daysOfWeek ?? []).map((day) => ({ url: 'daysOfWeek', valueCode: day })), ...(entry.allDay !== undefined ? [{ url: 'allDay', valueBoolean: entry.allDay }] : []), ...(entry.availableStartTime ? [{ url: 'availableStartTime', valueString: entry.availableStartTime }] : []), ...(entry.availableEndTime ? [{ url: 'availableEndTime', valueString: entry.availableEndTime }] : []), ], })), ...notAvailable.map((entry) => ({ url: SCHEDULE_NOT_AVAILABLE_EXTENSION_URL, extension: [ ...(entry.description ? [{ url: 'description', valueString: entry.description }] : []), ...(entry.during ? [{ url: 'during', valuePeriod: entry.during }] : []), ], })), ]; export const getScheduleAvailability = ( schedule?: Schedule, ): { availableTime: ScheduleAvailableTime[]; notAvailable: ScheduleNotAvailable[] } => { const exts = schedule?.extension ?? []; const availableTime = exts .filter((e) => e.url === SCHEDULE_AVAILABLE_TIME_EXTENSION_URL) .map((e) => { const sub = e.extension ?? []; const days = sub .filter((s) => s.url === 'daysOfWeek') .map((s) => s.valueCode) .filter((c): c is string => Boolean(c)); const allDay = sub.find((s) => s.url === 'allDay')?.valueBoolean; const availableStartTime = sub.find((s) => s.url === 'availableStartTime')?.valueString; const availableEndTime = sub.find((s) => s.url === 'availableEndTime')?.valueString; return { ...(days.length ? { daysOfWeek: days as ScheduleAvailableTime['daysOfWeek'] } : {}), ...(allDay !== undefined ? { allDay } : {}), ...(availableStartTime ? { availableStartTime } : {}), ...(availableEndTime ? { availableEndTime } : {}), } as ScheduleAvailableTime; }); const notAvailable = exts .filter((e) => e.url === SCHEDULE_NOT_AVAILABLE_EXTENSION_URL) .map((e) => { const sub = e.extension ?? []; const description = sub.find((s) => s.url === 'description')?.valueString; const during = sub.find((s) => s.url === 'during')?.valuePeriod; return { ...(description ? { description } : {}), ...(during ? { during } : {}), } as ScheduleNotAvailable; }); return { availableTime, notAvailable }; }; // Recurring daily break (uniform across the block's working days). HH:MM local start/end + optional label. export type ScheduleBreak = { startTime: string; endTime: string; description?: string }; export const buildScheduleBreakExtensions = (breaks: ScheduleBreak[] = []): Extension[] => breaks .filter((b) => b.startTime && b.endTime) .map((b) => ({ url: SCHEDULE_BREAK_EXTENSION_URL, extension: [ { url: 'startTime', valueString: b.startTime }, { url: 'endTime', valueString: b.endTime }, ...(b.description ? [{ url: 'description', valueString: b.description }] : []), ], })); export const getScheduleBreaks = (schedule?: Schedule): ScheduleBreak[] => (schedule?.extension ?? []) .filter((e) => e.url === SCHEDULE_BREAK_EXTENSION_URL) .map((e) => { const sub = e.extension ?? []; const startTime = sub.find((s) => s.url === 'startTime')?.valueString ?? ''; const endTime = sub.find((s) => s.url === 'endTime')?.valueString ?? ''; const description = sub.find((s) => s.url === 'description')?.valueString; return { startTime, endTime, ...(description ? { description } : {}) }; }) .filter((b) => b.startTime && b.endTime); // Subtract break intervals from an availability window, returning the remaining bookable sub-windows in order. export const subtractBreaksFromWindow = ( windowStart: Date, windowEnd: Date, breakIntervals: Array<{ start: Date; end: Date }>, ): Array<{ start: Date; end: Date }> => { let segments: Array<{ start: Date; end: Date }> = [{ start: windowStart, end: windowEnd }]; for (const brk of breakIntervals) { const next: Array<{ start: Date; end: Date }> = []; for (const seg of segments) { if (brk.end <= seg.start || brk.start >= seg.end) { next.push(seg); continue; } if (brk.start > seg.start) next.push({ start: seg.start, end: brk.start }); if (brk.end < seg.end) next.push({ start: brk.end, end: seg.end }); } segments = next; } return segments.filter((seg) => seg.start < seg.end); }; // Whether a slot is one a read of this schedule between start and end returns. const slotInWindow = (slot: Slot, schedule: Schedule, start?: string, end?: string): boolean => { if (slot.schedule?.reference !== `Schedule/${schedule.id}`) return false; const at = Date.parse(slot.start); return (!start || at >= Date.parse(start)) && (!end || at <= Date.parse(end)); }; // The Location a schedule is for (native Schedule.actor Location reference). export const getScheduleLocationReference = (schedule?: Schedule): Reference | undefined => (schedule?.actor ?? []).find((ref) => ref.reference?.startsWith('Location/')); /** The response header the api answers every Schedule write with. Matches `cors.ts`'s expose list. */ export const SCHEDULE_NOTICE_HEADER = 'X-Schedule-Notice'; /** * A schedule write, plus whether the person it happened to was actually told. * * `noticeOutcome` is `null` when the header could not be read - an older deployment that does not send * it, or a browser refusing an unexposed header - and those are indistinguishable from here on purpose. * Both mean "we do not know", which is a different thing from "nobody was told". */ export interface ScheduleWriteResult { schedule: Schedule; noticeOutcome: string | null; } export const useScheduleService = () => { const { get, post, put, writeWithHeaders } = useHttpService(); // `wpOrganization` is the ORG-level timezone fallback for a site that has not been given its own - // correct for a single-site practice, which is the common case. const { userRole, wpOrganization } = useAuthServiceContext(); const { getPractitionerRoleDisplay } = usePractitionerRoleServiceContext(); const { transformToIdentifier, transformFromIdentifier, transformToCodeableConcept, transformFromCodeableConcept, transformFromOperationalHours, transformFromOperationalHoursNotAvailable, createReferenceObject, addFhirPeriodIfApplicable, addPeriodControlsIfApplicable, extractReferenceType, formatToFhirInstant, unpackBundle } = useFhirServiceContext(); const [scheduleState, setScheduleState] = useState(); const [schedulesList, setSchedulesList] = useState([]); const [shouldReload, setShouldReload] = useState(false); const [lastUpserted, setLastUpserted] = useState(null); const [unsaved, setUnsaved] = useState(false); const [schedulesBundle, setSchedulesBundle] = useState | null>(null); const schedulesBundleRef = useRef | null>(null); const lastFetchTimeRef = useRef(0); useEffect(() => { schedulesBundleRef.current = schedulesBundle; }, [schedulesBundle]); useEffect(() => { if (userRole) { setSchedulesBundle(null); setSchedulesList([]); lastFetchTimeRef.current = 0; } }, [userRole]); /** * Every schedule the caller may see - ALL pages of them. * * THIS READ TOOK PAGE ONE AND CALLED IT THE ANSWER, and of everything the client sweep covers it * was the one with the worst blast radius. A schedule that falls off page one does not degrade the * Schedules screen - it makes a practitioner **unbookable**: `useFhirSchedules` caches this list on a * shared context for five minutes, and every downstream surface (booking funnel, availability, the * day board) asks that cache who has a schedule. **This is the same defect the server had fixed * days earlier**, where a one-page role lookup returned 10 of 11 and every schedule in the org * vanished - and this file's sibling comment named it while this line kept doing it. * * A bigger `_count` is not the fix - it moves the cliff to a larger practice. It follows `next`. * The page size is UNCHANGED at 100 unless probe is set; with the probe at 1 this read * must still return everything, which is the positive control the card asks for. */ const fetchSchedules = useCallback(async (practitioner?: Practitioner, organizationId?: string): Promise> => { const started = startSearch(); const searched = markSearchStarted(await fetchAllPagesAsBundle((pageParam) => { const params = new URLSearchParams(); params.append('_count', searchPageSize('100')); if (practitioner) { params.append('practitioner', practitioner.id as string); } if (pageParam) params.set('page', pageParam); return get>(`${API_URL}/Schedule?${params.toString()}`, { organizationId }); }), started); // A schedule names no practice, so another practice's list only takes changes to the rows it found. if (organizationId) return overlayHeldPage('Schedule', searched) as Bundle; const practitionerReference = practitioner ? `Practitioner/${practitioner.id}` : undefined; return overlayHeldBundle('Schedule', searched, (schedule) => !practitionerReference || (schedule.actor ?? []).some((actor) => actor.reference === practitionerReference)) as Bundle; }, [get]); const fetchSchedule = useCallback(async (id: string): Promise => { const cachedSchedule = schedulesList.find(schedule => schedule.id === id); if (cachedSchedule) { return cachedSchedule; } return get(`${API_URL}/Schedule/${id}`); }, [get, schedulesList]); const fetchSchedulesWithPolling = useCallback(async (practitioner?: Practitioner, forceReload = false): Promise> => { const now = Date.now(); const cacheAge = now - lastFetchTimeRef.current; const cacheFresh = cacheAge < 5 * 60 * 1000; if (!forceReload && !shouldReload && !writtenSince('Schedule', lastFetchTimeRef.current) && schedulesBundleRef.current && cacheFresh) { return schedulesBundleRef.current; } let retries = 10; const delay = 3000; let found = false; let bundle: Bundle | null = null; let entries: Schedule[]; do { bundle = await fetchSchedules(practitioner); entries = unpackBundle(bundle); found = lastUpserted ? entries.some(e => e.id === lastUpserted) : true; if (!found) { await new Promise(resolve => setTimeout(resolve, delay)); } retries--; } while (!found && retries > 0); setSchedulesBundle(bundle); setSchedulesList(entries); setLastUpserted(null); setShouldReload(false); lastFetchTimeRef.current = now; return bundle; }, [fetchSchedules, unpackBundle, lastUpserted, shouldReload]); /** * ALL THREE WRITES CARRY THE NOTICE OUTCOME, not just the one a card was raised about. * * The server returns `X-Schedule-Notice` on create, update AND delete - and a schedule being DELETED * is the change most worth being told about, since the person's week does not move, it disappears. * Wiring only the update path would leave two of the three silently dropping the fact, which is the * shape of a gate that ends up fencing one door of three. * * The outcome rides alongside the resource rather than inside it: the body is a FHIR Schedule and * `ManageScheduleModal` stores what comes back, then spreads it into the NEXT save - so anything * added to the resource would ride along to HealthLake on the following edit. */ const createSchedule = useCallback(async (schedule: Schedule): Promise => { const { data, header } = await writeWithHeaders('POST', `${API_URL}/Schedule`, schedule); return { schedule: data, noticeOutcome: header(SCHEDULE_NOTICE_HEADER) }; }, [writeWithHeaders]); const updateSchedule = useCallback(async (schedule: Schedule): Promise => { const { data, header } = await writeWithHeaders('PUT', `${API_URL}/Schedule/${schedule.id}`, schedule); return { schedule: data, noticeOutcome: header(SCHEDULE_NOTICE_HEADER) }; }, [writeWithHeaders]); const deleteSchedule = useCallback(async (schedule: Schedule): Promise => { const { data, header } = await writeWithHeaders('DELETE', `${API_URL}/Schedule/${schedule.id}`); return { schedule: data, noticeOutcome: header(SCHEDULE_NOTICE_HEADER) }; }, [writeWithHeaders]); const transformToFhirSchedule = useCallback((item: any, practitioner?: Practitioner, practitionerRole?: PractitionerRole): Schedule => { const { active, comment, serviceCategory, serviceType, specialty, identifier, allowBooking, allowPatientSelfBooking, allowNewPatientGeneralExam, } = item; const transformedIdentifier = transformToIdentifier(identifier); const actor: Reference[] = []; if (practitioner) { actor.push(createReferenceObject('Practitioner', practitioner.id as string, formatFhirHumanName(practitioner.name))); } if (practitionerRole) { actor.push(createReferenceObject('PractitionerRole', practitionerRole.id as string, getPractitionerRoleDisplay(practitionerRole))); } const bookingExtensions: Extension[] = [ { url: SCHEDULE_ALLOW_BOOKING_EXTENSION_URL, valueBoolean: allowBooking !== false, }, { url: SCHEDULE_ALLOW_PATIENT_SELF_BOOKING_EXTENSION_URL, valueBoolean: allowPatientSelfBooking !== false, }, { url: SCHEDULE_ALLOW_NEW_PATIENT_GENERAL_EXAM_EXTENSION_URL, valueBoolean: allowNewPatientGeneralExam === true, }, ]; const schedule: Schedule = { resourceType: 'Schedule', active: active, extension: bookingExtensions, ...(actor.length !== 0 && { actor: actor }), ...(comment && { comment: comment }), ...(serviceCategory && serviceCategory.length !== 0 && { serviceCategory: transformToCodeableConcept(serviceCategory) }), ...(serviceType && serviceType.length !== 0 && { serviceType: transformToCodeableConcept(serviceType) }), ...(specialty && specialty.length !== 0 && { specialty: transformToCodeableConcept(specialty) }), ...(transformedIdentifier.length !== 0 && { identifier: transformedIdentifier }) }; return addFhirPeriodIfApplicable(schedule, item, 'planningHorizon') as Schedule; }, [transformToIdentifier, createReferenceObject, transformToCodeableConcept]); const transformFromFhirSchedule = useCallback((schedule: Schedule): any => { const { comment, active, serviceCategory, serviceType, specialty, identifier, actor } = schedule; const practitionerRole: Reference | undefined = (actor ?? []).find((ref: Reference) => extractReferenceType(ref) === 'PractitionerRole'); const { allowBooking, allowPatientSelfBooking } = getScheduleBookingFlags(schedule); const formData = { comment: comment ?? '', active: active ?? true, serviceCategory: transformFromCodeableConcept(serviceCategory), serviceType: transformFromCodeableConcept(serviceType), specialty: transformFromCodeableConcept(specialty), identifier: transformFromIdentifier(identifier ?? []), practitionerRole: practitionerRole, allowBooking, allowPatientSelfBooking, }; return addPeriodControlsIfApplicable(formData, schedule, 'planningHorizon'); }, [transformFromCodeableConcept, transformFromIdentifier]); /** * One schedule's slots for a window, with the appointments that reference them. * * - THIS IS WHERE THE RECEPTIONIST DAY BOARD ACTUALLY GETS ITS APPOINTMENTS, which the * card did not know: the board does not use the org day read, it calls this once per schedule. A * truncated page here is what turns into *"Nothing needs you and nobody is waiting"* - the queues are * built from these entries, and an unread page is indistinguishable from an empty afternoon. * A day is not a small number of slots. `_count=100` covers roughly one practitioner-day at * 5-minute granularity, and the board asks for a window that includes an overdue lookback. * * It now honours probe as well - it was hardcoded at 100, so the instrument could not * reach the very board it was built to test. A bigger `_count` is still not the fix; it follows * `link[next]`. * `_revinclude` rides along per page: `_count` bounds the MATCHES (slots), and each page carries * the appointments belonging to its own slots - so paging keeps a slot and its appointment together. */ const fetchSlots = useCallback(async (schedule: Schedule, start?: string, end?: string, organizationId?: string): Promise> => { const started = startSearch(); const searched = await fetchAllPagesAsBundle((pageParam) => { const params = new URLSearchParams(); params.append('_count', searchPageSize('100')); params.append('schedule', schedule.id as string); params.append('_revinclude', 'Appointment:slot'); if (start) { params.append('start', `ge${start}`); } if (end) { params.append('start', `le${end}`); } if (pageParam) params.set('page', pageParam); return get>(`${API_URL}/Slot?${params.toString()}`, { organizationId }); }); if (!sameViewerSince(started)) return { ...searched, entry: [], ...(searched.total === undefined ? {} : { total: 0 }) }; // Only the slots take the hold here; the booked visits this read brings are overlaid by the reader showing them. const entries = searched.entry ?? []; const visits = entries.filter((entry) => entry.resource?.resourceType !== 'Slot'); const slots = markSearchStarted({ ...searched, entry: entries.filter((entry) => entry.resource?.resourceType === 'Slot') } as Bundle, started); const overlaid = overlayHeldBundle('Slot', slots, (slot) => slotInWindow(slot, schedule, start, end)); if (overlaid === slots) return searched; return { ...searched, entry: [...(overlaid.entry ?? []), ...visits], ...(overlaid.total === undefined ? {} : { total: overlaid.total }) } as Bundle; }, [get]); // When the schedule's practitioner is held anywhere, inside its open hours. Served by the org-authorization // stack, not the FHIR base. An answer without a list is a failed read, never an empty day. const fetchHeldTimes = useCallback(async (schedule: Schedule, start: string, end: string, organizationId?: string): Promise => { const params = new URLSearchParams({ start, end }); const answer = await get<{ held?: HeldTime[] }>( `${environment.orgAuthorizationApi}/Schedule/${encodeURIComponent(schedule.id as string)}/held-times?${params.toString()}`, { organizationId }, ); if (!Array.isArray(answer?.held)) throw new Error('Held times were not read.'); return answer.held; }, [get]); // A hold made for an action is sent only while that action still is, and cancelled with it. const createSlot = useCallback(async (slot: Slot, organizationId?: string, transport?: SaveTransport): Promise => { return post(`${API_URL}/Slot`, slot, { organizationId, ...(transport ? { admit: transport.admit, signal: transport.signal } : {}) }); }, [post]); // Names the version the edit was made against; a refusal is a RecordChangedError and nothing was saved. const updateSlot = useCallback(async (slot: Slot, basedOn: string): Promise => { try { return await put(`${API_URL}/Slot/${slot.id}`, slot, { headers: ifMatch(basedOn) }); } catch (error) { throw asRecordChanged(error); } }, [put]); // Gives back one's own unused booking hold; nothing was stored for it, so there is no version to name. const releaseBookingHold = useCallback(async (hold: Slot): Promise => { if (!isBookingHoldId(hold.id)) throw new Error('Only a booking hold can be given back.'); return put(`${API_URL}/Slot/${hold.id}`, { ...hold, status: 'free' }); }, [put]); // deleteSlot is gone (-07-27). DELETE /R4/Slot was removed from the API: it had no // entry in routes.ts, so it inherited the authorizer's Admins-only fallback and 403'd every // non-admin caller - silently, because the server already cleaned up the slots the client was // trying to delete. To stop a slot holding a time, mark it `free` via updateSlot; the reschedule // and booking-rollback paths now do that server-side and need nothing from the client. const fetchAppointmentForSlot = useCallback((slot: Slot): Promise> => { let params = new URLSearchParams(); params.append('slot', slot.id as string); // SEARCH-BOUNDED: the filter is ONE slot id - a single time block on a single practitioner's // schedule. Normally that is one appointment; overbooking makes it a few. The bound is // structural (how many bookings can share one time), not a guess about practice volume. return get>(`${API_URL}/Appointment?${params.toString()}`); }, []); const transformToFhirSlot = useCallback((item: any, schedule: Schedule): Slot => { const { status, start, end, overbooked, comment } = item; const { id, serviceCategory, serviceType, specialty } = schedule; return { resourceType: 'Slot', schedule: createReferenceObject('Schedule', id as string), ...(serviceCategory && serviceCategory.length !== 0 && { serviceCategory: serviceCategory }), ...(serviceType && serviceType.length !== 0 && { serviceType: serviceType }), ...(specialty && specialty.length !== 0 && { specialty: specialty }), ...(comment && { comment: comment }), status: status, start: formatToFhirInstant(start), end: formatToFhirInstant(end), overbooked: overbooked }; }, [formatToFhirInstant]); const transformFromFhirSlot = useCallback((slot: Slot) => { const { status, start, end, overbooked, comment } = slot; return { status: status, 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) : '', comment: comment ?? '', overbooked: overbooked ?? false }; }, []); const transformSlotToFullCalendarEvent = useCallback((slot: Slot) => { return { id: slot.id, title: slot.status.includes('busy') ? 'Not Available' : 'Available', start: slot.start, end: slot.end, backgroundColor: slot.status.includes('busy') ? '#dc3545' : '#28a745', borderColor: slot.status.includes('busy') ? '#dc3545' : '#28a745', editable: !slot.status.includes('busy'), interactive: !slot.status.includes('busy'), extendedProps: { type: 'slot', status: slot.status } }; }, []); // // THE PRACTICE'S CLOCK, NOT THE VIEWER'S. // // `availableTime.availableStartTime` is a FHIR `time` - "09:00", a wall clock with NO zone, which // is correct FHIR. Something has to decide WHICH 09:00, and until this change the answer was // `Date.setHours(...)`: 09:00 in whatever zone the person looking happened to be in. // // A California patient was therefore offered an Iowa practice's hours as 09:00-17:00 PACIFIC, and a // 4:30 PM pick landed at 6:30 PM Central - after the practice had closed. The availability check // could not catch it because it validated against windows built in the same wrong basis. Every // internal comparison agreed; that is exactly why nothing detected it. // // THE CALENDAR DAY MOVED TOO, not just the times. The old loop asked `date.getDay()` in the // viewer's zone, so at 23:00 Pacific a schedule's "Monday" hours could be generated against the // practice's Tuesday. Both halves are fixed here: dates are iterated as the PRACTICE's calendar // days, and each window is built from that date plus the wall clock IN the practice's zone. // const generateAvailabilityEvents = useCallback((practitionerRole: any, schedule: Schedule, timeZone?: string | null) => { if (!practitionerRole.availableTime || practitionerRole.availableTime.length === 0) { return []; } const events: any[] = []; const planningHorizon = schedule?.planningHorizon; if (!planningHorizon || !planningHorizon.start || !planningHorizon.end) { return []; } // The horizon is read as calendar dates where it is used - both the slot loop and the leave // band go through `fhirCalendarDate`, so no instant is parsed from it here. Bound to locals // past the guard above so the narrowing survives into the closures below. const horizonStartValue = planningHorizon.start; const horizonEndValue = planningHorizon.end; const scheduleBreaks = getScheduleBreaks(schedule); // FALLING BACK TO THE VIEWER'S ZONE IS THE OLD (WRONG) BEHAVIOUR, KEPT ONLY SO AN ORG THAT // HAS NOT SET A TIMEZONE STILL HAS A WORKING BOOKING SCREEN. Refusing outright would take // booking down for them, which is worse than the error it prevents. It is warned about rather // than silent, because "quietly wrong" is the whole defect being removed here. // THIS USED TO READ "org setup already prompts for a timezone (hasOrganizationTimezone is in // its completeness check), so this path should be rare and shrinking." BOTH HALVES WERE FALSE, // and the sentence was load-bearing: it is the only reason a silent fallback reads as acceptable. // - `hasOrganizationTimezone` EXISTS but is called by nothing. `getOrganizationSetupMissingItems` // tests name, main contact and primary address - timezone is not in it. // - So this path is not rare and not shrinking. It is the DEFAULT. Measured 2026-08-21: our main // staging practice has no timezone on the Organization and none on any of its three Locations, // and this warning fires ~30 times per page load. // The fallback itself stays - taking booking down is still worse. What is gone is the false // reassurance. Whether a timezone becomes REQUIRED at org setup is a product decision, carded. const zone = timeZone ?? viewerTimeZone(); if (!timeZone) { console.warn( 'SCHEDULE_AVAILABILITY_NO_TIMEZONE: no Location or Organization timezone was resolved, ' + `so this schedule's hours are being read in the viewer's zone (${zone}). A viewer in a ` + 'different timezone from the practice will be offered — and can book — the wrong times.', ); } // Not-available periods (e.g. time off) override scheduled availability for those days. // // AS CALENDAR DATES, THROUGH `fhirCalendarDate` - the same fix the planning horizon below // already carries, and for the same reason. Leave is authored in this product as two FHIR // `date`s with no zone; `new Date('2026-09-18')` makes that UTC midnight, so in every zone // west of UTC the last day's own morning fell AFTER the end instant and stayed bookable. // // Measured at the desk 2026-09-07, practice zone America/Chicago: leave entered 14-18 // September disabled Mon 14 and Wed 16 and left **Fri 18 offered**. Somebody who books a // week off can be booked on the day they named as their last. // // The end is INCLUSIVE of its whole day, which is what a person entering a date means. A // range authored as datetimes still blocks every day it touches, exactly as before - this // reads the practice's calendar date out of them rather than the viewer's. const notAvailableRanges = (practitionerRole.notAvailable ?? []) .map((na) => ({ start: na.during?.start ? fhirCalendarDate(na.during.start, zone) : undefined, end: na.during?.end ? fhirCalendarDate(na.during.end, zone) : undefined, })) .filter((r): r is { start: string; end: string } => Boolean(r.start && r.end)); const dayCodeToIndex: Record = { 'sun': 0, 'mon': 1, 'tue': 2, 'wed': 3, 'thu': 4, 'fri': 5, 'sat': 6 }; // Iterate the PRACTICE's calendar days, not the viewer's - see the header. // // THROUGH `fhirCalendarDate`, NOT `calendarDateInZone(new Date(...))`. A planning horizon is // authored as a FHIR `date`, which has no zone at all; parsing it as an instant put UTC // midnight on it and every zone west of UTC then read the horizon a day early at both ends. // The visible cost was the LAST day: a block ending on a Saturday generated nothing for that // Saturday, so a schedule's final working day could not be booked by anybody. const firstDate = fhirCalendarDate(planningHorizon.start, zone); const lastDate = fhirCalendarDate(planningHorizon.end, zone); for (let dateStr = firstDate; dateStr <= lastDate; dateStr = addCalendarDays(dateStr, 1)) { const dayOfWeek = dayOfWeekOf(dateStr); const dayCode = Object.keys(dayCodeToIndex).find(key => dayCodeToIndex[key] === dayOfWeek); if (!dayCode) continue; // Lexicographic on `YYYY-MM-DD` is chronological, and no instant is involved - which is // why the practice's midnight-to-midnight bounds this used to build are gone with it. if (notAvailableRanges.some((r) => dateStr >= r.start && dateStr <= r.end)) { continue; } const availableTimeForDay = practitionerRole.availableTime.filter( time => time.daysOfWeek?.includes(dayCode) && time.available === true ); if (availableTimeForDay.length === 0) continue; availableTimeForDay.forEach(availableTime => { // THE ONE-LINE HEART OF THE FIX. Was `new Date(date); d.setHours(h, m)` - the // viewer's clock. Now the wall clock is resolved IN the practice's zone, so "09:00" // means the practice's 09:00 no matter who is looking. const mk = (h: number, m: number) => zonedWallClockToInstant( dateStr, `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`, zone, ); let windowStart: Date | undefined; let windowEnd: Date | undefined; if (availableTime.allDay) { windowStart = mk(0, 0); // The practice's next midnight, which is the honest end of an all-day window and // is NOT "start + 24h" on a DST changeover day. windowEnd = zonedWallClockToInstant(addCalendarDays(dateStr, 1), '00:00', zone); } else if (availableTime.availableStartTime && availableTime.availableEndTime) { const [startHours, startMinutes] = availableTime.availableStartTime.split(':').map(Number); const [endHours, endMinutes] = availableTime.availableEndTime.split(':').map(Number); windowStart = mk(startHours, startMinutes); windowEnd = mk(endHours, endMinutes); } if (!windowStart || !windowEnd) return; // Recurring breaks apply to every working day; subtract them so break time is neither bookable nor // shown as available. The remaining sub-windows become the availability events. const breakIntervals = scheduleBreaks .map((b) => { const [bsH, bsM] = b.startTime.split(':').map(Number); const [beH, beM] = b.endTime.split(':').map(Number); return { start: mk(bsH, bsM), end: mk(beH, beM) }; }) .filter((iv) => iv.start < iv.end); subtractBreaksFromWindow(windowStart, windowEnd, breakIntervals).forEach((seg) => { events.push({ title: 'Available', start: seg.start, end: seg.end, display: 'background', backgroundColor: '#d4edda', extendedProps: { type: 'availability' } }); }); }); } if (practitionerRole.notAvailable && practitionerRole.notAvailable.length > 0) { practitionerRole.notAvailable.forEach(notAvailable => { if (!notAvailable.during || !notAvailable.during.start || !notAvailable.during.end) { return; } // THE SAME CALENDAR-DATE READING THE SLOT LOOP ABOVE USES, and it is here because // grepping the pattern rather than the symptom found this one: `new Date` on a FHIR // `date` put UTC midnight on both ends, so west of UTC the band was drawn five hours // late and STOPPED at the start of its last day instead of covering it. The leave // painted on the calendar was a day short of the leave the person entered. // // End-exclusive at the day AFTER the last one, because that is what a calendar // background event means by a whole day and what makes the last day look taken. const leaveFirst = fhirCalendarDate(notAvailable.during.start, zone); const leaveLast = fhirCalendarDate(notAvailable.during.end, zone); const horizonFirst = fhirCalendarDate(horizonStartValue, zone); const horizonLast = fhirCalendarDate(horizonEndValue, zone); const firstShown = leaveFirst > horizonFirst ? leaveFirst : horizonFirst; const lastShown = leaveLast < horizonLast ? leaveLast : horizonLast; const effectiveStart = zonedWallClockToInstant(firstShown, '00:00', zone); const effectiveEnd = zonedWallClockToInstant(addCalendarDays(lastShown, 1), '00:00', zone); if (firstShown <= lastShown) { events.push({ title: notAvailable.description || 'Not Available', start: effectiveStart, end: effectiveEnd, display: 'background', backgroundColor: '#f8d7da', extendedProps: { type: 'not-available' } }); } }); } return events; }, []); // Availability calendar events sourced from the Schedule's own weekly hours (the new model). The stored // FHIR-shaped hours are run back through the same form transforms the role path uses so the shape matches // generateAvailabilityEvents exactly. Falls back to the practitioner role's (already form-shaped) hours for // schedules saved before availability moved onto the Schedule. const getScheduleAvailabilityEvents = useCallback((schedule: Schedule, fallbackFormRole?: any, site?: Location | null, organization?: Organization) => { const { availableTime, notAvailable } = getScheduleAvailability(schedule); const source = availableTime.length > 0 ? { availableTime: transformFromOperationalHours(availableTime), notAvailable: transformFromOperationalHoursNotAvailable(notAvailable), } : fallbackFormRole; if (!source) return []; // The clock these hours are written in: the SITE's, else the organization's. // // RESOLVED HERE RATHER THAN AT SIX CALL SITES, so every existing caller improves from "the // viewer's zone" to "the organization's zone" without being touched - which is already correct // for a single-site practice, the common case. `site` is the override that matters for a // multi-site org, and for telehealth the caller passes the PHYSICAL site (a virtual Location // has no clock of its own). const timeZone = resolveBookingTimeZone({ site, organization: organization ?? wpOrganization.current ?? undefined }); return generateAvailabilityEvents(source, schedule, timeZone); }, [generateAvailabilityEvents, transformFromOperationalHours, transformFromOperationalHoursNotAvailable, wpOrganization]); const getScheduleDisplay = (schedule: Schedule): string => { if (!schedule) { return 'Unknown Schedule'; } const primaryActor = schedule.actor && schedule.actor.length > 0 ? schedule.actor[0].display || schedule.actor[0].reference?.split('/')[1] || 'Unknown Actor' : 'Unknown Actor'; const status = schedule.active !== false ? ' [Active]' : ' [Inactive]'; let dateSuffix = ''; if (schedule.planningHorizon) { dateSuffix = ` (${formatFhirPeriod(schedule.planningHorizon)})`; } return `${primaryActor}${status}${dateSuffix}`; }; return { scheduleState, setScheduleState, shouldReload, setShouldReload, lastUpserted, setLastUpserted, unsaved, setUnsaved, schedulesBundle, schedulesList, setSchedulesList, fetchSchedules, fetchSchedule, fetchSchedulesWithPolling, createSchedule, updateSchedule, deleteSchedule, fetchSlots, fetchHeldTimes, createSlot, updateSlot, releaseBookingHold, fetchAppointmentForSlot, transformToFhirSlot, transformFromFhirSlot, transformSlotToFullCalendarEvent, generateAvailabilityEvents, getScheduleAvailabilityEvents, transformToFhirSchedule, transformFromFhirSchedule, getScheduleDisplay }; }; const ScheduleServiceContext = createContext | undefined>(undefined); export const ScheduleServiceProvider: React.FC<{ children: ReactNode }> = ({ children }) => { const scheduleService = useScheduleService(); return ( {children} ); }; export const useScheduleServiceContext = () => { const context = useContext(ScheduleServiceContext); if (context === undefined) { throw new Error('useScheduleServiceContext must be used within a ScheduleServiceProvider'); } return context; };