import type { ReadOptions } from '../hooks/anchorView/readOptions'; import { isHeld, overlayHeldBundle, refersToPatient, writtenSince } from '../utils/writeThroughHold'; import { useState, useCallback, createContext, useContext, ReactNode, useEffect, useRef } from 'react'; import { Bundle, ClinicalImpression, Patient, ClinicalImpressionFinding, ClinicalImpressionInvestigation, Encounter, Practitioner, Reference, CodeableConcept } from 'fhir/r4'; import { environment } from '../environments/environment.ts'; import { useFhirServiceContext } from './FhirService.tsx'; import { useHttpService } from './HttpService.tsx'; import { formatFhirHumanName } from '../utils/fhirHumanName.tsx'; import { useEncounterServiceContext } from './EncounterService.tsx'; import { usePatientServiceContext } from './PatientService.tsx'; import { formatFhirCodeableConcept } from '../utils/fhirCodeableConcept.tsx'; import { formatFhirPeriod } from '../utils/fhirPeriod.tsx'; import { applyArchive, isArchived, type ArchiveReason } from '../utils/archivedResource'; import { statusLabel } from '../utils/fhirStatusVocabulary'; import { fetchAllPagesAsBundle } from '../utils/fetchAllPagesAsBundle'; import { searchPageSize } from '../utils/pageSizeOverride'; import { useCacheOwner } from '../utils/cacheOwner'; import { asRecordChanged, ifMatch, PAGE_OUT_OF_DATE_MESSAGE, RecordChangedError } from '../utils/recordChanged'; import type { SaveTransport } from '../utils/saveOperation'; const API_URL = environment.fhirClinicalApi; export const useClinicalImpressionService = () => { const { get, post, put, delete: del } = useHttpService(); const { patientState } = usePatientServiceContext(); const { transformToCodeableConcept, transformFromCodeableConcept, createReferenceObject, formatDateTime, formatDateTimeForInput, formatDateTimeForDisplay, addFhirPeriodIfApplicable, addPeriodControlsIfApplicable, unpackBundle, transformToReferenceCollection, transformFromReferenceCollection, extractReferenceId } = useFhirServiceContext(); const { getEncounterDisplay } = useEncounterServiceContext(); const [clinicalImpressionsList, setClinicalImpressionsList] = useState([]); const [shouldReload, setShouldReload] = useState(false); const [lastUpserted, setLastUpserted] = useState(null); const [unsaved, setUnsaved] = useState(false); const [clinicalImpressionsBundle, setClinicalImpressionsBundle] = useState | null>(null); const clinicalImpressionsBundleRef = useRef | null>(null); const lastFetchTimeRef = useRef(0); useEffect(() => { clinicalImpressionsBundleRef.current = clinicalImpressionsBundle; }, [clinicalImpressionsBundle]); // WHOSE RECORDS ARE THESE? The reset below fires only when there is NO patient, which moving // between two charts never is - so the previous patient's bundle survived the switch and was // served as a cache hit, with no request and nothing on screen to say the list was not theirs. const cacheOwner = useCacheOwner('ClinicalImpression', () => { setLastUpserted(null); setClinicalImpressionsBundle(null); setClinicalImpressionsList([]); lastFetchTimeRef.current = 0; }); useEffect(() => { if (!patientState) { setClinicalImpressionsBundle(null); setClinicalImpressionsList([]); lastFetchTimeRef.current = 0; cacheOwner.release(); } }, [patientState, cacheOwner.release]); const fetchClinicalImpressions = useCallback(async (patient: Patient, options?: ReadOptions): Promise> => { return overlayHeldBundle('ClinicalImpression', await fetchAllPagesAsBundle((pageParam) => { let params = new URLSearchParams(); params.append('_count', searchPageSize('100')); params.append('subject', patient.id as string); if (pageParam) params.set('page', pageParam); return get>(`${API_URL}/ClinicalImpression?${params.toString()}`, options); }), (resource) => refersToPatient(resource, patient.id)) as Bundle; }, [get]); const fetchClinicalImpressionsWithPolling = useCallback(async (patient: Patient, forceReload = false): Promise> => { const now = Date.now(); const cacheAge = now - lastFetchTimeRef.current; const cacheFresh = cacheAge < 5 * 60 * 1000; // cacheOwner.isFor is the load-bearing clause: a fresh bundle belonging to the PREVIOUS // patient satisfies every other condition here and would be returned as a hit. if (!forceReload && !shouldReload && !writtenSince('ClinicalImpression', lastFetchTimeRef.current) && clinicalImpressionsBundleRef.current && cacheFresh && cacheOwner.isCurrentFor(patient.id)) { return clinicalImpressionsBundleRef.current; } const operation = cacheOwner.begin(); let retries = 10; const delay = 3000; let found = false; let bundle: Bundle | null = null; let entries: ClinicalImpression[]; do { bundle = await fetchClinicalImpressions(patient); entries = unpackBundle(bundle).filter((r) => !isArchived(r)); found = lastUpserted ? entries.some(e => e.id === lastUpserted) || isHeld('ClinicalImpression', lastUpserted) : true; if (!found) { await new Promise(resolve => setTimeout(resolve, delay)); } retries--; } while (!found && retries > 0 && cacheOwner.publishes(operation)); if (!cacheOwner.publishes(operation)) return cacheOwner.answerFor(operation, bundle); setClinicalImpressionsBundle(bundle); setClinicalImpressionsList(entries.filter((r) => !isArchived(r))); setLastUpserted(null); setShouldReload(false); lastFetchTimeRef.current = now; // Stamped WITH the data, never before it - on a throw the owner must still name the list // that is actually in state. cacheOwner.claim(patient.id, operation); return bundle; }, [fetchClinicalImpressions, unpackBundle, lastUpserted, shouldReload]); // `fresh`: read on purpose (Reload, a check after a lost answer), so the list held here is not the answer. const fetchClinicalImpression = useCallback(async (id: string, fresh = false, transport?: SaveTransport): Promise => { const cachedImpression = !fresh && cacheOwner.isCurrent(cacheOwner.claimed) ? clinicalImpressionsList.find(impression => impression.id === id) : undefined; if (cachedImpression) { return cachedImpression; } const read = await get(`${API_URL}/ClinicalImpression/${id}`, fresh ? { cache: 'no-store', ...transport } : { ...transport }); transport?.admit(); return read; }, [get, clinicalImpressionsList]); const fetchClinicalImpressionHistory = useCallback(async (id: string, options?: ReadOptions): Promise> => { return get>(`${API_URL}/ClinicalImpression/${id}/_history`, options); }, [get]); // `transport`: the save this belongs to; the request is admitted and cancelled with it, and its answer used only while // it still belongs. const createClinicalImpression = useCallback(async (clinicalImpression: ClinicalImpression, transport?: SaveTransport): Promise => { let params = new URLSearchParams(); params.append('subject', extractReferenceId(clinicalImpression.subject) as string); const created = await post(`${API_URL}/ClinicalImpression?${params.toString()}`, clinicalImpression, { ...transport }); transport?.admit(); return created; }, [post, extractReferenceId]); // CONDITIONAL: written only over `basedOn`, the version the editor opened on. A refusal is a RecordChangedError // (412 another write landed first, 428 no version named); a lost answer stays a lost answer. `transport`: the save // this belongs to, admitted and cancelled with it, and its answer is used only while it still belongs. const updateClinicalImpression = useCallback(async (clinicalImpression: ClinicalImpression, basedOn: string | undefined, transport?: SaveTransport): Promise => { if (!basedOn) throw new RecordChangedError(PAGE_OUT_OF_DATE_MESSAGE, true); const saved = await put(`${API_URL}/ClinicalImpression/${clinicalImpression.id}`, clinicalImpression, { headers: ifMatch(basedOn), ...transport }) .catch((error: unknown) => { throw asRecordChanged(error); }); transport?.admit(); return saved; }, [put]); const deleteClinicalImpression = useCallback(async (clinicalImpression: ClinicalImpression): Promise => { return del(`${API_URL}/ClinicalImpression/${clinicalImpression.id}`); }, [del]); // Archiving writes the whole note as held, so it too is written only over the version held. const archiveClinicalImpression = useCallback( async (clinicalImpression: ClinicalImpression, reason: ArchiveReason, note?: string, transport?: SaveTransport): Promise => { const basedOn = clinicalImpression.meta?.versionId; if (!basedOn) throw new RecordChangedError(PAGE_OUT_OF_DATE_MESSAGE, true); const archived = await put(`${API_URL}/ClinicalImpression/${clinicalImpression.id}`, applyArchive(clinicalImpression, reason, note), { headers: ifMatch(basedOn), ...transport }) .catch((error: unknown) => { throw asRecordChanged(error); }); transport?.admit(); return archived; }, [put], ); const transformToFhirClinicalImpression = useCallback((item: any, patient: Patient, encounterState?: Encounter, practitioner?: Practitioner): ClinicalImpression => { const { status, statusReason, code, description, encounter, effectiveType, effectiveDateTime, date, assessor, previous, problem, summary, finding, prognosisCodeableConcept, prognosisReference, note, investigation } = item; const patientReference: Reference = createReferenceObject('Patient', patient.id as string, formatFhirHumanName(patient.name)); const transformedProblem = transformToReferenceCollection(problem); const transformedFindings = (finding ?? []).map((f: any) => { const transformedItemReference = transformToReferenceCollection(f.itemReference); const findingItem: ClinicalImpressionFinding = { ...(f.itemCodeableConcept && { itemCodeableConcept: transformToCodeableConcept(f.itemCodeableConcept) }), ...(transformedItemReference && transformedItemReference.length !== 0 && { itemReference: transformedItemReference[0] }), ...(f.basis && { basis: f.basis }) }; if (Object.keys(findingItem).length !== 0) { return findingItem } return null; }).filter(Boolean); const transformedInvestigations = (investigation ?? []).map((i: any) => { const transformedItem = transformToReferenceCollection(i.item); const investigationItem: ClinicalImpressionInvestigation = { code: transformToCodeableConcept(i.code) as CodeableConcept, ...(transformedItem && transformedItem.length !== 0 && { item: transformedItem }) }; if (Object.keys(investigationItem).length !== 0) { return investigationItem } return null; }).filter(Boolean); let clinicalImpression: ClinicalImpression = { resourceType: 'ClinicalImpression', status: status, subject: patientReference, ...(encounterState && { encounter: createReferenceObject('Encounter', encounterState?.id as string, getEncounterDisplay(encounterState)) }), ...(encounter && { encounter: encounter }), /* The assessor defaults to whoever is signed in - the same stamp a vision prescription already uses for its prescriber. The field stays on the form and stays optional, because a scribe writing on somebody else's behalf has to be able to name the clinician who actually assessed; it is a default, not a lock. */ ...((assessor || practitioner) && { assessor: assessor ?? createReferenceObject('Practitioner', practitioner!.id as string, formatFhirHumanName(practitioner!.name)) }), ...(statusReason && { statusReason: transformToCodeableConcept(statusReason) }), ...(code && { code: transformToCodeableConcept(code) }), ...(description && { description: description }), ...(effectiveType === 'dateTime' && effectiveDateTime && { effectiveDateTime: formatDateTime(effectiveDateTime) }), ...(date && { date: formatDateTime(date) }), ...(previous && { previous: previous }), ...(summary && { summary: summary }), ...(transformedProblem && transformedProblem.length !== 0 && { problem: transformedProblem }), ...(transformedFindings && transformedFindings.length !== 0 && { finding: transformedFindings }), ...(transformedInvestigations && transformedInvestigations.length !== 0 && { investigation: transformedInvestigations }), ...(prognosisCodeableConcept && prognosisCodeableConcept.length !== 0 && { prognosisCodeableConcept: transformToCodeableConcept(prognosisCodeableConcept) }), ...(prognosisReference && prognosisReference.length !== 0 && { prognosisReference: prognosisReference }), ...(note && note.length !== 0 && { note: note }) }; if (effectiveType === 'period') { delete clinicalImpression.effectiveDateTime; return addFhirPeriodIfApplicable(clinicalImpression, item, 'effectivePeriod'); } return clinicalImpression; }, [createReferenceObject, transformToCodeableConcept, formatDateTime, getEncounterDisplay, addFhirPeriodIfApplicable]); const transformFromFhirClinicalImpression = useCallback((clinicalImpression: ClinicalImpression): any => { const { status, statusReason, code, description, encounter, effectiveDateTime, effectivePeriod, date, assessor, previous, problem, protocol, summary, finding, prognosisCodeableConcept, prognosisReference, note, investigation } = clinicalImpression; const formData = { status: status, statusReason: transformFromCodeableConcept(statusReason), code: transformFromCodeableConcept(code), description: description ?? '', encounter: encounter, effectiveType: effectiveDateTime ? 'dateTime' : effectivePeriod ? 'period' : '', effectiveDateTime: formatDateTimeForInput(effectiveDateTime), date: formatDateTimeForInput(date), assessor: assessor, previous: previous, problem: transformFromReferenceCollection(problem ?? []), protocol: protocol, summary: summary, note: note ?? [], prognosisCodeableConcept: transformFromCodeableConcept(prognosisCodeableConcept ?? []), prognosisReference: prognosisReference ?? [], finding: (finding || []).map((f: ClinicalImpressionFinding) => ({ itemCodeableConcept: transformFromCodeableConcept(f.itemCodeableConcept), itemReference: transformFromReferenceCollection(f.itemReference ? [f.itemReference] : []), basis: f.basis ?? '' })), investigation: (investigation ?? []).map((inv: ClinicalImpressionInvestigation) => ({ code: transformFromCodeableConcept(inv.code), item: transformFromReferenceCollection(inv.item ?? []) })) }; return addPeriodControlsIfApplicable(formData, clinicalImpression, 'effectivePeriod'); }, [transformFromCodeableConcept, addPeriodControlsIfApplicable, formatDateTimeForInput]); const getClinicalImpressionDisplay = (clinicalImpression: ClinicalImpression): string => { if (!clinicalImpression) { return 'Unknown Clinical Impression'; } const impressionCode = formatFhirCodeableConcept(clinicalImpression.code) || 'Clinical Impression'; // - the human label, never the code. `statusLabel` returns `undefined` for a code we // have no words for, and this omits the status entirely in that case rather than falling back to // the raw value - the fallback IS the defect. `utils/fhirStatusVocabulary.ts` const statusText = statusLabel(clinicalImpression.status); const status = statusText ? ` ยท ${statusText}` : ''; let dateSuffix = ''; if (clinicalImpression.date) { dateSuffix = ` (${formatDateTimeForDisplay(clinicalImpression.date)})`; } else if (clinicalImpression.effectiveDateTime) { dateSuffix = ` (${formatDateTimeForDisplay(clinicalImpression.effectiveDateTime)})`; } else if (clinicalImpression.effectivePeriod) { dateSuffix = ` (${formatFhirPeriod(clinicalImpression.effectivePeriod)})`; } return `${impressionCode}${status}${dateSuffix}`; }; return { clinicalImpressionsBundle, // WHO THE CACHED LIST IS FOR, so a consumer can ask whether it belongs to the patient on // screen rather than trusting an app-wide context with no patient in its name. cachedForPatientId: cacheOwner.ownerId, clinicalImpressionsList, setClinicalImpressionsList, shouldReload, setShouldReload, lastUpserted, setLastUpserted, unsaved, setUnsaved, fetchClinicalImpressions, fetchClinicalImpressionsWithPolling, fetchClinicalImpression, fetchClinicalImpressionHistory, createClinicalImpression, updateClinicalImpression, deleteClinicalImpression, archiveClinicalImpression, transformToFhirClinicalImpression, transformFromFhirClinicalImpression, getClinicalImpressionDisplay }; }; const ClinicalImpressionServiceContext = createContext | undefined>(undefined); export const ClinicalImpressionServiceProvider: React.FC<{ children: ReactNode }> = ({ children }) => { const clinicalImpressionService = useClinicalImpressionService(); return ( {children} ); }; export const useClinicalImpressionServiceContext = () => { const context = useContext(ClinicalImpressionServiceContext); if (context === undefined) { throw new Error('useClinicalImpressionServiceContext must be used within a ClinicalImpressionServiceProvider'); } return context; };