import { withRead, type ReadOptions } from '../hooks/anchorView/readOptions'; import { asRecordChanged, ifMatch, PAGE_OUT_OF_DATE_MESSAGE, RecordChangedError } from '../utils/recordChanged'; import { isHeld, overlayHeldBundle, viewerEpoch, writtenSince } from '../utils/writeThroughHold'; import { useState, useCallback, createContext, useContext, ReactNode, useEffect, useMemo, useRef } from 'react'; import { Bundle, Goal, GoalTarget, Patient } 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 { formatFhirCodeableConcept } from '../utils/fhirCodeableConcept.tsx'; import { applyArchive, isArchived, type ArchiveReason } from '../utils/archivedResource'; import { statusLabel } from '../utils/fhirStatusVocabulary.ts'; import { fetchAllPagesAsBundle } from '../utils/fetchAllPagesAsBundle'; import { searchPageSize } from '../utils/pageSizeOverride'; import { useCacheOwner } from '../utils/cacheOwner'; import { withNewer } from '../utils/versionOrder'; import { createRecordFloor, type FloorEntry } from '../utils/recordPublicationFloor'; import type { SaveOperation, SaveTransport } from '../utils/saveOperation'; const API_URL = environment.fhirClinicalApi; /** What a goal event's read found: the edition to show, that it is not there, that it could not be read, or nothing to act on. */ export type GoalEventRead = { goal: Goal } | { gone: true } | { unavailable: true } | { retired: true }; export const useGoalService = () => { const { get, post, put, delete: del } = useHttpService(); const { patientState } = usePatientServiceContext(); const { transformToCodeableConcept, transformFromCodeableConcept, createReferenceObject, formatDate, unpackBundle, transformToReferenceCollection, transformFromReferenceCollection, extractReferenceId } = useFhirServiceContext(); const [goalsList, setGoalsList] = useState([]); const [shouldReload, setShouldReload] = useState(false); const [lastUpserted, setLastUpserted] = useState(null); const [unsaved, setUnsaved] = useState(false); // Goals that changed but could not be read again: still shown, and said to be unchecked until a read answers. const [uncheckedGoals, setUncheckedGoals] = useState([]); const [goalsBundle, setGoalsBundle] = useState | null>(null); const goalsBundleRef = useRef | null>(null); const lastFetchTimeRef = useRef(0); // The newest copy known of each goal, per patient: every publisher (search, save answers, broadcasts) passes it. const goalFloors = useRef(new Map>>()); const goalFloor = useMemo(() => createRecordFloor(goalFloors.current, (goal) => { const named = extractReferenceId(goal.subject); return Array.isArray(named) ? named[0] : named; }), [extractReferenceId]); useEffect(() => { goalsBundleRef.current = goalsBundle; }, [goalsBundle]); // 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 readSequence = useRef(0); const withheldAt = useRef(new Map()); const cacheOwner = useCacheOwner('Goal', () => { goalFloors.current.clear(); withheldAt.current.clear(); setUncheckedGoals([]); setLastUpserted(null); setGoalsBundle(null); setGoalsList([]); lastFetchTimeRef.current = 0; }); useEffect(() => { if (!patientState) { goalFloors.current.clear(); withheldAt.current.clear(); setUncheckedGoals([]); setGoalsBundle(null); setGoalsList([]); lastFetchTimeRef.current = 0; cacheOwner.release(); } }, [patientState, cacheOwner.release]); const fetchGoals = useCallback(async (patient: Patient, transport?: SaveTransport, options?: ReadOptions): Promise> => { const startedAt = ++readSequence.current; 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}/Goal?${params.toString()}`, withRead({ ...transport }, options)); }); // A goal found withheld comes back only when the server lists it in a search begun after that finding; // a search begun earlier, or a copy held locally, never brings it back. const served = new Set((bundle.entry ?? []).map((entry) => entry.resource?.id).filter(Boolean) as string[]); served.forEach((id) => { if ((withheldAt.current.get(id) ?? Infinity) < startedAt) withheldAt.current.delete(id); }); const admitted = (id?: string) => !withheldAt.current.has(id ?? ''); // Held writes back in front of the search, scoped to the patient being read - this service is a // singleton and staff move between charts, so one patient's held record must never appear on // another's list. const overlaid = overlayHeldBundle('Goal', bundle, (goal) => extractReferenceId(goal.subject) === patient.id) as Bundle; const entries = overlaid.entry ?? []; const kept = entries.filter((entry) => admitted(entry.resource?.id)); return kept.length === entries.length ? overlaid : { ...overlaid, entry: kept, ...(overlaid.total === undefined ? {} : { total: kept.length }) }; }, [get, extractReferenceId]); const fetchGoalsWithPolling = 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('Goal', lastFetchTimeRef.current) && goalsBundleRef.current && cacheFresh && cacheOwner.isCurrentFor(patient.id)) { return goalsBundleRef.current; } const operation = cacheOwner.begin(); let retries = 10; const delay = 3000; let found = false; let bundle: Bundle | null = null; let entries: Goal[]; do { bundle = await fetchGoals(patient); entries = unpackBundle(bundle); found = lastUpserted ? entries.some(e => e.id === lastUpserted) || isHeld('Goal', 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); setGoalsBundle(bundle); setGoalsList(goalFloor.settle(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; }, [fetchGoals, unpackBundle, lastUpserted, shouldReload]); // `fresh`: the goal as stored now, never the cached copy - what a narrow change is made on. const fetchGoal = useCallback(async (id: string, fresh = false, transport?: SaveTransport): Promise => { const cachedGoal = !fresh && cacheOwner.isCurrent(cacheOwner.claimed) ? goalsList.find(goal => goal.id === id) : undefined; if (cachedGoal) { return cachedGoal; } const read = await get(`${API_URL}/Goal/${id}`, { ...transport }); // Admitted first: a read for a save that has moved on teaches nothing. transport?.admit(); if (fresh) goalFloor.learn(read); return read; }, [get, goalsList, goalFloor]); // After a refused or unconfirmed save: the goal as stored now, read by id and put in place of the copy that lost. // `owner`: the recovery's lifetime (its patient and viewer): retired, nothing is read, learned or installed. const recoverGoal = useCallback(async (id: string, owner: SaveOperation): Promise => { const current = await owner.step(() => fetchGoal(id, true, owner.transport)); owner.admit(); setGoalsList((prev) => (prev.some((goal) => goal.id === id) ? withNewer(prev, current).filter((goal) => !isArchived(goal)) : prev)); return current; }, [fetchGoal]); // An event for an open goal's detail: admitted (and taught) by the same floor as the list. Answers whether to show it. const admitGoalEvent = useCallback((goal: Goal, kind: 'updated' | 'deleted'): boolean => ( kind === 'deleted' ? goalFloor.remove(goal, true) : goalFloor.learn(goal) ), [goalFloor]); // A broadcast, admitted by the same floor: an older or removed copy never replaces the one shown. const publishGoal = useCallback((goal: Goal, kind: 'created' | 'updated' | 'deleted') => { if (kind === 'deleted') { goalFloor.remove(goal, true); setGoalsList((prev) => prev.filter((p) => p.id !== goal.id)); return; } if (!goalFloor.learn(goal)) return; setGoalsList((prev) => (isArchived(goal) ? prev.filter((p) => p.id !== goal.id) : kind === 'created' || prev.some((p) => p.id === goal.id) ? withNewer(prev, goal) : prev)); }, [goalFloor]); // Each change of patient is a new visit: a read asked for on an earlier visit answers no later one, the same patient included. const patientVisit = useRef({ id: patientState?.id, visit: 0 }); useEffect(() => { patientVisit.current = { id: patientState?.id, visit: patientVisit.current.visit + 1 }; }, [patientState?.id]); const latestEventRead = useRef(new Map>()); const eventReads = useRef(new WeakMap>()); const markChecked = (goalId: string, checked: boolean) => setUncheckedGoals((prev) => (checked ? prev.filter((id) => id !== goalId) : prev.includes(goalId) ? prev : [...prev, goalId])); // The goal read again, fresh, through the gated Goal read. The answer counts only for the viewer, patient visit // and latest read it was asked for. const readGoalAgain = useCallback((goalId: string): Promise => { const viewer = viewerEpoch(); const { id: patientId, visit } = patientVisit.current; const holder: { read?: Promise } = {}; const owns = () => viewer === viewerEpoch() && visit === patientVisit.current.visit && latestEventRead.current.get(goalId) === holder.read; holder.read = (async (): Promise => { let fresh: Goal; try { fresh = await get(`${API_URL}/Goal/${encodeURIComponent(goalId)}`, { cache: 'no-store' }); } catch (error) { if (!owns()) return { retired: true }; if ((error as { status?: unknown } | null)?.status === 404) { // Withheld for now, not deleted: the floor keeps it, so the same edition can return once it is published. withheldAt.current.set(goalId, ++readSequence.current); markChecked(goalId, true); setGoalsList((prev) => prev.filter((goal) => goal.id !== goalId)); setGoalsBundle((prev) => (prev ? { ...prev, entry: (prev.entry ?? []).filter((entry) => entry.resource?.id !== goalId) } : prev)); return { gone: true }; } console.error(`Goal ${goalId} changed but could not be read again; what is shown is kept:`, error); markChecked(goalId, false); return { unavailable: true }; } if (!owns() || extractReferenceId(fresh.subject) !== patientId) return { retired: true }; markChecked(goalId, true); if (!goalFloor.learn(fresh)) return { retired: true }; withheldAt.current.delete(goalId); setGoalsList((prev) => (isArchived(fresh) ? prev.filter((goal) => goal.id !== goalId) : withNewer(prev, fresh))); return { goal: fresh }; })(); latestEventRead.current.set(goalId, holder.read); return holder.read; }, [get, extractReferenceId, goalFloor]); // A goal event says only which goal changed, never what it says now. One event reaches every subscriber, so they share one read. const readGoalForEvent = useCallback((goalId: string, event: object): Promise => { const shared = eventReads.current.get(event); if (shared) return shared; const read = readGoalAgain(goalId); eventReads.current.set(event, read); return read; }, [readGoalAgain]); /** Check again: the deliberate read of a goal that could not be checked when it changed. */ const recheckGoal = useCallback((goalId: string) => readGoalAgain(goalId), [readGoalAgain]); const fetchGoalHistory = useCallback(async (id: string, options?: ReadOptions): Promise> => { return get>(`${API_URL}/Goal/${id}/_history`, options); }, [get]); const createGoal = useCallback(async (goal: Goal, transport?: SaveTransport): Promise => { let params = new URLSearchParams(); params.append('subject', extractReferenceId(goal.subject) as string); const created = await post(`${API_URL}/Goal?${params.toString()}`, goal, { ...transport }); goalFloor.learn(created); return created; }, [post, extractReferenceId, goalFloor]); // CONDITIONAL: written only over `basedOn`, the version of the goal the change was made on. A refusal is a // RecordChangedError: 412 someone else saved first (an old target or due date never comes back), 428 no version. const updateGoal = useCallback(async (goal: Goal, basedOn: string | undefined, transport?: SaveTransport): Promise => { if (!basedOn) throw new RecordChangedError(PAGE_OUT_OF_DATE_MESSAGE, true); return put(`${API_URL}/Goal/${goal.id}`, goal, { headers: ifMatch(basedOn), ...transport }) .then((saved) => { goalFloor.learn(saved); return saved; }) .catch((error: unknown) => { throw asRecordChanged(error); }); }, [put, goalFloor]); const deleteGoal = useCallback(async (goal: Goal): Promise => { return del(`${API_URL}/Goal/${goal.id}`); }, [del]); const archiveGoal = useCallback( async (goal: Goal, reason: ArchiveReason, note?: string): Promise => put(`${API_URL}/Goal/${goal.id}`, applyArchive(goal, reason, note)), [put], ); const transformToFhirGoal = useCallback((item: any, patient?: Patient): Goal => { const { lifecycleStatus, achievementStatus, category, priority, description, startType, startDate, startCodeableConcept, target, statusDate, statusReason, expressedBy, addresses, note, outcomeCode, outcomeReference, meta } = item; const usCoreGoalProfile: string = 'http://hl7.org/fhir/us/core/StructureDefinition/us-core-goal'; const transformedAddresses = transformToReferenceCollection(addresses); const transformedExpressedBy = transformToReferenceCollection(expressedBy); let goal: Goal = { resourceType: 'Goal', meta: { ...meta, profile: [usCoreGoalProfile] }, lifecycleStatus: lifecycleStatus, description: transformToCodeableConcept(description), ...(patient && { subject: createReferenceObject('Patient', patient.id as string, formatFhirHumanName(patient.name)) }), ...(achievementStatus && { achievementStatus: transformToCodeableConcept(achievementStatus) }), ...(category && category.length !== 0 && { category: transformToCodeableConcept(category) }), ...(priority && { priority: transformToCodeableConcept(priority) }), ...(startDate && { startDate: formatDate(startDate) }), ...(startType === 'codeableConcept' && startCodeableConcept && { startCodeableConcept: transformToCodeableConcept(startCodeableConcept) }), ...(statusDate && { statusDate: formatDate(statusDate) }), ...(statusReason && { statusReason: statusReason }), ...(transformedAddresses && transformedAddresses.length !== 0 && { addresses: transformedAddresses }), ...(transformedExpressedBy && transformedExpressedBy.length !== 0 && { expressedBy: transformedExpressedBy[0] }), ...(note && note.length !== 0 && { note }), ...(outcomeCode && outcomeCode.length !== 0 && { outcomeCode: transformToCodeableConcept(outcomeCode) }), ...(outcomeReference && outcomeReference.length !== 0 && { outcomeReference: outcomeReference }), ...(target && target.length !== 0 && { target: target.map((t: any) => { const goalTarget: GoalTarget = { ...(t.measure && { measure: transformToCodeableConcept(t.measure) }) }; if (t.measureType === 'quantity' && t.detailQuantity) { goalTarget.detailQuantity = t.detailQuantity; } else if (t.measureType === 'range' && t.detailRange) { goalTarget.detailRange = t.detailRange; } else if (t.measureType === 'codeableConcept' && t.detailCodeableConcept) { // goalTarget.detailCodeableConcept = transformToCodeableConcept(t.detailCodeableConcept); } else if (t.measureType === 'string' && t.detailString) { goalTarget.detailString = t.detailString; } else if (t.measureType === 'boolean' && t.detailBoolean !== undefined) { goalTarget.detailBoolean = t.detailBoolean; } else if (t.measureType === 'integer' && t.detailInteger !== undefined) { goalTarget.detailInteger = t.detailInteger; } else if (t.measureType === 'ratio' && t.detailRatio) { goalTarget.detailRatio = t.detailRatio; } if (t.dueType === 'date' && t.dueDate) { goalTarget.dueDate = formatDate(t.dueDate); } else if (t.dueType === 'duration' && t.dueDuration) { goalTarget.dueDuration = t.dueDuration; } return goalTarget; }) }) }; return goal; }, [createReferenceObject, transformToCodeableConcept, formatDate, transformToReferenceCollection]); const transformFromFhirGoal = useCallback((goal: Goal): any => { const { lifecycleStatus, achievementStatus, category, priority, description, subject, startDate, startCodeableConcept, target, statusDate, statusReason, expressedBy, addresses, note, outcomeCode, outcomeReference, meta } = goal; const formData = { meta: meta, lifecycleStatus: lifecycleStatus, achievementStatus: transformFromCodeableConcept(achievementStatus), category: transformFromCodeableConcept(category ?? []), priority: transformFromCodeableConcept(priority), description: transformFromCodeableConcept(description), subject: subject, start: startDate ? 'date' : startCodeableConcept ? 'codeableConcept' : 'date', startDate: startDate ?? '', startCodeableConcept: transformFromCodeableConcept(startCodeableConcept), statusDate: statusDate ?? '', statusReason: statusReason ?? '', expressedBy: transformFromReferenceCollection(expressedBy ? [expressedBy] : []), addresses: transformFromReferenceCollection(addresses ?? []), note: note ?? [], outcomeCode: transformFromCodeableConcept(outcomeCode ?? []), outcomeReference: outcomeReference ?? [], target: (target ?? []).map((t: GoalTarget) => { const extendedTarget: any = { measureType: '', dueType: '', measure: transformFromCodeableConcept(t.measure) }; if (t.detailQuantity) { extendedTarget.measureType = 'quantity'; extendedTarget.detailQuantity = t.detailQuantity; } else if (t.detailRange) { extendedTarget.measureType = 'range'; extendedTarget.detailRange = t.detailRange; } else if (t.detailCodeableConcept) { extendedTarget.measureType = 'codeableConcept'; extendedTarget.detailCodeableConcept = transformFromCodeableConcept(t.detailCodeableConcept); } else if (t.detailString) { extendedTarget.measureType = 'string'; extendedTarget.detailString = t.detailString; } else if (t.detailBoolean !== undefined) { extendedTarget.measureType = 'boolean'; extendedTarget.detailBoolean = t.detailBoolean; } else if (t.detailInteger !== undefined) { extendedTarget.measureType = 'integer'; extendedTarget.detailInteger = t.detailInteger; } else if (t.detailRatio) { extendedTarget.measureType = 'ratio'; extendedTarget.detailRatio = t.detailRatio; } if (t.dueDate) { extendedTarget.dueType = 'date'; extendedTarget.dueDate = t.dueDate; } else if (t.dueDuration) { extendedTarget.dueType = 'duration'; extendedTarget.dueDuration = t.dueDuration; } return extendedTarget; }) }; return formData; }, [transformFromCodeableConcept, transformFromReferenceCollection]); const getGoalDisplay = (goal: Goal): string => { if (!goal) { return 'Unknown Goal'; } // - this is the site the first sweep MISSED, and why the guard is now shape-based // rather than name-based: the expression was character-for-character the one being swept, on a // property called `lifecycleStatus` instead of `status`. Every Goal lifecycle code is in the // shared vocabulary, so nothing is omitted here in practice. const description = formatFhirCodeableConcept(goal.description) || 'Goal'; const lifecycle = statusLabel(goal.lifecycleStatus); const status = lifecycle ? ` ยท ${lifecycle}` : ''; let dateSuffix = ''; if (goal.startDate) { dateSuffix = ` (${formatDate(goal.startDate)})`; } else if (goal.statusDate) { dateSuffix = ` (${formatDate(goal.statusDate)})`; } return `${description}${status}${dateSuffix}`; }; return { goalsBundle, // 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, goalsList, setGoalsList, shouldReload, setShouldReload, lastUpserted, setLastUpserted, unsaved, setUnsaved, fetchGoals, fetchGoalsWithPolling, fetchGoal, recoverGoal, publishGoal, admitGoalEvent, readGoalForEvent, recheckGoal, uncheckedGoals, fetchGoalHistory, createGoal, updateGoal, deleteGoal, archiveGoal, transformToFhirGoal, transformFromFhirGoal, getGoalDisplay }; }; const GoalServiceContext = createContext | undefined>(undefined); export const GoalServiceProvider: React.FC<{ children: ReactNode }> = ({ children }) => { const goalService = useGoalService(); return ( {children} ); }; export const useGoalServiceContext = () => { const context = useContext(GoalServiceContext); if (context === undefined) { throw new Error('useGoalServiceContext must be used within a GoalServiceProvider'); } return context; };