import { isAsOfIncomplete } from '../utils/searchTruncation'; import { useEffect, useState, useCallback, useRef } from 'react'; import { usePatientServiceContext, useDiagnosticReportServiceContext } from '../services'; import { describeFetchFailure, isRefusedRead } from '../utils/fetchFailureMessage'; import { patientCacheGuard } from '../utils/cacheOwner'; /** * THE RESULTS HALF OF *My Visits & Results* -. * * Deliberately the SAME SHAPE as its eleven siblings (`useFhirObservations`, `useFhirConditions`,...) * down to the `attempted` flag and its reasoning. The service and its provider have been mounted all * along; only the hook was missing, which is why the stream could carry visits and prescriptions and * not the results the screen is half named after. */ interface UseFhirDiagnosticReportsOptions { autoLoad?: boolean; errorMessage?: string; forceReload?: boolean; } interface UseFhirDiagnosticReportsReturn { /** 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; /** * - 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 would say * *"no results"* about a question nobody asked. * Only the hook knows which of the two it is in, so it reports it. */ attempted: boolean; loadDiagnosticReports: () => Promise; /** Re-reads past the cache without a loading state; a failure is reported like a load's. */ refreshDiagnosticReports: () => Promise; clearError: () => void; diagnosticReportsBundle: ReturnType['diagnosticReportsBundle']; diagnosticReportsList: ReturnType['diagnosticReportsList']; } export const useFhirDiagnosticReports = ( options: UseFhirDiagnosticReportsOptions = {}, ): UseFhirDiagnosticReportsReturn => { const { autoLoad = true, errorMessage = 'Failed to load results', forceReload = false } = options; const { patientState } = usePatientServiceContext(); const { cachedForPatientId, fetchDiagnosticReportsWithPolling, diagnosticReportsBundle, diagnosticReportsList } = useDiagnosticReportServiceContext(); 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); // Whether this view has read the list since it opened, by a load or by the quiet re-read below. const readSinceOpened = useRef(false); const read = useCallback(async (quiet: boolean) => { if (!patientState) { return; } if (!quiet) { setLoading(true); setError(undefined); setRefused(false); } askingFor.current = patientState.id; try { await fetchDiagnosticReportsWithPolling(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 diagnostic reports:', err); setFailedFor(patientState.id); setRefused(isRefusedRead(err)); setError(describeFetchFailure(err, errorMessage)); } finally { if (!quiet && askingFor.current === patientState.id) setLoading(false); } }, [patientState, fetchDiagnosticReportsWithPolling, errorMessage]); const loadDiagnosticReports = useCallback(() => read(false), [read]); const refreshDiagnosticReports = useCallback(() => read(true), [read]); const clearError = useCallback(() => { setError(undefined); setRefused(false); }, []); 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 list in // place, with no request and nothing on screen to say it was not theirs. // NOT a refetch on every navigation: same patient, bundle loaded, no request. if (!diagnosticReportsBundle || cachedForPatientId !== patientState.id || forceReload) { readSinceOpened.current = true; loadDiagnosticReports(); return; } // A view opening on this patient's cached list re-reads it once, without a loading state, so a // result corrected since the list loaded shows the next time a results view opens. if (readSinceOpened.current) return; readSinceOpened.current = true; refreshDiagnosticReports(); }, [autoLoad, patientState, diagnosticReportsBundle, cachedForPatientId, forceReload, loadDiagnosticReports, refreshDiagnosticReports]); // 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, // 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), loadDiagnosticReports, refreshDiagnosticReports, clearError, // Served as of a cutoff and not in full: what it left out is unknown, never none. incomplete: isAsOfIncomplete(guard.bundle(diagnosticReportsBundle, null)), diagnosticReportsBundle: guard.bundle(diagnosticReportsBundle, null), diagnosticReportsList: guard.list(diagnosticReportsList) }; };