import { isAsOfIncomplete } from '../utils/searchTruncation'; import { useEffect, useState, useCallback, useRef } from 'react'; import { nextReadBackDue, onReadBackChange, readBacksGivenUp, writtenSince } from '../utils/writeThroughHold'; import { usePatientServiceContext, useQuestionnaireResponseServiceContext } from '../services'; import { describeFetchFailure } from '../utils/fetchFailureMessage'; import { patientCacheGuard } from '../utils/cacheOwner'; interface UseFhirQuestionnaireResponsesOptions { autoLoad?: boolean; errorMessage?: string; forceReload?: boolean; } interface UseFhirQuestionnaireResponsesReturn { /** 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; loadQuestionnaireResponses: () => Promise; /** Answers this patient was given that could not be read back, and are no longer asked for on their own. */ answersNotReadBack: number; /** Asks once more for those answers, and for the list. */ retryAnswersNotReadBack: () => Promise; clearError: () => void; questionnaireResponsesBundle: ReturnType['questionnaireResponsesBundle']; questionnaireResponsesList: ReturnType['questionnaireResponsesList']; } export const useFhirQuestionnaireResponses = (options: UseFhirQuestionnaireResponsesOptions = {}): UseFhirQuestionnaireResponsesReturn => { const { autoLoad = true, errorMessage = "Failed to load questionnaire responses", forceReload = false } = options; const { patientState } = usePatientServiceContext(); const { cachedForPatientId, fetchQuestionnaireResponsesWithPolling, questionnaireResponsesBundle, questionnaireResponsesList } = useQuestionnaireResponseServiceContext(); const [loading, setLoading] = useState(false); const [error, setError] = useState(); const loadedAtRef = useRef(0); // Counts finished loads, so the next read-back is scheduled after each one, whether or not it changed the list. const [loadsDone, setLoadsDone] = useState(0); // Counts read backs settled by any list sharing these answers: one this list did not start, or whose own load // was superseded, still changes what this list should show or when it should ask next. const [readBacksSettled, setReadBacksSettled] = useState(0); // Counts answers of this patient read back by any list. Each is one forced reload here, because the cached list // cannot show it and nothing about the clocks may be relied on to say so. const [answersRecovered, setAnswersRecovered] = useState(0); const patientIdRef = useRef(patientState?.id); patientIdRef.current = patientState?.id; useEffect(() => onReadBackChange((resourceType, event) => { if (resourceType !== 'QuestionnaireResponse') return; if (event.read && (event.scope === undefined || event.scope === patientIdRef.current)) setAnswersRecovered((recovered) => recovered + 1); else setReadBacksSettled((settled) => settled + 1); }), []); // Only the newest load of a view still shown reports how it went: one for a patient this view has moved on // from neither ends the next load's wait nor shows its error. const latestLoadRef = useRef(0); const mountedRef = useRef(true); useEffect(() => { mountedRef.current = true; return () => { mountedRef.current = false; }; }, []); const load = useCallback(async (deliberate: boolean, force = deliberate) => { if (!patientState) { return; } const request = ++latestLoadRef.current; const current = () => mountedRef.current && request === latestLoadRef.current; setLoading(true); setError(undefined); try { // Stamped as the read starts, so the re-render it causes does not ask a second time. loadedAtRef.current = Date.now(); await fetchQuestionnaireResponsesWithPolling(patientState, undefined, force, deliberate); } catch (err) { if (!current()) return; console.error('Failed to load questionnaire responses:', err); setError(describeFetchFailure(err, errorMessage)); } finally { if (current()) { setLoading(false); setLoadsDone((done) => done + 1); } } }, [patientState, fetchQuestionnaireResponsesWithPolling, errorMessage]); const loadQuestionnaireResponses = useCallback(() => load(false), [load]); const retryAnswersNotReadBack = useCallback(() => load(true), [load]); const clearError = useCallback(() => { setError(undefined); }, []); 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. // An answer written since this view last loaded (an attached form) is a reason to ask again. const answerWritten = writtenSince('QuestionnaireResponse', loadedAtRef.current); if (!questionnaireResponsesBundle || cachedForPatientId !== patientState.id || forceReload || answerWritten) { loadQuestionnaireResponses(); } }, [autoLoad, patientState, questionnaireResponsesBundle, cachedForPatientId, forceReload, loadQuestionnaireResponses, readBacksSettled]); useEffect(() => { if (!answersRecovered || !autoLoad || !patientState) return; load(false, true); }, [answersRecovered]); // eslint-disable-line react-hooks/exhaustive-deps // An attached answer that could not be read back yet is asked for again when it is due, not on every render. useEffect(() => { if (!autoLoad || !patientState) return; const due = nextReadBackDue('QuestionnaireResponse', patientState.id); if (due === undefined) return; const timer = setTimeout(loadQuestionnaireResponses, Math.max(0, due - Date.now())); return () => clearTimeout(timer); }, [autoLoad, patientState, loadsDone, readBacksSettled, loadQuestionnaireResponses]); // 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, // 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), loadQuestionnaireResponses, answersNotReadBack: patientState ? readBacksGivenUp('QuestionnaireResponse', patientState.id).length : 0, retryAnswersNotReadBack, clearError, // Served as of a cutoff and not in full: what it left out is unknown, never none. incomplete: isAsOfIncomplete(guard.bundle(questionnaireResponsesBundle, null)), questionnaireResponsesBundle: guard.bundle(questionnaireResponsesBundle, null), questionnaireResponsesList: guard.list(questionnaireResponsesList) }; };