import type { ReadOptions } from '../hooks/anchorView/readOptions'; import { isAsOfIncomplete } from '../utils/searchTruncation'; import { holdWrite, onViewerChange, overlayHeldBundle, startWrite, writtenSince } from '../utils/writeThroughHold'; import { asRecordChanged, ifMatch, PAGE_OUT_OF_DATE_MESSAGE, RecordChangedError } from '../utils/recordChanged'; import type { SaveTransport } from '../utils/saveOperation'; import { withNewer } from '../utils/versionOrder'; /** Whether `next` is an older version than `held` (unversioned records are never called older). */ const olderThan = (next: { meta?: { versionId?: string } }, held: { meta?: { versionId?: string } } | undefined): boolean => { if (!next.meta?.versionId || !held?.meta?.versionId) return false; const a = Number(next.meta.versionId); const b = Number(held.meta.versionId); return Number.isFinite(a) && Number.isFinite(b) && a < b; }; import { useState, useCallback, createContext, useContext, ReactNode, useEffect, useRef, useMemo } from 'react'; import { Bundle, AllergyIntolerance, Patient, AllergyIntoleranceReaction, CodeableConcept, Encounter, Practitioner, Reference, 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 { useEncounterServiceContext } from './EncounterService.tsx'; import { usePatientServiceContext } from './PatientService.tsx'; import { formatFhirCodeableConcept } from '../utils/fhirCodeableConcept.tsx'; import { applyArchive, isArchived, type ArchiveReason } from '../utils/archivedResource'; import { codeableStatusLabel } from '../utils/fhirStatusVocabulary.ts'; import { fetchAllPagesAsBundle } from '../utils/fetchAllPagesAsBundle'; import { searchPageSize } from '../utils/pageSizeOverride'; import { useCacheOwner } from '../utils/cacheOwner'; const API_URL = environment.fhirClinicalApi; /** What is known of one allergy: its newest version, whether it is active, removed (archived) or deleted, when a write * or broadcast (rather than a search) last taught it, and - while active - that newest copy itself, so a lagging read * can be shown it rather than an older one. */ export type AllergyFloorEntry = { version?: string; state: 'active' | 'removed' | 'deleted'; eventAt?: number; record?: AllergyIntolerance }; /** A search answered below what is already known of an allergy, with no copy at that version to show instead: the read * is reported as not yet settled rather than shown older, or as a list without it. */ export class AllergyReadBehindError extends Error { /** `bounded`: a read served as of a cutoff and not in full could not show what is known; nothing newer may stand in. */ constructor(public readonly bounded = false) { super(bounded ? 'This allergy list could not be read in full as it stood when access was limited.' : 'The allergy list is still catching up with a recent change. Try again in a moment.'); this.name = 'AllergyReadBehindError'; } } /** How long a row learned from a write or broadcast is kept while a lagging search has not caught up with it. */ const SEARCH_CATCH_UP_MS = 5 * 60 * 1000; /** * THE ONE PUBLICATION BOUNDARY FOR ALLERGIES: the newest version known of each, kept through its removal, per patient. * Every producer - this service's create, update, archive and delete answers, its searches, and the broadcast - is * admitted by it and teaches it, so whichever lands first an older copy never replaces a newer one, never puts back * a removed or deleted row, and a known newer row is never lost to a lagging search. * `store` is the patient-keyed memory (one per service); `patientOf` names a record's patient. */ export const createAllergyFloor = ( store: Map>, patientOf: (reference?: Reference) => string | string[] | undefined, now: () => number = Date.now, ) => { const versionsOf = (record: AllergyIntolerance) => { const named = patientOf(record.patient); const patientId = (Array.isArray(named) ? named[0] : named) ?? ''; let versions = store.get(patientId); if (!versions) { versions = new Map(); store.set(patientId, versions); } return versions; }; const floorOf = (record: AllergyIntolerance) => (record.id ? versionsOf(record).get(record.id) : undefined); /** Not older than what is known of it, and not deleted. An archive always moves the version on, so an active copy * at the very version known as removed is the copy from before it (an archive answered with no body). */ const admits = (record: AllergyIntolerance): boolean => { const floor = floorOf(record); if (!floor) return true; if (floor.state === 'deleted') return false; if (floor.state === 'removed' && !isArchived(record) && record.meta?.versionId === floor.version) return false; return !(floor.version && olderThan(record, { meta: { versionId: floor.version } })); }; /** A copy seen: admitted, it becomes what is known (removed if archived). Answers whether it was admitted. */ const learn = (record: AllergyIntolerance, via: 'event' | 'search' = 'event'): boolean => { if (!admits(record)) return false; if (record.id && record.meta?.versionId) { versionsOf(record).set(record.id, { version: record.meta.versionId, state: isArchived(record) ? 'removed' : 'active', eventAt: via === 'event' ? now() : undefined, record: isArchived(record) ? undefined : record, }); } return true; }; return { admits, learn, /** Taken off the list - archived at the version answered, or deleted. Answers whether it was admitted: an * archive older than a newer active version known is not a removal of that newer version. */ remove: (record: AllergyIntolerance, deleted = false): boolean => { if (!record.id) return false; if (deleted) { versionsOf(record).set(record.id, { version: record.meta?.versionId, state: 'deleted' }); return true; } return learn(record); }, /** * A search's entries for one patient, as they may be published. Each admitted entry teaches the floor. An older * copy is dropped when the record was removed or deleted since; otherwise the newest copy known is shown in its * place - the row held, or the copy the floor kept - and never the older one. With neither at hand the read is * reported as not yet settled (AllergyReadBehindError), never published below the floor. A row a write or * broadcast taught moments ago that the search has not caught up with is kept the same way. */ // `incomplete`: a search served as of a cutoff and not in full. It may show only what was served: an entry below // what is known is not settled (never replaced by the newer copy), and no recently taught row is added to it. settle: (entries: AllergyIntolerance[], held: AllergyIntolerance[], patientId: string, incomplete = false): AllergyIntolerance[] => { const newestAtHand = (id: string, floor: AllergyFloorEntry) => [held.find((row) => row.id === id), floor.record].find( (candidate): candidate is AllergyIntolerance => !!candidate && !isArchived(candidate) && !(floor.version && olderThan(candidate, { meta: { versionId: floor.version } })), ); const settled: AllergyIntolerance[] = []; const seen = new Set(); for (const entry of entries) { if (entry.id) seen.add(entry.id); if (learn(entry, 'search')) { settled.push(entry); continue; } const floor = floorOf(entry); if (!entry.id || !floor || floor.state !== 'active') continue; if (incomplete) throw new AllergyReadBehindError(true); const newest = newestAtHand(entry.id, floor); if (!newest) throw new AllergyReadBehindError(); settled.push(newest); } for (const [id, floor] of incomplete ? [] : store.get(patientId) ?? []) { if (seen.has(id) || floor.state !== 'active' || !floor.eventAt || now() - floor.eventAt >= SEARCH_CATCH_UP_MS) continue; const newest = newestAtHand(id, floor); if (newest) settled.push(newest); } return settled; }, }; }; export type AllergyFloor = ReturnType; export const useAllergyIntoleranceService = () => { const { get, post, put, delete: del } = useHttpService(); const { patientState } = usePatientServiceContext(); const { transformToCodeableConcept, transformFromCodeableConcept, createReferenceObject, formatDateTime, formatDateTimeForInput, addFhirPeriodIfApplicable, addPeriodControlsIfApplicable, extractReferenceType, unpackBundle, transformToFhirAge, transformFromFhirAge, extractReferenceId } = useFhirServiceContext(); const { getEncounterDisplay } = useEncounterServiceContext(); const [allergyIntolerancesList, setAllergyIntolerancesList] = useState([]); const [shouldReload, setShouldReload] = useState(false); const [lastUpserted, setLastUpserted] = useState(null); const [unsaved, setUnsaved] = useState(false); const [allergyIntolerancesBundle, setAllergyIntolerancesBundle] = useState | null>(null); const allergyIntolerancesBundleRef = useRef | null>(null); // THE NEWEST VERSION KNOWN OF EACH ALLERGY, KEPT THROUGH ITS REMOVAL, per patient. This service's own answers // (create, update, archive, delete) and the broadcast both learn from it and are admitted by it, so whichever // arrives first, an older copy never puts back a row that was archived or deleted, or replaces a newer one. const allergyFloors = useRef(new Map>()); const allergyFloor = useMemo(() => createAllergyFloor(allergyFloors.current, extractReferenceId), [extractReferenceId]); // What is known of each allergy is the signed-in person's: another viewer starts from nothing. useEffect(() => onViewerChange(() => allergyFloors.current.clear()), []); // The list as held now: what a search's older copies give way to. const heldList = useRef([]); const lastFetchTimeRef = useRef(0); const allergyPatientId = useCallback((allergy: AllergyIntolerance): string | undefined => { const extracted = extractReferenceId(allergy.patient); return Array.isArray(extracted) ? extracted[0] : extracted; }, [extractReferenceId]); useEffect(() => { allergyIntolerancesBundleRef.current = allergyIntolerancesBundle; }, [allergyIntolerancesBundle]); heldList.current = allergyIntolerancesList; /** * WHOSE ALLERGIES 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. * * On an allergy list that is a safety question rather than a cosmetic one, and it looks exactly like * a patient who has none. */ // Which full list read is published, for whom and when it started: a reader asking whether its list was read under // a newer entitlement looks here, never at its own request (a superseded read resolves without publishing). const [listRead, setListRead] = useState<{ patientId?: string; startedAt: number } | undefined>(undefined); const cacheOwner = useCacheOwner('AllergyIntolerance', () => { setLastUpserted(null); setAllergyIntolerancesBundle(null); setAllergyIntolerancesList([]); lastFetchTimeRef.current = 0; }); useEffect(() => { if (!patientState) { setAllergyIntolerancesBundle(null); setAllergyIntolerancesList([]); lastFetchTimeRef.current = 0; cacheOwner.release(); } }, [patientState, cacheOwner.release]); const fetchAllergyIntolerances = useCallback(async (patient: Patient, options?: ReadOptions): Promise> => { const bundle = 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); // get(`${environment.fhirFoundationApi}/Provenance?target=AllergyIntolerance/d1932a83-22a0-482e-997c-fe6a5328ff48`); return get>(`${API_URL}/AllergyIntolerance?${params.toString()}`, options); }); const overlaid = overlayHeldBundle('AllergyIntolerance', bundle, (allergy) => allergyPatientId(allergy) === patient.id) as Bundle; // Through the one publication boundary: a lagging search never reinstalls an older or removed copy, and // teaches what it finds (an archive it discovers keeps a later old echo out). const entries = overlaid.entry ?? []; const held = heldList.current.filter((allergy) => allergyPatientId(allergy) === patient.id); const settled = allergyFloor.settle(entries.map((entry) => entry.resource).filter(Boolean) as AllergyIntolerance[], held, patient.id as string, isAsOfIncomplete(bundle)); return { ...overlaid, entry: settled.map((resource) => entries.find((entry) => entry.resource === resource) ?? { resource }), }; }, [get, allergyPatientId, allergyFloor]); const fetchAllergyIntolerancesWithPolling = 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('AllergyIntolerance', lastFetchTimeRef.current) && allergyIntolerancesBundleRef.current && cacheFresh && cacheOwner.isCurrentFor(patient.id)) { return allergyIntolerancesBundleRef.current; } const operation = cacheOwner.begin(); /* * WHAT USED TO BE HERE AND WHY IT COULD NOT WORK - see writeThroughHold. A retry loop * polled this search up to ten times at three seconds, waiting for the id just written to turn * up, and served the list without it when the thirty seconds ran out. * * ON AN ALLERGY LIST THAT IS THE SAFETY QUESTION THIS FILE ALREADY WORRIES ABOUT ABOVE. An * allergy recorded and then absent looks exactly like a patient who does not have it, and it * is the same shape as the stale-bundle defect the cache owner was added for. * * A LONGER LOOP IS NOT THE FIX: the lag is not a constant to tune against. Measured on * 2026-08-31, a RelatedPerson was findable inside 14.4 seconds and a Task was still missing at * 2m47s. Holding what the write returned is right at both ends. */ let published: Bundle; try { published = await fetchAllergyIntolerances(patient); } catch (error) { // A limited read that could not show what is known: what was published before (a newer copy this read // may not show) is retired with it, and the list is no longer this patient's answer. if (error instanceof AllergyReadBehindError && error.bounded && cacheOwner.publishes(operation)) { allergyIntolerancesBundleRef.current = null; setAllergyIntolerancesBundle(null); setAllergyIntolerancesList([]); lastFetchTimeRef.current = 0; cacheOwner.release(); } throw error; } if (!cacheOwner.publishes(operation)) return cacheOwner.answerFor(operation, published); setAllergyIntolerancesBundle(published); setAllergyIntolerancesList(unpackBundle(published).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 in state. cacheOwner.claim(patient.id, operation); setListRead({ patientId: patient.id, startedAt: now }); return published; // `lastUpserted` is no longer read here - the hold replaced the wait for it - so it is not a // dependency. It is still SET by a write, because it is what the write has to say. }, [fetchAllergyIntolerances, unpackBundle, allergyPatientId, shouldReload]); // `fresh`: the record as stored now, never the cached copy - what a refused save shows as "saved now". const fetchAllergyIntolerance = useCallback(async (id: string, fresh = false, transport?: SaveTransport): Promise => { const cachedAllergy = !fresh && cacheOwner.isCurrent(cacheOwner.claimed) ? allergyIntolerancesList.find(allergy => allergy.id === id) : undefined; if (cachedAllergy) { return cachedAllergy; } return get(`${API_URL}/AllergyIntolerance/${id}`, { ...transport }); }, [get, allergyIntolerancesList]); const fetchAllergyIntoleranceHistory = useCallback(async (id: string, options?: ReadOptions): Promise> => { return get>(`${API_URL}/AllergyIntolerance/${id}/_history`, options); }, [get]); // `transport`: the save this belongs to. Its answer is published to the list only while that save still belongs. const createAllergyIntolerance = useCallback(async (allergyIntolerance: AllergyIntolerance, transport?: SaveTransport): Promise => { const params = new URLSearchParams(); params.append('patient', extractReferenceId(allergyIntolerance.patient) as string); const createdAllergyIntolerance = await post(`${API_URL}/AllergyIntolerance?${params.toString()}`, allergyIntolerance, { ...transport }); transport?.admit(); // The create's answer can land after a newer version of it already arrived (by broadcast): it is published // only where it is not older than what is held, once per record, and never after the record was removed. if (createdAllergyIntolerance?.resourceType === 'AllergyIntolerance' && createdAllergyIntolerance.id && allergyFloor.admits(createdAllergyIntolerance)) { allergyFloor.learn(createdAllergyIntolerance); setAllergyIntolerancesList(prev => prev.some(existingAllergy => existingAllergy.id === createdAllergyIntolerance.id) ? withNewer(prev, createdAllergyIntolerance) : [createdAllergyIntolerance, ...prev] ); if (allergyIntolerancesBundleRef.current) { const entries = allergyIntolerancesBundleRef.current.entry || []; const held = entries.find(entry => entry.resource?.id === createdAllergyIntolerance.id)?.resource; setAllergyIntolerancesBundle({ ...allergyIntolerancesBundleRef.current, entry: held ? entries.map(entry => entry.resource?.id === createdAllergyIntolerance.id && !olderThan(createdAllergyIntolerance, held) ? { ...entry, resource: createdAllergyIntolerance } : entry) : [{ resource: createdAllergyIntolerance }, ...entries] }); } setLastUpserted(createdAllergyIntolerance.id); setShouldReload(false); } else { setShouldReload(true); } return createdAllergyIntolerance; }, [post, extractReferenceId, allergyFloor]); // CONDITIONAL: written only over `basedOn`, the version the editor opened on (or reviewed). A refusal is a // RecordChangedError: 412 another write landed first, 428 this page named no version. // `transport`: the save this belongs to - the request is admitted and cancelled with it, and its answer is put in // this service's lists only while the save still belongs to what is on screen. const updateAllergyIntolerance = useCallback(async (allergyIntolerance: AllergyIntolerance, basedOn: string | undefined, transport?: SaveTransport): Promise => { const allergyIntoleranceId = allergyIntolerance.id; if (!allergyIntoleranceId) { throw new Error('Cannot update AllergyIntolerance without an id.'); } if (!basedOn) throw new RecordChangedError(PAGE_OUT_OF_DATE_MESSAGE, true); const startedIn = startWrite(); const responseAllergyIntolerance = await put(`${API_URL}/AllergyIntolerance/${allergyIntoleranceId}`, allergyIntolerance, { headers: ifMatch(basedOn), ...transport }) .catch((error: unknown) => { throw asRecordChanged(error); }); transport?.admit(); const updatedAllergyIntolerance: AllergyIntolerance = responseAllergyIntolerance?.resourceType === 'AllergyIntolerance' && responseAllergyIntolerance.id ? responseAllergyIntolerance : allergyIntolerance; // Superseded or removed meanwhile (a newer version, an archive or a delete already known): nothing of this // answer is published or held - it would put an older or removed copy back. if (!allergyFloor.learn(updatedAllergyIntolerance)) return updatedAllergyIntolerance; // A save that answered with no resource is held as what was sent. holdWrite(updatedAllergyIntolerance, Date.now(), startedIn); // Only forward: a version already superseded in the list (by a later broadcast or save) is not put back. setAllergyIntolerancesList(prev => prev.map(existingAllergy => existingAllergy.id === allergyIntoleranceId && !olderThan(updatedAllergyIntolerance, existingAllergy) ? updatedAllergyIntolerance : existingAllergy ) ); if (allergyIntolerancesBundleRef.current?.entry) { setAllergyIntolerancesBundle({ ...allergyIntolerancesBundleRef.current, entry: allergyIntolerancesBundleRef.current.entry.map(entry => ({ ...entry, resource: entry.resource?.id === allergyIntoleranceId && !olderThan(updatedAllergyIntolerance, entry.resource as AllergyIntolerance) ? updatedAllergyIntolerance : entry.resource })) }); } setLastUpserted(updatedAllergyIntolerance.id || allergyIntoleranceId); setShouldReload(false); return updatedAllergyIntolerance; }, [put, allergyFloor]); const deleteAllergyIntolerance = useCallback(async (allergyIntolerance: AllergyIntolerance): Promise => { const deletedAllergyIntolerance = await del(`${API_URL}/AllergyIntolerance/${allergyIntolerance.id}`); // Deleted: no later echo of it is put back. allergyFloor.remove(allergyIntolerance, true); setAllergyIntolerancesList(prev => prev.filter(existingAllergy => existingAllergy.id !== allergyIntolerance.id) ); if (allergyIntolerancesBundleRef.current?.entry) { setAllergyIntolerancesBundle({ ...allergyIntolerancesBundleRef.current, entry: allergyIntolerancesBundleRef.current.entry.filter( entry => entry.resource?.id !== allergyIntolerance.id ) }); } setShouldReload(false); return deletedAllergyIntolerance; }, [del, allergyFloor]); // Archiving writes the whole record as held, so it too is written only over the version held. `transport`: the // archive this belongs to; the list drops the record only while it still belongs. const archiveAllergyIntolerance = useCallback(async (allergyIntolerance: AllergyIntolerance, reason: ArchiveReason, note?: string, transport?: SaveTransport): Promise => { const basedOn = allergyIntolerance.meta?.versionId; if (!basedOn) throw new RecordChangedError(PAGE_OUT_OF_DATE_MESSAGE, true); const archivedAllergyIntolerance = await put(`${API_URL}/AllergyIntolerance/${allergyIntolerance.id}`, applyArchive(allergyIntolerance, reason, note), { headers: ifMatch(basedOn), ...transport }) .catch((error: unknown) => { throw asRecordChanged(error); }); transport?.admit(); // Archived at the version answered: an older copy arriving after this (the create echo) is not put back. If a // newer active version is already known (it arrived while this answer was held), that newer row is kept. if (!allergyFloor.remove(archivedAllergyIntolerance?.id ? archivedAllergyIntolerance : applyArchive(allergyIntolerance, reason, note))) { return archivedAllergyIntolerance; } setAllergyIntolerancesList(prev => prev.filter(existingAllergy => existingAllergy.id !== allergyIntolerance.id) ); if (allergyIntolerancesBundleRef.current?.entry) { setAllergyIntolerancesBundle({ ...allergyIntolerancesBundleRef.current, entry: allergyIntolerancesBundleRef.current.entry.filter( entry => entry.resource?.id !== allergyIntolerance.id ) }); } setShouldReload(false); return archivedAllergyIntolerance; }, [put, allergyFloor]); const transformToFhirAllergyIntolerance = useCallback((item: any, patient?: Patient, encounterState?: Encounter, recorder?: Practitioner | Patient | RelatedPerson): AllergyIntolerance => { const { encounter, clinicalStatus, verificationStatus, type, category, criticality, code, onsetType, onsetDateTime, onsetAge, onsetString, onsetRange, recordedDate, lastOccurrence, note, reaction, meta } = item; const allergyIntoleranceProfile: string = 'http://hl7.org/fhir/us/core/StructureDefinition/us-core-allergyintolerance'; const allergyIntolerance: AllergyIntolerance = { resourceType: 'AllergyIntolerance', meta: { ...meta, profile: [allergyIntoleranceProfile] }, ...(patient && { patient: createReferenceObject('Patient', patient.id as string, formatFhirHumanName(patient.name)) }), ...(recorder && { recorder: createReferenceObject(recorder.resourceType, recorder.id as string, formatFhirHumanName(recorder.name)) }), ...(encounterState && { encounter: createReferenceObject('Encounter', encounterState?.id as string, getEncounterDisplay(encounterState)) }), ...(encounter && { encounter: encounter }), ...(clinicalStatus && { clinicalStatus: transformToCodeableConcept(clinicalStatus) }), ...(verificationStatus && { verificationStatus: transformToCodeableConcept(verificationStatus) }), ...(type && { type: type }), ...(category && category.length !== 0 && { category: category }), ...(criticality && { criticality: criticality }), ...(code && { code: transformToCodeableConcept(code) }), ...(onsetType === 'dateTime' && onsetDateTime && { onsetDateTime: formatDateTime(onsetDateTime) }), ...(onsetType === 'age' && onsetAge && { onsetAge: transformToFhirAge(onsetAge) }), ...(onsetType === 'string' && onsetString && { onsetString: onsetString }), ...(onsetType === 'range' && onsetRange && { onsetRange: onsetRange }), ...(recordedDate && { recordedDate: formatDateTime(recordedDate) }), ...(lastOccurrence && { lastOccurrence: formatDateTime(lastOccurrence) }), ...(note?.length !== 0 && { note: note }), ...(reaction && reaction.length !== 0 && { reaction: reaction.map((reaction: any) => ({ manifestation: transformToCodeableConcept(reaction.manifestation) as CodeableConcept[], ...(reaction.substance && { substance: transformToCodeableConcept(reaction.substance) }), ...(reaction.description && { description: reaction.description }), ...(reaction.onset && { onset: formatDateTime(reaction.onset) }), ...(reaction.severity && { severity: reaction.severity }), ...(reaction.exposureRoute && { exposureRoute: transformToCodeableConcept(reaction.exposureRoute) as CodeableConcept }), ...(reaction.note?.length !== 0 && { note: reaction.note }) })) }) }; if (onsetType === 'period') { return addFhirPeriodIfApplicable(allergyIntolerance, item, 'onsetPeriod'); } return allergyIntolerance; }, [ createReferenceObject, transformToCodeableConcept, addFhirPeriodIfApplicable, getEncounterDisplay, transformToFhirAge, formatDateTime ]); const transformFromFhirAllergyIntolerance = useCallback((allergyIntolerance: AllergyIntolerance): any => { const { recorder, asserter, encounter, clinicalStatus, verificationStatus, type, category, criticality, code, onsetDateTime, onsetAge, onsetPeriod, onsetRange, onsetString, recordedDate, lastOccurrence, note, reaction, meta } = allergyIntolerance; let onsetType = ''; if (onsetDateTime) onsetType = 'dateTime'; else if (onsetAge) onsetType = 'age'; else if (onsetPeriod) onsetType = 'period'; else if (onsetRange) onsetType = 'range'; else if (onsetString) onsetType = 'string'; const formData = { meta: meta, recorderType: extractReferenceType(recorder), recorder: recorder ?? null, asserterType: extractReferenceType(asserter), asserter: asserter ?? null, encounter: encounter ?? null, clinicalStatus: transformFromCodeableConcept(clinicalStatus), verificationStatus: transformFromCodeableConcept(verificationStatus), type: type ?? '', category: category ?? [], criticality: criticality ?? '', code: transformFromCodeableConcept(code) ?? null, onsetType: onsetType, onsetDateTime: formatDateTimeForInput(onsetDateTime), onsetAge: transformFromFhirAge(onsetAge), onsetString: onsetString, onsetRange: onsetRange, recordedDate: formatDateTimeForInput(recordedDate), lastOccurrence: formatDateTimeForInput(lastOccurrence), note: note ?? [], reaction: (reaction ?? []).map((reaction: AllergyIntoleranceReaction) => { return { substance: transformFromCodeableConcept(reaction.substance), manifestation: transformFromCodeableConcept(reaction.manifestation ?? []), description: reaction.description ?? '', onset: formatDateTimeForInput(reaction.onset), severity: reaction.severity ?? '', exposureRoute: transformFromCodeableConcept(reaction.exposureRoute), note: reaction.note ?? [] }; }) }; if (onsetPeriod) { return addPeriodControlsIfApplicable(formData, allergyIntolerance, 'onsetPeriod'); } return formData; }, [transformFromCodeableConcept, extractReferenceType, transformFromFhirAge, formatDateTimeForInput]); const getAllergyIntoleranceDisplay = (allergyIntolerance: AllergyIntolerance): string => { if (!allergyIntolerance) { return 'Unknown Allergy/Intolerance'; } // - was ` [${...}]`. It also used `formatFhirCodeableConcept`, which falls back to the // raw CODE when the terminology supplies no display, so an allergy could read `[resolved]`. // `codeableStatusLabel` omits rather than leaking a code `utils/fhirStatusVocabulary.ts`. const allergyName = formatFhirCodeableConcept(allergyIntolerance.code) || 'Unknown Substance'; const clinicalStatus = codeableStatusLabel(allergyIntolerance.clinicalStatus); const statusSuffix = clinicalStatus ? ` · ${clinicalStatus}` : ''; const criticalitySuffix = allergyIntolerance.criticality === 'high' ? ' HIGH CRITICALITY' : ''; const reactionCount = allergyIntolerance.reaction?.length || 0; const reactionSuffix = reactionCount > 0 ? ` · ${reactionCount} reaction${reactionCount !== 1 ? 's' : ''}` : ''; const categorySuffix = allergyIntolerance.category && allergyIntolerance.category.length > 0 ? ` (${allergyIntolerance.category.join(', ')})` : ''; return `${allergyName}${statusSuffix}${criticalitySuffix}${reactionSuffix}${categorySuffix}`; }; const getAllergyIntoleranceSeverityInfo = (allergyIntolerance: AllergyIntolerance): { hasCritical: boolean, hasHigh: boolean, text: string } => { if (!allergyIntolerance || !allergyIntolerance.reaction) { return { hasCritical: false, hasHigh: false, text: '' }; } const severities = allergyIntolerance.reaction .map(reaction => reaction.severity) .filter(severity => severity); const hasCritical = severities.includes('severe'); const hasHigh = allergyIntolerance.criticality === 'high'; if (hasCritical && hasHigh) { return { hasCritical: true, hasHigh: true, text: 'Severe reactions, High criticality' }; } if (hasCritical) { return { hasCritical: true, hasHigh: false, text: 'Severe reactions' }; } if (hasHigh) { return { hasCritical: false, hasHigh: true, text: 'High criticality' }; } return { hasCritical: false, hasHigh: false, text: '' }; }; return { allergyIntolerancesBundle, allergyIntolerancesList, // 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 that has no patient in its name. cachedForPatientId: cacheOwner.ownerId, listRead, setAllergyIntolerancesList, allergyFloor, shouldReload, setShouldReload, lastUpserted, setLastUpserted, unsaved, setUnsaved, fetchAllergyIntolerances, fetchAllergyIntolerancesWithPolling, fetchAllergyIntolerance, fetchAllergyIntoleranceHistory, createAllergyIntolerance, updateAllergyIntolerance, deleteAllergyIntolerance, archiveAllergyIntolerance, transformToFhirAllergyIntolerance, transformFromFhirAllergyIntolerance, getAllergyIntoleranceDisplay }; }; const AllergyIntoleranceServiceContext = createContext | undefined>(undefined); export const AllergyIntoleranceServiceProvider: React.FC<{ children: ReactNode }> = ({ children }) => { const allergyIntoleranceService = useAllergyIntoleranceService(); return ( {children} ); }; /** Absent outside its provider, for readers that must never take a surface down with them. */ export const useOptionalAllergyIntoleranceServiceContext = () => useContext(AllergyIntoleranceServiceContext); export const useAllergyIntoleranceServiceContext = () => { const context = useContext(AllergyIntoleranceServiceContext); if (context === undefined) { throw new Error('useAllergyIntoleranceServiceContext must be used within an AllergyIntoleranceServiceProvider'); } return context; };