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, NutritionOrder, Patient, NutritionOrderOralDiet, NutritionOrderEnteralFormula, NutritionOrderOralDietNutrient, NutritionOrderOralDietTexture, NutritionOrderEnteralFormulaAdministration, Reference, Encounter } 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 { 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 useNutritionOrderService = () => { const { get, post, put, delete: del } = useHttpService(); const { patientState } = usePatientServiceContext(); const { transformToCodeableConcept, transformFromCodeableConcept, createReferenceObject, formatDateTime, formatDateTimeForInput, formatDateTimeForDisplay, unpackBundle, transformToFhirQuantity, transformFromFhirQuantity, transformToFhirTiming, transformFromFhirTiming, transformToFhirRatio, transformFromFhirRatio, extractReferenceId } = useFhirServiceContext(); const { getEncounterDisplay } = useEncounterServiceContext(); const [nutritionOrdersList, setNutritionOrdersList] = useState([]); const [shouldReload, setShouldReload] = useState(false); const [lastUpserted, setLastUpserted] = useState(null); const [unsaved, setUnsaved] = useState(false); const [nutritionOrdersBundle, setNutritionOrdersBundle] = useState | null>(null); const nutritionOrdersBundleRef = useRef | null>(null); const lastFetchTimeRef = useRef(0); useEffect(() => { nutritionOrdersBundleRef.current = nutritionOrdersBundle; }, [nutritionOrdersBundle]); // 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('NutritionOrder', () => { setLastUpserted(null); setNutritionOrdersBundle(null); setNutritionOrdersList([]); lastFetchTimeRef.current = 0; }); useEffect(() => { if (!patientState) { setNutritionOrdersBundle(null); setNutritionOrdersList([]); lastFetchTimeRef.current = 0; cacheOwner.release(); } }, [patientState, cacheOwner.release]); const fetchNutritionOrders = useCallback(async (patient: Patient, options?: ReadOptions): Promise> => { return overlayHeldBundle('NutritionOrder', 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}/NutritionOrder?${params.toString()}`, options); }), (resource) => refersToPatient(resource, patient.id)) as Bundle; }, [get]); const fetchNutritionOrdersWithPolling = 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('NutritionOrder', lastFetchTimeRef.current) && nutritionOrdersBundleRef.current && cacheFresh && cacheOwner.isCurrentFor(patient.id)) { return nutritionOrdersBundleRef.current; } const operation = cacheOwner.begin(); let retries = 10; const delay = 3000; let found = false; let bundle: Bundle | null = null; let entries: NutritionOrder[]; do { bundle = await fetchNutritionOrders(patient); entries = unpackBundle(bundle).filter((r) => !isArchived(r)); found = lastUpserted ? entries.some(e => e.id === lastUpserted) || isHeld('NutritionOrder', 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); setNutritionOrdersBundle(bundle); setNutritionOrdersList(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; }, [fetchNutritionOrders, unpackBundle, lastUpserted, shouldReload]); const fetchNutritionOrder = useCallback(async (id: string): Promise => { const cachedOrder = cacheOwner.isCurrent(cacheOwner.claimed) ? nutritionOrdersList.find(order => order.id === id) : undefined; if (cachedOrder) { return cachedOrder; } return get(`${API_URL}/NutritionOrder/${id}`); }, [get, nutritionOrdersList]); const fetchNutritionOrderHistory = useCallback(async (id: string, options?: ReadOptions): Promise> => { return get>(`${API_URL}/NutritionOrder/${id}/_history`, options); }, [get]); const createNutritionOrder = useCallback(async (nutritionOrder: NutritionOrder, transport?: SaveTransport): Promise => { let params = new URLSearchParams(); params.append('patient', extractReferenceId(nutritionOrder.patient) as string); return post(`${API_URL}/NutritionOrder?${params.toString()}`, nutritionOrder, { ...transport }); }, [post, extractReferenceId]); const updateNutritionOrder = useCallback(async (nutritionOrder: NutritionOrder): Promise => { return put(`${API_URL}/NutritionOrder/${nutritionOrder.id}`, nutritionOrder); }, [put]); const deleteNutritionOrder = useCallback(async (nutritionOrder: NutritionOrder): Promise => { return del(`${API_URL}/NutritionOrder/${nutritionOrder.id}`); }, [del]); const archiveNutritionOrder = useCallback( async (nutritionOrder: NutritionOrder, reason: ArchiveReason, note?: string): Promise => put(`${API_URL}/NutritionOrder/${nutritionOrder.id}`, applyArchive(nutritionOrder, reason, note)), [put], ); const transformToFhirNutritionOrder = useCallback((item: any, patient: Patient, encounterState?: Encounter): NutritionOrder => { const { status, intent, encounter, dateTime, orderer, allergyIntolerance, foodPreferenceModifier, excludeFoodModifier, oralDiet, supplement, enteralFormula, note } = item; const patientReference: Reference = createReferenceObject('Patient', patient.id as string, formatFhirHumanName(patient.name)); // An oral-only order has no formula and a formula-only order no oral diet: an absent one is empty, and the // resource leaves an empty one out. const { baseFormulaType, baseFormulaProductName, additiveType, additiveProductName, caloricDensity, routeofAdministration, administration, maxVolumeToDeliver, administrationInstruction } = enteralFormula ?? {}; const transformedCaloricDensity = transformToFhirQuantity(caloricDensity); const transformedMaxVolumeToDeliver = transformToFhirQuantity(maxVolumeToDeliver); const { type, schedule, nutrient, texture, fluidConsistencyType } = oralDiet ?? {}; const transformedOralDiet: NutritionOrderOralDiet = { ...(type && type.length !== 0 && { type: transformToCodeableConcept(type) }), ...(schedule && schedule.length !== 0 && { schedule: schedule.map(transformToFhirTiming) }), ...(nutrient && nutrient.length !== 0 && { nutrient: nutrient.map((n: any) => ({ ...(n.modifier && { modifier: transformToCodeableConcept(n.modifier) }), ...(n.amount && { amount: transformToFhirQuantity(n.amount) }) })) }), ...(texture && texture.length !== 0 && { texture: texture.map((t: any) => ({ ...(t.modifier && { modifier: transformToCodeableConcept(t.modifier) }), ...(t.foodType && { foodType: transformToCodeableConcept(t.foodType) }) })) }), ...(fluidConsistencyType && fluidConsistencyType.length !== 0 && { fluidConsistencyType: transformToCodeableConcept(fluidConsistencyType) }), ...(oralDiet?.instruction && { instruction: oralDiet.instruction }) }; const transformedEnteralFormula: NutritionOrderEnteralFormula = { ...(baseFormulaType && { baseFormulaType: transformToCodeableConcept(baseFormulaType) }), ...(baseFormulaProductName && { baseFormulaProductName: baseFormulaProductName }), ...(additiveType && { additiveType: transformToCodeableConcept(additiveType) }), ...(additiveProductName && { additiveProductName: additiveProductName }), ...(transformedCaloricDensity && { caloricDensity: transformedCaloricDensity }), ...(routeofAdministration && { routeofAdministration: transformToCodeableConcept(routeofAdministration) }), ...(transformedMaxVolumeToDeliver && { maxVolumeToDeliver: transformedMaxVolumeToDeliver }), ...(administrationInstruction && { administrationInstruction: administrationInstruction }), ...(administration && administration.length !== 0 && { administration: administration.map((a: any) => ({ schedule: transformToFhirTiming(a.schedule), quantity: transformToFhirQuantity(a.quantity), ...(a.rate === 'quantity' && a.rateQuantity && { rateQuantity: transformToFhirQuantity(a.rateQuantity) }), ...(a.rate === 'ratio' && a.rateRatio && { rateRatio: transformToFhirRatio(a.rateRatio) }) })) }), }; return { resourceType: 'NutritionOrder', status: status, intent: intent, patient: patientReference, dateTime: formatDateTime(dateTime), ...(encounterState && { encounter: createReferenceObject('Encounter', encounterState?.id as string, getEncounterDisplay(encounterState)) }), ...(encounter && { encounter: encounter }), ...(orderer && { orderer: orderer }), ...(allergyIntolerance && allergyIntolerance.length !== 0 && { allergyIntolerance: allergyIntolerance }), ...(foodPreferenceModifier && foodPreferenceModifier.length !== 0 && { foodPreferenceModifier: transformToCodeableConcept(foodPreferenceModifier) }), ...(excludeFoodModifier && excludeFoodModifier.length !== 0 && { excludeFoodModifier: transformToCodeableConcept(excludeFoodModifier) }), ...(note && note.length !== 0 && { note: note }), ...(Object.keys(transformedOralDiet).length !== 0 && { oralDiet: transformedOralDiet }), ...(supplement && supplement.length !== 0 && { supplement: supplement.map((s: any) => ({ ...(s.type && { type: transformToCodeableConcept(s.type) }), ...(s.productName && { productName: s.productName }), ...(s.schedule && s.schedule.length !== 0 && { schedule: (s.schedule ?? []).map(transformToFhirTiming) }), ...(s.quantity && { quantity: transformToFhirQuantity(s.quantity) }), ...(s.instruction && { instruction: s.instruction }) })) }), ...(Object.keys(transformedEnteralFormula).length !== 0 && { enteralFormula: transformedEnteralFormula }) }; }, [ createReferenceObject, transformToCodeableConcept, formatDateTime, transformToFhirQuantity, getEncounterDisplay, transformToFhirTiming, transformToFhirRatio ]); const transformFromFhirNutritionOrder = useCallback((nutritionOrder: NutritionOrder): any => { const { status, intent, patient, encounter, dateTime, orderer, allergyIntolerance, foodPreferenceModifier, excludeFoodModifier, oralDiet, supplement, enteralFormula, note } = nutritionOrder; const formData = { status: status, intent: intent, patient: patient, encounter: encounter, dateTime: formatDateTimeForInput(dateTime), orderer: orderer, allergyIntolerance: allergyIntolerance ?? [], foodPreferenceModifier: transformFromCodeableConcept(foodPreferenceModifier ?? []), excludeFoodModifier: transformFromCodeableConcept(excludeFoodModifier ?? []), note: note ?? [], oralDiet: oralDiet ? { type: transformFromCodeableConcept(oralDiet.type ?? []), schedule: (oralDiet.schedule ?? []).map(transformFromFhirTiming), nutrient: (oralDiet.nutrient ?? []).map((n: NutritionOrderOralDietNutrient) => ({ modifier: transformFromCodeableConcept(n.modifier), amount: transformFromFhirQuantity(n.amount) })), texture: (oralDiet.texture ?? []).map((t: NutritionOrderOralDietTexture) => ({ modifier: transformFromCodeableConcept(t.modifier), foodType: transformFromCodeableConcept(t.foodType) })), fluidConsistencyType: transformFromCodeableConcept(oralDiet.fluidConsistencyType ?? []), instruction: oralDiet.instruction ?? '' } : {}, supplement: (supplement ?? []).map(s => ({ type: transformFromCodeableConcept(s.type), productName: s.productName ?? '', schedule: (s.schedule ?? []).map(transformFromFhirTiming), quantity: transformFromFhirQuantity(s.quantity), instruction: s.instruction ?? '' })), enteralFormula: enteralFormula ? { baseFormulaType: transformFromCodeableConcept(enteralFormula.baseFormulaType), baseFormulaProductName: enteralFormula.baseFormulaProductName ?? '', additiveType: transformFromCodeableConcept(enteralFormula.additiveType), additiveProductName: enteralFormula.additiveProductName ?? '', caloricDensity: transformFromFhirQuantity(enteralFormula.caloricDensity), routeofAdministration: transformFromCodeableConcept(enteralFormula.routeofAdministration), maxVolumeToDeliver: transformFromFhirQuantity(enteralFormula.maxVolumeToDeliver), administrationInstruction: enteralFormula.administrationInstruction ?? '', administration: (enteralFormula.administration ?? []).map((a: NutritionOrderEnteralFormulaAdministration) => ({ schedule: transformFromFhirTiming(a.schedule), quantity: transformFromFhirQuantity(a.quantity), rate: a.rateQuantity ? 'quantity' : a.rateRatio ? 'ratio' : '', rateQuantity: transformFromFhirQuantity(a.rateQuantity), rateRatio: transformFromFhirRatio(a.rateRatio) })), } : {} }; return formData; }, [ transformFromCodeableConcept, transformFromFhirQuantity, transformFromFhirTiming, transformFromFhirRatio, formatDateTimeForInput ]); const getNutritionOrderDisplay = (nutritionOrder: NutritionOrder): string => { if (!nutritionOrder) { return 'Unknown Nutrition Order'; } let orderName = 'Nutrition Order'; if (nutritionOrder.oralDiet?.type && nutritionOrder.oralDiet.type.length > 0) { orderName = formatFhirCodeableConcept(nutritionOrder.oralDiet.type[0]); } else if (nutritionOrder.enteralFormula?.baseFormulaType) { orderName = formatFhirCodeableConcept(nutritionOrder.enteralFormula.baseFormulaType); } else if (nutritionOrder.supplement && nutritionOrder.supplement.length > 0) { orderName = 'Nutritional Supplement'; } // - 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(nutritionOrder.status); const status = statusText ? ` ยท ${statusText}` : ''; let dateSuffix = ''; if (nutritionOrder.dateTime) { dateSuffix = ` (${formatDateTimeForDisplay(nutritionOrder.dateTime)})`; } return `${orderName}${status}${dateSuffix}`; }; return { nutritionOrdersBundle, // 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, nutritionOrdersList, setNutritionOrdersList, shouldReload, setShouldReload, lastUpserted, setLastUpserted, unsaved, setUnsaved, fetchNutritionOrders, fetchNutritionOrdersWithPolling, fetchNutritionOrder, fetchNutritionOrderHistory, createNutritionOrder, updateNutritionOrder, deleteNutritionOrder, archiveNutritionOrder, transformToFhirNutritionOrder, transformFromFhirNutritionOrder, getNutritionOrderDisplay }; }; const NutritionOrderServiceContext = createContext | undefined>(undefined); export const NutritionOrderServiceProvider: React.FC<{ children: ReactNode }> = ({ children }) => { const nutritionOrderService = useNutritionOrderService(); return ( {children} ); }; export const useNutritionOrderServiceContext = () => { const context = useContext(NutritionOrderServiceContext); if (context === undefined) { throw new Error('useNutritionOrderServiceContext must be used within a NutritionOrderServiceProvider'); } 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 useOptionalNutritionOrderServiceContext = () => useContext(NutritionOrderServiceContext);