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, DeviceUseStatement, Patient, CodeableConcept, Practitioner, PractitionerRole, RelatedPerson } 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 { 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'; const API_URL = environment.fhirClinicalApi; export const useDeviceUseStatementService = () => { const { get, post, put, delete: del } = useHttpService(); const { patientState } = usePatientServiceContext(); const { transformToCodeableConcept, transformFromCodeableConcept, createReferenceObject, formatDateTime, formatDateTimeForInput, formatDateTimeForDisplay, addFhirPeriodIfApplicable, unpackBundle, transformToFhirTiming, transformFromFhirTiming, transformToReferenceCollection, transformFromReferenceCollection, extractReferenceId } = useFhirServiceContext(); const [deviceUseStatementsList, setDeviceUseStatementsList] = useState([]); const [shouldReload, setShouldReload] = useState(false); const [lastUpserted, setLastUpserted] = useState(null); const [unsaved, setUnsaved] = useState(false); const [deviceUseStatementsBundle, setDeviceUseStatementsBundle] = useState | null>(null); const deviceUseStatementsBundleRef = useRef | null>(null); const lastFetchTimeRef = useRef(0); useEffect(() => { deviceUseStatementsBundleRef.current = deviceUseStatementsBundle; }, [deviceUseStatementsBundle]); // 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('DeviceUseStatement', () => { setLastUpserted(null); setDeviceUseStatementsBundle(null); setDeviceUseStatementsList([]); lastFetchTimeRef.current = 0; }); useEffect(() => { if (!patientState) { setDeviceUseStatementsBundle(null); setDeviceUseStatementsList([]); lastFetchTimeRef.current = 0; cacheOwner.release(); } }, [patientState, cacheOwner.release]); const fetchDeviceUseStatements = useCallback(async (patient: Patient, options?: ReadOptions): Promise> => { return overlayHeldBundle('DeviceUseStatement', 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}/DeviceUseStatement?${params.toString()}`, options); }), (resource) => refersToPatient(resource, patient.id)) as Bundle; }, [get]); const fetchDeviceUseStatementsWithPolling = 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('DeviceUseStatement', lastFetchTimeRef.current) && deviceUseStatementsBundleRef.current && cacheFresh && cacheOwner.isCurrentFor(patient.id)) { return deviceUseStatementsBundleRef.current; } const operation = cacheOwner.begin(); let retries = 10; const delay = 3000; let found = false; let bundle: Bundle | null = null; let entries: DeviceUseStatement[]; do { bundle = await fetchDeviceUseStatements(patient); entries = unpackBundle(bundle); found = lastUpserted ? entries.some(e => e.id === lastUpserted) || isHeld('DeviceUseStatement', 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); setDeviceUseStatementsBundle(bundle); setDeviceUseStatementsList(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; }, [fetchDeviceUseStatements, unpackBundle, lastUpserted, shouldReload]); const fetchDeviceUseStatement = useCallback(async (id: string): Promise => { const cachedDeviceUseStatement = cacheOwner.isCurrent(cacheOwner.claimed) ? deviceUseStatementsList.find(deviceUseStatement => deviceUseStatement.id === id) : undefined; if (cachedDeviceUseStatement) { return cachedDeviceUseStatement; } return get(`${API_URL}/DeviceUseStatement/${id}`); }, [get, deviceUseStatementsList]); const fetchDeviceUseStatementHistory = useCallback(async (id: string, options?: ReadOptions): Promise> => { return get>(`${API_URL}/DeviceUseStatement/${id}/_history`, options); }, [get]); const createDeviceUseStatement = useCallback(async (deviceUseStatement: DeviceUseStatement): Promise => { let params = new URLSearchParams(); params.append('subject', extractReferenceId(deviceUseStatement.subject) as string); return post(`${API_URL}/DeviceUseStatement?${params.toString()}`, deviceUseStatement); }, [post, extractReferenceId]); const updateDeviceUseStatement = useCallback(async (deviceUseStatement: DeviceUseStatement): Promise => { return put(`${API_URL}/DeviceUseStatement/${deviceUseStatement.id}`, deviceUseStatement); }, [put]); const deleteDeviceUseStatement = useCallback(async (deviceUseStatement: DeviceUseStatement): Promise => { return del(`${API_URL}/DeviceUseStatement/${deviceUseStatement.id}`); }, [del]); const archiveDeviceUseStatement = useCallback(async (deviceUseStatement: DeviceUseStatement, reason: ArchiveReason, note?: string): Promise => { return put(`${API_URL}/DeviceUseStatement/${deviceUseStatement.id}`, applyArchive(deviceUseStatement, reason, note)); }, [put]); const transformToFhirDeviceUseStatement = useCallback((item: any, patient?: Patient, source?: Patient | Practitioner | PractitionerRole | RelatedPerson): DeviceUseStatement => { const { status, device, bodySite, timingType, timingDateTime, recordedOn, reasonCode, reasonReference, derivedFrom, note } = item; const transformedReasonReference = transformToReferenceCollection(reasonReference); const transformedDerivedFrom = transformToReferenceCollection(derivedFrom); let deviceUseStatement: DeviceUseStatement = { resourceType: 'DeviceUseStatement', status: status, device: device, ...(patient && { subject: createReferenceObject('Patient', patient.id as string, formatFhirHumanName(patient.name)) }), ...(source && { source: createReferenceObject(source.resourceType, source.id as string, formatFhirHumanName(source.name)) }), ...(bodySite && { bodySite: transformToCodeableConcept(bodySite) }), ...(recordedOn && { recordedOn: formatDateTime(recordedOn) }), ...(timingType === 'dateTime' && timingDateTime && { timingDateTime: formatDateTime(timingDateTime) }), ...(reasonCode && reasonCode.length !== 0 && { reasonCode: transformToCodeableConcept(reasonCode) as CodeableConcept[] }), ...(transformedReasonReference && transformedReasonReference.length !== 0 && { reasonReference: transformedReasonReference }), ...(transformedDerivedFrom && transformedDerivedFrom.length !== 0 && { derivedFrom: transformedDerivedFrom }), ...(note && note.length !== 0 && { note: note }) }; if (timingType === 'period') { deviceUseStatement = addFhirPeriodIfApplicable(deviceUseStatement, item, 'timingPeriod'); } else if (timingType === 'timing') { deviceUseStatement = { ...deviceUseStatement, timingTiming: transformToFhirTiming(item.timingTiming) }; } return deviceUseStatement; }, [ createReferenceObject, transformToCodeableConcept, addFhirPeriodIfApplicable, formatDateTime, transformToFhirTiming, transformToReferenceCollection ]); const transformFromFhirDeviceUseStatement = useCallback((deviceUseStatement: DeviceUseStatement): any => { const { status, device, bodySite, recordedOn, timingDateTime, timingPeriod, timingTiming, reasonCode, reasonReference, derivedFrom, note } = deviceUseStatement; let timingType = ''; if (timingDateTime) timingType = 'dateTime'; else if (timingPeriod) timingType = 'period'; else if (timingTiming) timingType = 'timing'; const formData = { status: status, device: device, bodySite: transformFromCodeableConcept(bodySite), timingType: timingType, timingDateTime: formatDateTimeForInput(timingDateTime), timingTiming: transformFromFhirTiming(timingTiming), recordedOn: formatDateTimeForInput(recordedOn), reasonCode: transformFromCodeableConcept(reasonCode ?? []), reasonReference: transformFromReferenceCollection(reasonReference ?? []), derivedFrom: transformFromReferenceCollection(derivedFrom ?? []), note: note ?? [] }; let result = formData; if (timingPeriod) { result = addFhirPeriodIfApplicable(result, deviceUseStatement, 'timingPeriod'); } return result; }, [ transformFromCodeableConcept, addFhirPeriodIfApplicable, transformFromFhirTiming, transformFromReferenceCollection, formatDateTimeForInput ]); const getDeviceUseStatementDisplay = (deviceUseStatement: DeviceUseStatement): string => { if (!deviceUseStatement) { return 'Unknown Device Use Statement'; } let deviceName = 'Unknown Device'; if (deviceUseStatement.device) { deviceName = deviceUseStatement.device.display || deviceUseStatement.device.reference?.split('/').pop() || 'Device Reference'; } // - 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(deviceUseStatement.status); const status = statusText ? ` ยท ${statusText}` : ''; let dateSuffix = ''; if (deviceUseStatement.timingDateTime) { dateSuffix = ` (${formatDateTimeForDisplay(deviceUseStatement.timingDateTime)})`; } else if (deviceUseStatement.timingPeriod) { dateSuffix = ` (${formatFhirPeriod(deviceUseStatement.timingPeriod)})`; } return `${deviceName}${status}${dateSuffix}`; }; return { deviceUseStatementsBundle, // 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, deviceUseStatementsList, setDeviceUseStatementsList, shouldReload, setShouldReload, lastUpserted, setLastUpserted, unsaved, setUnsaved, fetchDeviceUseStatements, fetchDeviceUseStatementsWithPolling, fetchDeviceUseStatement, fetchDeviceUseStatementHistory, createDeviceUseStatement, updateDeviceUseStatement, deleteDeviceUseStatement, archiveDeviceUseStatement, transformToFhirDeviceUseStatement, transformFromFhirDeviceUseStatement, getDeviceUseStatementDisplay }; }; const DeviceUseStatementServiceContext = createContext | undefined>(undefined); export const DeviceUseStatementServiceProvider: React.FC<{ children: ReactNode }> = ({ children }) => { const deviceUseStatementService = useDeviceUseStatementService(); return ( {children} ); }; export const useDeviceUseStatementServiceContext = () => { const context = useContext(DeviceUseStatementServiceContext); if (context === undefined) { throw new Error('useDeviceUseStatementServiceContext must be used within a DeviceUseStatementServiceProvider'); } return context; };