import { useChartRecordStanding, useFreshAfterReauthorization, type RecordAccessStanding, type RecordFreshness } from './useRecordAccessStanding'; import { isAsOfIncomplete, isServedAtCutoff } from '../utils/searchTruncation'; import { useEffect, useState, useCallback, useRef } from 'react'; import { usePatientServiceContext, useConditionServiceContext, useFhirServiceContext } from '../services'; import { Condition, Bundle } from 'fhir/r4'; import { describeFetchFailure, isRefusedRead } from '../utils/fetchFailureMessage'; import { patientCacheGuard } from '../utils/cacheOwner'; interface UseFhirConditionsOptions { /** The record standing to answer to; the open chart's when not given. */ standing?: RecordAccessStanding; autoLoad?: boolean; errorMessage?: string; forceReload?: boolean; } interface UseFhirConditionsReturn { /** After a reauthorization, whether this list has been read again under it. */ freshness: RecordFreshness; /** Served as of a cutoff and not in full: what it left out is unknown, never none. */ incomplete: boolean; loading: boolean; error: string | undefined; /** * - was the server actually asked? * * This hook early-returns while `patientState` is unresolved, so `setLoading(true)` never runs and * the effect never fires. The caller is then holding `loading: false` - `error: undefined` - an * empty list - **indistinguishable from a load that succeeded and found nothing** - and * `ResourceListStatus` says *"No X found."* about a question nobody asked. * Only the hook knows which of the two it is in, so it reports it. */ attempted: boolean; /** The service's list is this patient's. Anything else is another patient's, never this one's data. */ listIsThisPatients: boolean; answered: boolean; /** The last load was answered with a 403: refused, not lost. */ refused: boolean; /** The failure belongs to the patient now in context; another patient's failure is not this one's. */ failureIsThisPatients: boolean; loadConditions: () => Promise; clearError: () => void; conditionsBundle: ReturnType['conditionsBundle']; conditionsList: ReturnType['conditionsList']; tempConditionsBundle: Bundle | null; tempConditionsList: Condition[]; loadTempConditions: () => Promise; clearTempConditions: () => void; } export const useFhirConditions = (options: UseFhirConditionsOptions = {}): UseFhirConditionsReturn => { const { autoLoad = true, errorMessage = 'Failed to load conditions', forceReload = false } = options; const { patientState, tempPatientState } = usePatientServiceContext(); const { fetchConditionsWithPolling, listRead, fetchConditions, conditionsBundle, conditionsList, cachedForPatientId } = useConditionServiceContext(); // After a reauthorization this list is read again past every cache; until then it is not taken for current. const chartStanding = useChartRecordStanding().standing; const freshness = useFreshAfterReauthorization(options.standing ?? chartStanding, () => ( patientState ? fetchConditionsWithPolling(patientState, true) : Promise.reject(new Error('No patient to read.'))), { resource: 'Condition', patientId: patientState?.id, listRead, servedAtCutoff: isServedAtCutoff(conditionsBundle) }); 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 [tempConditionsBundle, setTempConditionsBundle] = useState | null>(null); const [tempConditionsList, setTempConditionsList] = useState([]); const loadConditions = useCallback(async () => { if (!patientState) { return; } setLoading(true); setError(undefined); setRefused(false); askingFor.current = patientState.id; try { await fetchConditionsWithPolling(patientState); } catch (err) { if (askingFor.current !== patientState.id) return; console.error('Failed to load conditions:', err); setFailedFor(patientState.id); setRefused(isRefusedRead(err)); setError(describeFetchFailure(err, errorMessage)); } finally { if (askingFor.current === patientState.id) setLoading(false); } }, [patientState, fetchConditionsWithPolling, errorMessage]); const loadTempConditions = useCallback(async () => { if (!tempPatientState) { return; } setLoading(true); setError(undefined); try { const bundle = await fetchConditions(tempPatientState); setTempConditionsBundle(bundle); setTempConditionsList(unpackBundle(bundle)); } catch (err) { console.error('Failed to load conditions:', err); setError(describeFetchFailure(err, `${errorMessage} for selected patient`)); } finally { setLoading(false); } }, [tempPatientState, fetchConditions, errorMessage]); const clearError = useCallback(() => { setError(undefined); }, []); const clearTempConditions = useCallback(() => { setTempConditionsBundle(null); setTempConditionsList([]); }, []); /** * A BUNDLE BELONGING TO SOMEBODY ELSE IS NOT A REASON NOT TO ASK. * * This used to auto-load on `!conditionsBundle || forceReload`. Moving between two patients' charts * leaves a bundle in place - the PREVIOUS patient's - so the effect stayed quiet, no request went * out, and the clinician read the wrong problem list with nothing on screen to say so. * * The service now records who its cache is for, so this asks the same question the encounter panel * asks at render time: does this list belong to the patient I am on? * * NOT a refetch on every navigation. Same patient, bundle already loaded, no request - which is * what keeps a correct-and-current list from flashing a spinner. */ const cachedListIsForThisPatient = cachedForPatientId === patientState?.id; useEffect(() => { if (!autoLoad) return; if (!patientState) return; if (!conditionsBundle || !cachedListIsForThisPatient || forceReload) { loadConditions(); } }, [autoLoad, patientState, conditionsBundle, cachedListIsForThisPatient, forceReload, loadConditions]); useEffect(() => { if (tempPatientState) { loadTempConditions(); } else { clearTempConditions(); } }, [tempPatientState, loadTempConditions, clearTempConditions]); // The cache outlives a change of patient: hand out only the patient asked about. const guard = patientCacheGuard(cachedForPatientId, patientState?.id); return { freshness, loading: guard.loading(loading, Boolean(error) || refused), error, // Derived from the SAME condition the early returns above test, never tracked separately: a // hand-set flag goes stale the first time somebody adds a third reason to bail, and silently. attempted: Boolean(patientState), listIsThisPatients: cachedForPatientId !== undefined && cachedForPatientId === patientState?.id, /** This patient's read has answered, one way or the other. Until then an empty list is unknown. */ answered: (Boolean(error) && failedFor === patientState?.id) || (Boolean(conditionsBundle) && cachedListIsForThisPatient), refused: refused && failedFor === patientState?.id, failureIsThisPatients: failedFor !== undefined && failedFor === patientState?.id, loadConditions, clearError, // Served as of a cutoff and not in full: what it left out is unknown, never none. incomplete: isAsOfIncomplete(guard.bundle(conditionsBundle, null)), conditionsBundle: guard.bundle(conditionsBundle, null), conditionsList: guard.list(conditionsList), tempConditionsBundle, tempConditionsList, loadTempConditions, clearTempConditions }; };