import { withRead, type ReadOptions } from '../hooks/anchorView/readOptions'; import { isHeld, overlayHeldBundle, writtenSince } from '../utils/writeThroughHold'; import { useState, useCallback, createContext, useContext, ReactNode, useEffect, useRef } from 'react'; import { Bundle, CarePlan, Patient, CarePlanActivity, CarePlanActivityDetail, Encounter, CodeableConcept, Practitioner, 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 { carePlanName } from '../utils/carePlanName'; import { useEncounterServiceContext } from './EncounterService.tsx'; import { IValueSet } from './ValueSetService.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'; import { asRecordChanged, ifMatch } from '../utils/recordChanged'; import type { SaveTransport } from '../utils/saveOperation'; const API_URL = environment.fhirClinicalApi; export const useCarePlanService = () => { const { get, post, put, delete: del } = useHttpService(); const { patientState } = usePatientServiceContext(); const { transformToCodeableConcept, transformFromCodeableConcept, createReferenceObject, formatDateTime, formatDateTimeForInput, formatDateTimeForDisplay, unpackBundle, addFhirPeriodIfApplicable, addPeriodControlsIfApplicable, transformToFhirQuantity, transformFromFhirQuantity, transformToReferenceCollection, transformFromReferenceCollection, transformToFhirTiming, transformFromFhirTiming, extractReferenceId } = useFhirServiceContext(); const { getEncounterDisplay } = useEncounterServiceContext(); const [carePlansList, setCarePlansList] = useState([]); const [shouldReload, setShouldReload] = useState(false); const [lastUpserted, setLastUpserted] = useState(null); const [unsaved, setUnsaved] = useState(false); const [carePlansBundle, setCarePlansBundle] = useState | null>(null); const carePlansBundleRef = useRef | null>(null); const lastFetchTimeRef = useRef(0); useEffect(() => { carePlansBundleRef.current = carePlansBundle; }, [carePlansBundle]); // 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('CarePlan', () => { setLastUpserted(null); setCarePlansBundle(null); setCarePlansList([]); lastFetchTimeRef.current = 0; }); useEffect(() => { if (!patientState) { setCarePlansBundle(null); setCarePlansList([]); lastFetchTimeRef.current = 0; cacheOwner.release(); } }, [patientState, cacheOwner.release]); const fetchCarePlans = useCallback(async (patient: Patient, transport?: SaveTransport, options?: ReadOptions): Promise> => { const bundle = 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}/CarePlan?${params.toString()}`, withRead({ ...transport }, options)); }); return overlayHeldBundle('CarePlan', bundle, (plan) => extractReferenceId(plan.subject) === patient.id) as Bundle; }, [get, extractReferenceId]); const fetchCarePlansWithPolling = 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('CarePlan', lastFetchTimeRef.current) && carePlansBundleRef.current && cacheFresh && cacheOwner.isCurrentFor(patient.id)) { return carePlansBundleRef.current; } const operation = cacheOwner.begin(); let retries = 10; const delay = 3000; let found = false; let bundle: Bundle | null = null; let entries: CarePlan[]; do { bundle = await fetchCarePlans(patient); entries = unpackBundle(bundle).filter((r) => !isArchived(r)); found = lastUpserted ? entries.some(e => e.id === lastUpserted) || isHeld('CarePlan', 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); setCarePlansBundle(bundle); setCarePlansList(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; }, [fetchCarePlans, unpackBundle, lastUpserted, shouldReload]); // `fresh`: the plan as stored now, never the list's copy - what an editor refused for a changed plan shows. // `transport`: the editor operation a read belongs to - admitted before it is sent and cancelled with it. const fetchCarePlan = useCallback(async (id: string, fresh = false, transport?: SaveTransport): Promise => { const cachedCarePlan = !fresh && cacheOwner.isCurrent(cacheOwner.claimed) ? carePlansList.find(carePlan => carePlan.id === id) : undefined; if (cachedCarePlan) { return cachedCarePlan; } return get(`${API_URL}/CarePlan/${id}`, { ...transport }); }, [get, carePlansList]); const fetchCarePlanHistory = useCallback(async (id: string, options?: ReadOptions): Promise> => { return get>(`${API_URL}/CarePlan/${id}/_history`, options); }, [get]); const createCarePlan = useCallback(async (carePlan: CarePlan, transport?: SaveTransport): Promise => { let params = new URLSearchParams(); params.append('subject', extractReferenceId(carePlan.subject) as string); const created = await post(`${API_URL}/CarePlan?${params.toString()}`, carePlan, { ...transport }); return created; }, [post, extractReferenceId]); // CONDITIONAL: written only over `basedOn`, the version of the stored plan the edit was made on, never the version // echoed in the body. A refusal is a RecordChangedError (412 the plan changed; 428 no version was named). const updateCarePlan = useCallback(async (carePlan: CarePlan, basedOn: string | undefined, transport?: SaveTransport): Promise => { try { return await put(`${API_URL}/CarePlan/${carePlan.id}`, carePlan, { ...(basedOn ? { headers: ifMatch(basedOn) } : {}), ...transport }); } catch (error) { throw asRecordChanged(error); } }, [put]); const deleteCarePlan = useCallback(async (carePlan: CarePlan): Promise => { return del(`${API_URL}/CarePlan/${carePlan.id}`); }, [del]); // The archive is built on the stored plan it is given, so it is written only over that plan's version. const archiveCarePlan = useCallback( async (carePlan: CarePlan, reason: ArchiveReason, note?: string, transport?: SaveTransport): Promise => updateCarePlan(applyArchive(carePlan, reason, note), carePlan.meta?.versionId, transport), [updateCarePlan], ); const transformToFhirCarePlan = useCallback((item: any, patient?: Patient, encounterState?: Encounter, author?: Practitioner | Patient | RelatedPerson): CarePlan => { const { basedOn, replaces, partOf, status, intent, category, title, description, encounter, created, contributor, careTeam, addresses, goal, activity, note, meta } = item; const carePlanProfile: string = 'http://hl7.org/fhir/us/core/StructureDefinition/us-core-careplan'; const transformedActivity: CarePlanActivity[] = (activity ?? []).map((act: any) => { const { outcomeCodeableConcept, reference, progress, detail } = act; const transformedReference = transformToReferenceCollection(reference); const carePlanActivity: CarePlanActivity = { ...(outcomeCodeableConcept && outcomeCodeableConcept.length !== 0 && { outcomeCodeableConcept: transformToCodeableConcept(outcomeCodeableConcept) }), ...(transformedReference && transformedReference.length !== 0 && { reference: transformedReference[0] }), ...(progress && progress.length !== 0 && { progress: progress }), }; if (detail) { const { status, kind, code, reasonCode, reasonReference, goal, statusReason, doNotPerform, scheduled, scheduledTiming, location, performer, productCodeableConcept, productReference, dailyAmount, quantity, description } = detail; const transformedDailyAmount = transformToFhirQuantity(dailyAmount); const transformedQuantity = transformToFhirQuantity(quantity); const transformedReasonReference = transformToReferenceCollection(reasonReference); const transformedPerformer = transformToReferenceCollection(performer); const carePlanActivityDetail: CarePlanActivityDetail = { status: status, ...(kind && { kind: kind }), ...(code && { code: transformToCodeableConcept(code) }), ...(reasonCode && reasonCode.length !== 0 && { reasonCode: transformToCodeableConcept(reasonCode) }), ...(transformedReasonReference && transformedReasonReference.length !== 0 && { reasonReference: transformedReasonReference }), ...(goal && goal.length !== 0 && { goal: goal }), ...(statusReason && { statusReason: transformToCodeableConcept(statusReason) }), ...(doNotPerform !== undefined && { doNotPerform: doNotPerform }), ...(scheduled === 'timing' && scheduledTiming && { scheduledTiming: transformToFhirTiming(scheduledTiming) }), ...(location && { location: location }), ...(transformedPerformer && transformedPerformer.length !== 0 && { performer: transformedPerformer }), ...(productCodeableConcept && { productCodeableConcept: transformToCodeableConcept(productCodeableConcept) }), ...(productReference && { productReference: createReferenceObject(productReference.resourceType, productReference.id) }), ...(description && { description: description }), ...(transformedDailyAmount && { dailyAmount: transformedDailyAmount }), ...(transformedQuantity && { quantity: transformedQuantity }) }; // One schedule choice, the one selected: a period kept from before a switch to Timing is not sent too. carePlanActivity.detail = scheduled === 'period' ? addFhirPeriodIfApplicable(carePlanActivityDetail, detail, 'scheduledPeriod') : carePlanActivityDetail; } return carePlanActivity; }); let carePlan: CarePlan = { resourceType: 'CarePlan', status: status, intent: intent, meta: { ...meta, profile: [carePlanProfile] }, text: { status: 'additional', div: `
${title || 'Untitled Care Plan'}
` }, category: [ // required by US Core ...(!category.find((cat: IValueSet) => cat.code === 'assess-plan') ? [transformToCodeableConcept({ system: 'http://hl7.org/fhir/us/core/CodeSystem/careplan-category', code: 'assess-plan', display: 'Assessment and Plan of Treatment' }) as CodeableConcept] : []), ...transformToCodeableConcept(category) as CodeableConcept[] ], ...(patient && { subject: createReferenceObject('Patient', patient.id as string, formatFhirHumanName(patient.name)) }), ...(author && { author: createReferenceObject(author.resourceType, author.id as string, formatFhirHumanName(author.name)) }), ...(encounterState && { encounter: createReferenceObject('Encounter', encounterState?.id as string, getEncounterDisplay(encounterState)) }), ...(encounter && { encounter: encounter }), ...(basedOn && basedOn.length !== 0 && { basedOn: basedOn }), ...(replaces && replaces.length !== 0 && { replaces: replaces }), ...(partOf && partOf.length !== 0 && { partOf: partOf }), ...(title && { title: title }), ...(description && { description: description }), ...(created && { created: formatDateTime(created) }), //...(author && { author: createReferenceObject(author.resourceType, author.id) }), ...(note && note.length !== 0 && { note: note }), ...(contributor && contributor.length !== 0 && { contributor: contributor }), ...(careTeam && careTeam.length !== 0 && { careTeam: careTeam }), ...(addresses && addresses.length !== 0 && { addresses: addresses }), ...(goal && goal.length !== 0 && { goal: goal }), ...(transformedActivity && transformedActivity.length !== 0 && { activity: transformedActivity }) }; return addFhirPeriodIfApplicable(carePlan, item) as CarePlan; }, [ createReferenceObject, transformToCodeableConcept, formatDateTime, transformToFhirQuantity, getEncounterDisplay, transformToReferenceCollection ]); const transformFromFhirCarePlan = useCallback((carePlan: CarePlan): any => { const { basedOn, replaces, partOf, status, intent, category, title, description, encounter, created, contributor, careTeam, addresses, goal, activity, note, meta } = carePlan; const formData = { meta: meta, basedOn: basedOn ?? [], replaces: replaces ?? [], partOf: partOf ?? [], status: status, intent: intent, category: transformFromCodeableConcept((category ?? []).filter(cat => transformFromCodeableConcept(cat).code !== 'assess-plan')), title: title ?? '', description: description ?? '', encounter: encounter, created: formatDateTimeForInput(created), contributor: contributor ?? [], careTeam: careTeam ?? [], addresses: addresses ?? [], goal: goal ?? [], note: note ?? [], activity: (activity ?? []).map((act: CarePlanActivity) => { const { outcomeCodeableConcept, outcomeReference, reference, progress, detail } = act; const extendedActivity: any = { outcomeCodeableConcept: transformFromCodeableConcept(outcomeCodeableConcept ?? []), outcomeReference: transformFromReferenceCollection(outcomeReference || []), reference: transformFromReferenceCollection(reference ? [reference] : []), progress: progress ?? [], detailType: 'other' }; if (detail) { const { status, kind, code, reasonCode, reasonReference, goal, statusReason, doNotPerform, scheduledTiming, scheduledPeriod, location, performer, productCodeableConcept, productReference, dailyAmount, quantity, description } = detail; extendedActivity.detail = { status: status, kind: kind ?? '', code: transformFromCodeableConcept(code), reasonCode: transformFromCodeableConcept(reasonCode ?? []), reasonReference: transformFromReferenceCollection(reasonReference ?? []), goal: goal ?? [], statusReason: transformFromCodeableConcept(statusReason), doNotPerform: doNotPerform ?? false, scheduled: scheduledTiming ? 'timing' : scheduledPeriod ? 'period' : '', scheduledTiming: transformFromFhirTiming(scheduledTiming), location: location, performer: transformFromReferenceCollection(performer ?? []), productCodeableConcept: transformFromCodeableConcept(productCodeableConcept), productReference: productReference, dailyAmount: transformFromFhirQuantity(dailyAmount), quantity: transformFromFhirQuantity(quantity), description: description ?? '' }; if (code) { const detailCode = code.coding?.[0]?.code?.toLowerCase() || ''; if (detailCode.includes('appointment') || detailCode.includes('visit')) { extendedActivity.detailType = 'appointment'; } else if (detailCode.includes('referral') || detailCode.includes('consult')) { extendedActivity.detailType = 'referral'; } else if (detailCode.includes('medication') || detailCode.includes('drug')) { extendedActivity.detailType = 'medication'; } else if (detailCode.includes('procedure') || detailCode.includes('surgery')) { extendedActivity.detailType = 'procedure'; } else if (detailCode.includes('observation') || detailCode.includes('test')) { extendedActivity.detailType = 'observation'; } } extendedActivity.detail = addPeriodControlsIfApplicable(extendedActivity.detail, detail, 'scheduledPeriod') } return extendedActivity; }) }; return addPeriodControlsIfApplicable(formData, carePlan); }, [ transformFromCodeableConcept, addPeriodControlsIfApplicable, transformFromFhirQuantity, transformFromReferenceCollection, transformFromFhirTiming, formatDateTimeForInput ]); const getCarePlanDisplay = (carePlan: CarePlan): string => { if (!carePlan) { return 'Unknown Care Plan'; } // The CATEGORY fallback that used to sit here is gone. A reference reading // "Assessment and Plan of Treatment - Draft" names a classification most plans share, and a // wrong value that looks right is harder to notice than a plain one. const title = carePlanName(carePlan, 'Care Plan'); // - 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(carePlan.status); const status = statusText ? ` ยท ${statusText}` : ''; let dateSuffix = ''; if (carePlan.period) { dateSuffix = ` (${formatFhirPeriod(carePlan.period)})`; } else if (carePlan.created) { dateSuffix = ` (${formatDateTimeForDisplay(carePlan.created)})`; } return `${title}${status}${dateSuffix}`; }; return { carePlansBundle, // 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, carePlansList, setCarePlansList, shouldReload, setShouldReload, lastUpserted, setLastUpserted, unsaved, setUnsaved, fetchCarePlans, fetchCarePlansWithPolling, fetchCarePlan, fetchCarePlanHistory, createCarePlan, updateCarePlan, deleteCarePlan, archiveCarePlan, transformToFhirCarePlan, transformFromFhirCarePlan, getCarePlanDisplay }; }; const CarePlanServiceContext = createContext | undefined>(undefined); export const CarePlanServiceProvider: React.FC<{ children: ReactNode }> = ({ children }) => { const carePlanService = useCarePlanService(); return ( {children} ); }; export const useCarePlanServiceContext = () => { const context = useContext(CarePlanServiceContext); if (context === undefined) { throw new Error('useCarePlanServiceContext must be used within a CarePlanServiceProvider'); } return context; };