import { isAsOfIncomplete } from '../utils/searchTruncation'; import { useEffect, useState, useCallback, useRef } from 'react'; import { usePatientServiceContext, useObservationServiceContext, useFhirServiceContext } from '../services'; import { Observation, Bundle } from 'fhir/r4'; import { describeFetchFailure, isRefusedRead } from '../utils/fetchFailureMessage'; import { patientCacheGuard } from '../utils/cacheOwner'; interface UseFhirObservationsOptions { autoLoad?: boolean; errorMessage?: string; forceReload?: boolean; /** A view showing values that may have been corrected re-reads this patient's cached list once on opening. */ refreshOnOpen?: boolean; } interface UseFhirObservationsReturn { /** Served as of a cutoff and not in full: what it left out is unknown, never none. */ incomplete: boolean; loading: boolean; error: string | undefined; /** The last load was answered with a 403: refused, not lost. */ refused: boolean; /** The error and refusal are this patient's read, not an earlier patient's. */ failureIsThisPatients: boolean; /** The service's list is this patient's. Anything else is another patient's, never this one's data. */ listIsThisPatients: boolean; loadObservations: () => Promise; refreshObservations: () => Promise; clearError: () => void; observationsBundle: ReturnType['observationsBundle']; observationsList: ReturnType['observationsList']; tempObservationsBundle: Bundle | null; tempObservationsList: Observation[]; loadTempObservations: () => Promise; clearTempObservations: () => void; } export const useFhirObservations = (options: UseFhirObservationsOptions = {}): UseFhirObservationsReturn => { const { autoLoad = true, errorMessage = 'Failed to load observations', forceReload = false, refreshOnOpen = false } = options; const { patientState, tempPatientState } = usePatientServiceContext(); const { fetchObservationsWithPolling, fetchObservations, observationsBundle, observationsList, cachedForPatientId } = useObservationServiceContext(); const { unpackBundle } = useFhirServiceContext(); const [loading, setLoading] = useState(false); const [error, setError] = useState(); const [refused, setRefused] = useState(false); const [failedFor, setFailedFor] = useState(); // The latest patient asked for; a slower read for an earlier patient must not write over it. const askingFor = useRef(undefined); const readSinceOpened = useRef(false); const [tempObservationsBundle, setTempObservationsBundle] = useState | null>(null); const [tempObservationsList, setTempObservationsList] = useState([]); const read = useCallback(async (quiet: boolean) => { if (!patientState) { return; } if (!quiet) { setLoading(true); setError(undefined); setRefused(false); } askingFor.current = patientState.id; try { await fetchObservationsWithPolling(patientState, quiet); if (askingFor.current !== patientState.id) return; setError(undefined); setRefused(false); } catch (err) { if (askingFor.current !== patientState.id) return; console.error('Failed to load observations:', err); setFailedFor(patientState.id); setRefused(isRefusedRead(err)); setError(describeFetchFailure(err, errorMessage)); } finally { if (!quiet && askingFor.current === patientState.id) setLoading(false); } }, [patientState, fetchObservationsWithPolling, errorMessage]); const loadObservations = useCallback(() => read(false), [read]); const refreshObservations = useCallback(() => read(true), [read]); const loadTempObservations = useCallback(async () => { if (!tempPatientState) { return; } setLoading(true); setError(undefined); try { const bundle = await fetchObservations(tempPatientState); setTempObservationsBundle(bundle); setTempObservationsList(unpackBundle(bundle)); } catch (err) { console.error('Failed to load temporary observations:', err); setError(describeFetchFailure(err, `${errorMessage} for selected patient`)); } finally { setLoading(false); } }, [tempPatientState, fetchObservations, errorMessage]); const clearError = useCallback(() => { setError(undefined); setRefused(false); }, []); const clearTempObservations = useCallback(() => { setTempObservationsBundle(null); setTempObservationsList([]); }, []); useEffect(() => { if (!autoLoad) return; if (!patientState) return; // A bundle belonging to SOMEBODY ELSE is not a reason not to ask. This used to fire only when // there was no bundle at all, so moving between two charts left the previous patient's results // in place, with no request and nothing on screen to say they were not theirs. // NOT a refetch on every navigation: same patient, bundle loaded, no request. if (!observationsBundle || cachedForPatientId !== patientState.id || forceReload) { readSinceOpened.current = true; loadObservations(); return; } if (!refreshOnOpen || readSinceOpened.current) return; readSinceOpened.current = true; refreshObservations(); }, [autoLoad, patientState, observationsBundle, cachedForPatientId, forceReload, refreshOnOpen, loadObservations, refreshObservations]); useEffect(() => { if (tempPatientState) { loadTempObservations(); } else { clearTempObservations(); } }, [tempPatientState, loadTempObservations, clearTempObservations]); // 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) || refused), error, refused, failureIsThisPatients: failedFor !== undefined && failedFor === patientState?.id, listIsThisPatients: cachedForPatientId !== undefined && cachedForPatientId === patientState?.id, loadObservations, refreshObservations, clearError, // Served as of a cutoff and not in full: what it left out is unknown, never none. incomplete: isAsOfIncomplete(guard.bundle(observationsBundle, null)), observationsBundle: guard.bundle(observationsBundle, null), observationsList: guard.list(observationsList), tempObservationsBundle, tempObservationsList, loadTempObservations, clearTempObservations }; };