import type { ReadOptions } from '../hooks/anchorView/readOptions'; import type { SaveTransport } from '../utils/saveOperation'; import { isHeld, overlayHeldBundle, refersToPatient, writtenSince } from '../utils/writeThroughHold'; import { useState, useCallback, createContext, useContext, ReactNode, useEffect, useRef } from 'react'; import { Bundle, VisionPrescription, VisionPrescriptionLensSpecification, VisionPrescriptionLensSpecificationPrism, Patient, Encounter, Reference, Practitioner } 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 { 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 useVisionPrescriptionService = () => { const { get, post, put, delete: del } = useHttpService(); const { patientState } = usePatientServiceContext(); const { transformToCodeableConcept, transformFromCodeableConcept, createReferenceObject, formatDateTime, formatDateTimeForInput, formatDateTimeForDisplay, unpackBundle, transformToFhirQuantity, transformFromFhirQuantity, extractReferenceId } = useFhirServiceContext(); const { getEncounterDisplay } = useEncounterServiceContext(); const [visionPrescriptionsList, setVisionPrescriptionsList] = useState([]); const [shouldReload, setShouldReload] = useState(false); const [lastUpserted, setLastUpserted] = useState(null); const [unsaved, setUnsaved] = useState(false); const [visionPrescriptionsBundle, setVisionPrescriptionsBundle] = useState | null>(null); const visionPrescriptionsBundleRef = useRef | null>(null); const lastFetchTimeRef = useRef(0); useEffect(() => { visionPrescriptionsBundleRef.current = visionPrescriptionsBundle; }, [visionPrescriptionsBundle]); // 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('VisionPrescription', () => { setLastUpserted(null); setVisionPrescriptionsBundle(null); setVisionPrescriptionsList([]); lastFetchTimeRef.current = 0; }); useEffect(() => { if (!patientState) { setVisionPrescriptionsBundle(null); setVisionPrescriptionsList([]); lastFetchTimeRef.current = 0; cacheOwner.release(); } }, [patientState, cacheOwner.release]); const fetchVisionPrescriptions = useCallback(async (patient: Patient, options?: ReadOptions): Promise> => { return overlayHeldBundle('VisionPrescription', await fetchAllPagesAsBundle((pageParam) => { let 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}/VisionPrescription?${params.toString()}`, options); }), (resource) => refersToPatient(resource, patient.id)) as Bundle; }, [get]); const fetchVisionPrescriptionsWithPolling = 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('VisionPrescription', lastFetchTimeRef.current) && visionPrescriptionsBundleRef.current && cacheFresh && cacheOwner.isCurrentFor(patient.id)) { return visionPrescriptionsBundleRef.current; } const operation = cacheOwner.begin(); let retries = 10; const delay = 3000; let found = false; let bundle: Bundle | null = null; let entries: VisionPrescription[]; do { bundle = await fetchVisionPrescriptions(patient); entries = unpackBundle(bundle).filter((r) => !isArchived(r)); found = lastUpserted ? entries.some(e => e.id === lastUpserted) || isHeld('VisionPrescription', 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); setVisionPrescriptionsBundle(bundle); setVisionPrescriptionsList(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; }, [fetchVisionPrescriptions, unpackBundle, lastUpserted, shouldReload]); const fetchVisionPrescription = useCallback(async (id: string): Promise => { const cachedPrescription = cacheOwner.isCurrent(cacheOwner.claimed) ? visionPrescriptionsList.find(prescription => prescription.id === id) : undefined; if (cachedPrescription) { return cachedPrescription; } return get(`${API_URL}/VisionPrescription/${id}`); }, [get, visionPrescriptionsList]); const fetchVisionPrescriptionHistory = useCallback(async (id: string, options?: ReadOptions): Promise> => { return get>(`${API_URL}/VisionPrescription/${id}/_history`, options); }, [get]); const createVisionPrescription = useCallback(async (visionPrescription: VisionPrescription, transport?: SaveTransport): Promise => { let params = new URLSearchParams(); params.append('patient', extractReferenceId(visionPrescription.patient) as string); return post(`${API_URL}/VisionPrescription?${params.toString()}`, visionPrescription, { ...transport }); }, [post, extractReferenceId]); const updateVisionPrescription = useCallback(async (visionPrescription: VisionPrescription): Promise => { return put(`${API_URL}/VisionPrescription/${visionPrescription.id}`, visionPrescription); }, [put]); const deleteVisionPrescription = useCallback(async (visionPrescription: VisionPrescription): Promise => { return del(`${API_URL}/VisionPrescription/${visionPrescription.id}`); }, [del]); const archiveVisionPrescription = useCallback( async (visionPrescription: VisionPrescription, reason: ArchiveReason, note?: string): Promise => put(`${API_URL}/VisionPrescription/${visionPrescription.id}`, applyArchive(visionPrescription, reason, note)), [put], ); const transformToFhirVisionPrescription = useCallback((item: any, patient: Patient, practitioner: Practitioner, encounterState?: Encounter): VisionPrescription => { const { status, created, encounter, dateWritten, lensSpecification } = item; const patientReference: Reference = createReferenceObject('Patient', patient.id as string, formatFhirHumanName(patient.name)); const practitionerReference: Reference = createReferenceObject('Practitioner', practitioner.id as string, formatFhirHumanName(practitioner.name)); let visionPrescription: VisionPrescription = { resourceType: 'VisionPrescription', status: status, created: formatDateTime(created), patient: patientReference, prescriber: practitionerReference, ...(encounterState && { encounter: createReferenceObject('Encounter', encounterState?.id as string, getEncounterDisplay(encounterState)) }), ...(encounter && { encounter: encounter }), ...(dateWritten && { dateWritten: formatDateTime(dateWritten) }), ...(lensSpecification && lensSpecification.length !== 0 && { lensSpecification: lensSpecification.map((lens: any) => { const lensSpec: VisionPrescriptionLensSpecification = { eye: lens.eye, product: transformToCodeableConcept(lens.product), ...(lens.sphere !== undefined && { sphere: +lens.sphere }), ...(lens.cylinder !== undefined && { cylinder: +lens.cylinder }), ...(lens.axis !== undefined && { axis: +lens.axis }), ...(lens.prism && lens.prism.length !== 0 && { prism: lens.prism.map((p: any) => ({ amount: +p.amount, base: p.base })) }), ...(lens.add !== undefined && { add: +lens.add }), ...(lens.power !== undefined && { power: +lens.power }), ...(lens.backCurve !== undefined && { backCurve: +lens.backCurve }), ...(lens.diameter !== undefined && { diameter: +lens.diameter }), ...(lens.duration && { duration: transformToFhirQuantity(lens.duration) }), ...(lens.color && { color: lens.color }), ...(lens.brand && { brand: lens.brand }), ...(lens.note && lens.note.length !== 0 && { note: lens.note }) }; return lensSpec; }) }) }; return visionPrescription; }, [createReferenceObject, transformToCodeableConcept, formatDateTime, transformToFhirQuantity]); const transformFromFhirVisionPrescription = useCallback((visionPrescription: VisionPrescription): any => { const { status, created, patient, encounter, dateWritten, prescriber, lensSpecification } = visionPrescription; const formData = { status: status, created: formatDateTimeForInput(created), patient: patient, encounter: encounter, dateWritten: formatDateTimeForInput(dateWritten), prescriber: prescriber, lensSpecification: (lensSpecification ?? []).map((lens: VisionPrescriptionLensSpecification) => ({ eye: lens.eye, product: transformFromCodeableConcept(lens.product), sphere: lens.sphere, cylinder: lens.cylinder, axis: lens.axis, prism: (lens.prism ?? []).map((p: VisionPrescriptionLensSpecificationPrism) => ({ amount: p.amount, base: p.base })), add: lens.add, power: lens.power, backCurve: lens.backCurve, diameter: lens.diameter, duration: transformFromFhirQuantity(lens.duration), color: lens.color, brand: lens.brand, note: lens.note ?? [], prisms: [] })) }; return formData; }, [transformFromCodeableConcept, transformFromFhirQuantity, formatDateTimeForInput]); const getVisionPrescriptionDisplay = (visionPrescription: VisionPrescription): string => { if (!visionPrescription) { return 'Unknown Vision Prescription'; } const prescriptionName = 'Vision Prescription'; // - 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(visionPrescription.status); const status = statusText ? ` ยท ${statusText}` : ''; let dateSuffix = ''; if (visionPrescription.dateWritten) { dateSuffix = ` (${formatDateTimeForDisplay(visionPrescription.dateWritten)})`; } else if (visionPrescription.created) { dateSuffix = ` (${formatDateTimeForDisplay(visionPrescription.created)})`; } return `${prescriptionName}${status}${dateSuffix}`; }; 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, visionPrescriptionsBundle, visionPrescriptionsList, setVisionPrescriptionsList, shouldReload, setShouldReload, lastUpserted, setLastUpserted, unsaved, setUnsaved, fetchVisionPrescriptions, fetchVisionPrescriptionsWithPolling, fetchVisionPrescription, fetchVisionPrescriptionHistory, createVisionPrescription, updateVisionPrescription, deleteVisionPrescription, archiveVisionPrescription, transformToFhirVisionPrescription, transformFromFhirVisionPrescription, getVisionPrescriptionDisplay }; }; const VisionPrescriptionServiceContext = createContext | undefined>(undefined); export const VisionPrescriptionServiceProvider: React.FC<{ children: ReactNode }> = ({ children }) => { const visionPrescriptionService = useVisionPrescriptionService(); return ( {children} ); }; export const useVisionPrescriptionServiceContext = () => { const context = useContext(VisionPrescriptionServiceContext); if (context === undefined) { throw new Error('useVisionPrescriptionServiceContext must be used within a VisionPrescriptionServiceProvider'); } return context; }; /** * The same accessor, tolerant of an absent provider. * * Used by the encounter's "Add to the record" door, which mounts on a surface whose tests and older * callers do not all wrap this provider. Absent it the door simply does not draw - a capability that * cannot write must not be offered - rather than taking the whole encounter down with it, which is the * rule `useOptionalScribeService` already states for an add-on and applies just as well here. */ export const useOptionalVisionPrescriptionServiceContext = () => useContext(VisionPrescriptionServiceContext);