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, FamilyMemberHistory, Patient, FamilyMemberHistoryCondition, 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 { usePatientServiceContext } from './PatientService.tsx'; import { formatFhirCodeableConcept } from '../utils/fhirCodeableConcept.tsx'; import { applyArchive, isArchived, type ArchiveReason } from '../utils/archivedResource'; import { fetchAllPagesAsBundle } from '../utils/fetchAllPagesAsBundle'; import { getBirthSuffix } from '../utils/familyMemberBirthSuffix'; import { searchPageSize } from '../utils/pageSizeOverride'; import { useCacheOwner } from '../utils/cacheOwner'; const API_URL = environment.fhirClinicalApi; export const useFamilyMemberHistoryService = () => { const { get, post, put, delete: del } = useHttpService(); const { patientState } = usePatientServiceContext(); const { transformToCodeableConcept, transformFromCodeableConcept, createReferenceObject, formatDate, addFhirPeriodIfApplicable, addPeriodControlsIfApplicable, transformToFhirAge, transformFromFhirAge, unpackBundle, transformToReferenceCollection, transformFromReferenceCollection, formatDateTime, formatDateTimeForInput, extractReferenceId } = useFhirServiceContext(); const [familyMemberHistoriesList, setFamilyMemberHistoriesList] = useState([]); const [shouldReload, setShouldReload] = useState(false); const [lastUpserted, setLastUpserted] = useState(null); const [unsaved, setUnsaved] = useState(false); const [familyMemberHistoriesBundle, setFamilyMemberHistoriesBundle] = useState | null>(null); const familyMemberHistoriesBundleRef = useRef | null>(null); const lastFetchTimeRef = useRef(0); useEffect(() => { familyMemberHistoriesBundleRef.current = familyMemberHistoriesBundle; }, [familyMemberHistoriesBundle]); // 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('FamilyMemberHistory', () => { setLastUpserted(null); setFamilyMemberHistoriesBundle(null); setFamilyMemberHistoriesList([]); lastFetchTimeRef.current = 0; }); useEffect(() => { if (!patientState) { setFamilyMemberHistoriesBundle(null); setFamilyMemberHistoriesList([]); lastFetchTimeRef.current = 0; cacheOwner.release(); } }, [patientState, cacheOwner.release]); const fetchFamilyMemberHistories = useCallback(async (patient: Patient, options?: ReadOptions): Promise> => { return overlayHeldBundle('FamilyMemberHistory', await fetchAllPagesAsBundle((pageParam) => { const params = new URLSearchParams(); params.append('_count', searchPageSize('100')); params.append('patient', patient.id as string); if (pageParam) params.set('page', pageParam); return get>(`${API_URL}/FamilyMemberHistory?${params.toString()}`, options); }), (resource) => refersToPatient(resource, patient.id)) as Bundle; }, [get]); const fetchFamilyMemberHistoriesWithPolling = 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('FamilyMemberHistory', lastFetchTimeRef.current) && familyMemberHistoriesBundleRef.current && cacheFresh && cacheOwner.isCurrentFor(patient.id)) { return familyMemberHistoriesBundleRef.current; } const operation = cacheOwner.begin(); let retries = 10; const delay = 3000; let found = false; let bundle: Bundle | null = null; let entries: FamilyMemberHistory[]; do { bundle = await fetchFamilyMemberHistories(patient); entries = unpackBundle(bundle); found = lastUpserted ? entries.some(e => e.id === lastUpserted) || isHeld('FamilyMemberHistory', 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); setFamilyMemberHistoriesBundle(bundle); setFamilyMemberHistoriesList(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; }, [fetchFamilyMemberHistories, unpackBundle, lastUpserted, shouldReload]); const fetchFamilyMemberHistory = useCallback(async (id: string): Promise => { const cachedHistory = cacheOwner.isCurrent(cacheOwner.claimed) ? familyMemberHistoriesList.find(history => history.id === id) : undefined; if (cachedHistory) { return cachedHistory; } return get(`${API_URL}/FamilyMemberHistory/${id}`); }, [get, familyMemberHistoriesList]); const fetchFamilyMemberHistoryHistory = useCallback(async (id: string, options?: ReadOptions): Promise> => { return get>(`${API_URL}/FamilyMemberHistory/${id}/_history`, options); }, [get]); const createFamilyMemberHistory = useCallback(async (familyMemberHistory: FamilyMemberHistory): Promise => { let params = new URLSearchParams(); params.append('patient', extractReferenceId(familyMemberHistory.patient) as string); return post(`${API_URL}/FamilyMemberHistory?${params.toString()}`, familyMemberHistory); }, [post, extractReferenceId]); const updateFamilyMemberHistory = useCallback(async (familyMemberHistory: FamilyMemberHistory): Promise => { return put(`${API_URL}/FamilyMemberHistory/${familyMemberHistory.id}`, familyMemberHistory); }, [put]); const deleteFamilyMemberHistory = useCallback(async (familyMemberHistory: FamilyMemberHistory): Promise => { return del(`${API_URL}/FamilyMemberHistory/${familyMemberHistory.id}`); }, [del]); const archiveFamilyMemberHistory = useCallback(async (familyMemberHistory: FamilyMemberHistory, reason: ArchiveReason, note?: string): Promise => { return put(`${API_URL}/FamilyMemberHistory/${familyMemberHistory.id}`, applyArchive(familyMemberHistory, reason, note)); }, [put]); const transformToFhirFamilyMemberHistory = useCallback((item: any, patient: Patient): FamilyMemberHistory => { const { status, dataAbsentReason, relationship, name, sex, bornType, bornDate, ageType, estimatedAge, ageAge, ageRange, deceasedType, deceasedBoolean, deceasedAge, deceasedRange, deceasedDate, reasonCode, reasonReference, note, condition } = item; const transformedReasonReference = transformToReferenceCollection(reasonReference); let familyMemberHistory: FamilyMemberHistory = { resourceType: 'FamilyMemberHistory', status: status, patient: createReferenceObject('Patient', patient.id as string, formatFhirHumanName(patient?.name)), relationship: transformToCodeableConcept(relationship), date: formatDateTime(new Date()), ...(dataAbsentReason && { dataAbsentReason: transformToCodeableConcept(dataAbsentReason) }), ...(name && { name: name }), ...(sex && { sex: transformToCodeableConcept(sex) }), ...(reasonCode && reasonCode.length !== 0 && { reasonCode: transformToCodeableConcept(reasonCode) }), ...(transformedReasonReference && transformedReasonReference.length !== 0 && { reasonReference: transformedReasonReference }), ...(bornType === 'date' && bornDate && { bornDate: formatDate(bornDate) }), ...(ageType === 'age' && ageAge && { ageAge: transformToFhirAge(ageAge) }), ...(ageType === 'range' && ageRange && { ageRange: ageRange }), ...(ageType && { estimatedAge: !!estimatedAge }), ...(!deceasedAge && !deceasedRange && !deceasedDate && { deceasedBoolean: deceasedBoolean }), ...(deceasedBoolean && deceasedType === 'age' && { deceasedAge: transformToFhirAge(deceasedAge) }), ...(deceasedBoolean && deceasedType === 'range' && { deceasedRange: deceasedRange }), ...(deceasedBoolean && deceasedType === 'date' && { deceasedDate: formatDate(deceasedDate) }), ...(note?.length !== 0 && { note: note }), ...(condition && condition.length !== 0 && { condition: condition.map((c: any) => { const familyMemberHistoryCondition: FamilyMemberHistoryCondition = { code: transformToCodeableConcept(c.code) as CodeableConcept, contributedToDeath: !!c.contributedToDeath, ...(c.outcome && { outcome: transformToCodeableConcept(c.outcome) as CodeableConcept }), ...(c.onsetType === 'age' && c.onsetAge && { onsetAge: transformToFhirAge(c.onsetAge) }), ...(c.onsetType === 'range' && c.onsetRange && { onsetRange: c.onsetRange }), ...(c.note && c.note.length !== 0 && { note: c.note }) }; if (c.onsetType === 'period') { return addFhirPeriodIfApplicable(familyMemberHistoryCondition, c, 'onsetPeriod'); } return familyMemberHistoryCondition; }) }) }; if (bornType === 'period') { return addFhirPeriodIfApplicable(familyMemberHistory, item, 'bornPeriod'); } return familyMemberHistory; }, [ createReferenceObject, transformToCodeableConcept, transformToFhirAge, addFhirPeriodIfApplicable, transformToReferenceCollection, formatDateTime ]); const transformFromFhirFamilyMemberHistory = useCallback((familyMemberHistory: FamilyMemberHistory): any => { const { status, relationship, dataAbsentReason, name, date, sex, reasonCode, reasonReference, bornDate, bornPeriod, ageAge, ageRange, estimatedAge, deceasedBoolean, deceasedAge, deceasedRange, deceasedDate, condition, note } = familyMemberHistory; const formData = { status: status, relationship: transformFromCodeableConcept(relationship), dataAbsentReason: transformFromCodeableConcept(dataAbsentReason) ?? null, name: name ?? '', date: formatDateTimeForInput(date), sex: transformFromCodeableConcept(sex) ?? null, reasonCode: transformFromCodeableConcept(reasonCode ?? []), reasonReference: transformFromReferenceCollection(reasonReference ?? []), bornType: bornDate ? 'date' : bornPeriod ? 'period' : '', bornDate: bornDate, ageType: ageAge ? 'age' : ageRange ? 'range' : '', ageAge: transformFromFhirAge(ageAge), ageRange: ageRange, estimatedAge: estimatedAge, deceasedBoolean: deceasedBoolean || deceasedAge || deceasedRange || deceasedDate, deceasedType: deceasedAge ? 'age' : deceasedRange ? 'range' : deceasedDate ? 'date' : '', deceasedAge: deceasedAge, deceasedRange: deceasedRange, deceasedDate: deceasedDate, note: note ?? [], condition: (condition ?? []).map((c: FamilyMemberHistoryCondition) => { const extendedFamilyMemberHistoryCondition: any = { code: transformFromCodeableConcept(c.code), outcome: transformFromCodeableConcept(c.outcome), contributedToDeath: c.contributedToDeath, onsetType: c.onsetRange ? 'range' : c.onsetPeriod ? 'period' : c.onsetAge ? 'age' : '', onsetRange: c.onsetRange, onsetAge: transformFromFhirAge(c.onsetAge), note: c.note ?? [] }; return addPeriodControlsIfApplicable(extendedFamilyMemberHistoryCondition, c, 'onsetPeriod'); }) }; return addPeriodControlsIfApplicable(formData, familyMemberHistory, 'bornPeriod'); }, [ transformFromCodeableConcept, transformFromFhirAge, transformFromReferenceCollection, addPeriodControlsIfApplicable, formatDateTimeForInput ]); const getFamilyMemberHistoryDisplay = (familyMemberHistory: FamilyMemberHistory): string => { if (!familyMemberHistory) { return 'Unknown Family Member'; } const nameOrRelationship = familyMemberHistory.name || formatFhirCodeableConcept(familyMemberHistory.relationship) || 'Family Member'; const deceasedInfo = getDeceasedStatusText(familyMemberHistory); const deceasedSuffix = deceasedInfo.isDeceased ? ` (${deceasedInfo.text})` : ''; const conditionCount = familyMemberHistory.condition?.length || 0; const conditionSuffix = conditionCount > 0 ? ` - ${conditionCount} condition${conditionCount !== 1 ? 's' : ''}` : ''; const birthSuffix = getBirthSuffix(familyMemberHistory); return `${nameOrRelationship}${deceasedSuffix}${conditionSuffix}${birthSuffix}`; }; const getDeceasedStatusText = (familyMemberHistory: FamilyMemberHistory): { isDeceased: boolean, text: string } => { if (!familyMemberHistory) return { isDeceased: false, text: '' }; if (familyMemberHistory.deceasedBoolean === true) { return { isDeceased: true, text: 'Deceased' }; } if (familyMemberHistory.deceasedDate) { return { isDeceased: true, text: `Deceased on ${formatDate(familyMemberHistory.deceasedDate)}` }; } if (familyMemberHistory.deceasedRange) { return { isDeceased: true, text: `Deceased between age ${familyMemberHistory.deceasedRange.low?.value}-${familyMemberHistory.deceasedRange.high?.value}` }; } if (familyMemberHistory.deceasedAge) { return { isDeceased: true, text: `Deceased at age ${familyMemberHistory.deceasedAge.value} ${familyMemberHistory.deceasedAge.unit}` }; } return { isDeceased: false, text: 'Living' }; }; return { // 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, familyMemberHistoriesBundle, familyMemberHistoriesList, setFamilyMemberHistoriesList, shouldReload, setShouldReload, lastUpserted, setLastUpserted, unsaved, setUnsaved, fetchFamilyMemberHistories, fetchFamilyMemberHistoriesWithPolling, fetchFamilyMemberHistory, fetchFamilyMemberHistoryHistory, createFamilyMemberHistory, updateFamilyMemberHistory, deleteFamilyMemberHistory, archiveFamilyMemberHistory, transformToFhirFamilyMemberHistory, transformFromFhirFamilyMemberHistory, getFamilyMemberHistoryDisplay }; }; const FamilyMemberHistoryServiceContext = createContext | undefined>(undefined); export const FamilyMemberHistoryServiceProvider: React.FC<{ children: ReactNode }> = ({ children }) => { const familyMemberHistoryService = useFamilyMemberHistoryService(); return ( {children} ); }; export const useFamilyMemberHistoryServiceContext = () => { const context = useContext(FamilyMemberHistoryServiceContext); if (context === undefined) { throw new Error('useFamilyMemberHistoryServiceContext must be used within a FamilyMemberHistoryServiceProvider'); } return context; };