import { isAsOfIncomplete } from '../utils/searchTruncation'; import { useEffect, useState, useCallback } from 'react'; import { usePatientServiceContext, useAppointmentServiceContext } from '../services'; import { describeFetchFailure } from '../utils/fetchFailureMessage'; import { patientCacheGuard } from '../utils/cacheOwner'; interface UseFhirAppointmentsOptions { autoLoad?: boolean; errorMessage?: string; forceReload?: boolean; } interface UseFhirAppointmentsReturn { /** Served as of a cutoff and not in full: what it left out is unknown, never none. */ incomplete: boolean; loading: boolean; error: string | undefined; /** A different reader may have failed while the previous bundle remains cached. */ refreshFailed: boolean; stale: boolean; /** This patient's read has answered, one way or the other. Until then an empty list is unknown. */ answered: boolean; /** The cached list belongs to the current patient; otherwise it is another chart's and must not be shown. */ listIsThisPatients: boolean; loadAppointments: () => Promise; clearError: () => void; appointmentsBundle: ReturnType['appointmentsBundle']; appointmentsList: ReturnType['appointmentsList']; } export const useFhirAppointments = (options: UseFhirAppointmentsOptions = {}): UseFhirAppointmentsReturn => { const { autoLoad = true, errorMessage = "Failed to load appointments", forceReload = false } = options; const { patientState } = usePatientServiceContext(); const { cachedForPatientId, fetchAppointmentsWithPolling, appointmentsBundle, appointmentsList, appointmentsFailedFor, shouldReload } = useAppointmentServiceContext(); const [loading, setLoading] = useState(false); const [error, setError] = useState(); const loadAppointments = useCallback(async () => { if (!patientState) { return; } setLoading(true); setError(undefined); try { await fetchAppointmentsWithPolling(patientState); } catch (err) { console.error('Failed to load appointments:', err); setError(describeFetchFailure(err, errorMessage)); } finally { setLoading(false); } }, [patientState, fetchAppointmentsWithPolling, errorMessage]); const clearError = useCallback(() => { setError(undefined); }, []); /** * INVALIDATING A CACHE DOES NOT FETCH ANYTHING. SOMETHING HAS TO ASK AGAIN. * * This condition used to be `!appointmentsBundle || forceReload` - it fired only when there was NO * bundle at all. After a booking there IS one; it is merely stale. So the effect stayed quiet, * `fetchAppointmentsWithPolling` was never called, and the only code that could notice the * invalidation never ran. * * THAT IS WHY THREE SUCCESSIVE FIXES DID NOT SHOW UP ON SCREEN. Each made `markAppointmentsStale` * more correct - the state flag, then the synchronous ref alongside it - and every one of them was * right. None of them made anybody re-ask, so the patient's own portal kept the pre-booking list * until the document reloaded and the bundle started out undefined again. * * MEASURED, on an ordinary patient: home read "1 scheduled appointment - 7 things" straight after * booking, and "2 scheduled appointments - 8 things" after a hard reload, with the same booking. * * `shouldReload` is the service's own state and already on its context, so it re-renders consumers * when it flips - which is what turns the invalidation into a fetch. `fetchAppointmentsWithPolling` * clears it once it has served, so this cannot loop. * * The duplicate-booking guard was never affected and that is the clue that found this: it calls * `fetchAppointments` DIRECTLY, bypassing the cache, so it saw the new appointment while the three * surfaces reading through here did not. * * `cachedForPatientId` covers the OTHER half of the same mistake. Stale-for-this-patient is what the * paragraph above is about; this is a bundle that is perfectly fresh and belongs to SOMEBODY ELSE, * which moving between two charts produces and which no freshness test can see. */ useEffect(() => { if (!autoLoad) return; if (!patientState) return; if (!appointmentsBundle || cachedForPatientId !== patientState.id || shouldReload || forceReload) { loadAppointments(); } }, [autoLoad, patientState, appointmentsBundle, cachedForPatientId, shouldReload, forceReload, loadAppointments]); // The cache outlives a change of patient: hand out only the patient asked about. const guard = patientCacheGuard(cachedForPatientId, patientState?.id); return { loading: guard.loading(loading, Boolean(error)), error, refreshFailed: Boolean(patientState?.id && appointmentsFailedFor === patientState.id), stale: Boolean(shouldReload), answered: Boolean(error) || (Boolean(appointmentsBundle) && cachedForPatientId === patientState?.id), listIsThisPatients: Boolean(appointmentsBundle) && cachedForPatientId === patientState?.id, loadAppointments, clearError, // Served as of a cutoff and not in full: what it left out is unknown, never none. incomplete: isAsOfIncomplete(guard.bundle(appointmentsBundle, null)), appointmentsBundle: guard.bundle(appointmentsBundle, null), appointmentsList: guard.list(appointmentsList) }; };