import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useViewerIsPatient } from '../../../../hooks/useViewerIsPatient'; import type { CarePlan, Goal, GoalTarget, HumanName, Observation, Reference, Task } from 'fhir/r4'; import { useAuthServiceContext, useCarePlanServiceContext, useGoalServiceContext, useObservationServiceContext, usePatientServiceContext, useTaskServiceContext, } from '../../../../services'; import { describeGoalRow, describeGoalSource, type GoalRow, type GoalSource } from '../../../../utils/goalRow'; import { buildOwnGoal, buildOwnReading, type NewOwnGoalInput } from '../../../../utils/carePlanAuthoring'; import { goalPractice } from '../../../../utils/goalAuthorship'; import { AKASHIC_GOAL_DIRECTION_EXTENSION, AKASHIC_GOAL_START_VALUE_EXTENSION } from '../../../../utils/goalProgress'; import { formatFhirHumanName } from '../../../../utils/fhirHumanName'; import { isSaveRetired, startOwnedSave, useSaveScope, type SaveOperation } from '../../../../utils/saveOperation'; import { operationPartiesOf, useObservationOperation, type EarlierSave } from '../../../../hooks/useObservationOperation'; import { OPERATION_UNAVAILABLE_MESSAGE, operationNotYetAdmitted, operationReused, operationUnconfirmed } from '../../../../utils/observationOperation'; import { RecoveryFull, RecoveryUnavailable } from '../../../../utils/messaging/sendRecovery'; // Every goal the patient has, with its progress worked out from the readings that exist. // // THE READINGS ARE FETCHED HERE AND NOT STORED ANYWHERE. A goal carries no progress value, so this page // cannot be handed one - it reads the observations and computes. That is the whole reason every surface // showing a goal moves together: none of them holds a number that could go stale. // // THE PLANS ARE FETCHED ONLY TO NAME THEM. A goal's `addresses` already says which plan owns it; the // plans are needed for the title and the author on the source line, which is the drawing's "first // question either way". export interface GoalListItem { goal: Goal; row: GoalRow; source?: GoalSource; /** Written at another practice: staff see it and do not get to change it. */ fromAnotherPractice?: boolean; } export interface GoalRows { /** Still being worked on. */ working: GoalListItem[]; /** Reached, and therefore retired - nothing un-reaches a goal. */ reached: GoalListItem[]; observations: Observation[]; /** The patient's tasks, so a day that passed unanswered can be listed rather than left as a gap. */ tasks: Task[]; loadingProgress: boolean; /** The readings behind progress could not be read, so an empty list is unknown. */ progressFailed: boolean; /** A new goal from the goal modal: the patient's own, or one a clinician sets for them. `opening`: the sheet's. */ setOwnGoal: (input: Omit, opening?: SaveOperation) => Promise; /** Change a goal from the goal modal. Who may is decided by the page, as the server does. `opening`: the sheet's. */ editOwnGoal: (goal: Goal, input: Omit, opening?: SaveOperation) => Promise; /** One reading against a goal the patient logs themselves. */ /** `opening`: the goal detail's, so leaving that goal retires a reading not yet sent. */ logReading: (goal: Goal, value: number, opening?: SaveOperation) => Promise; /** An earlier logged reading whose save was never confirmed; no other is logged until it is settled. */ readingEarlier?: EarlierSave; checkReading: () => Promise; finishReading: () => Promise; newReading: () => void; /** A goal sheet's opening: retired on close, and by another patient or viewer. */ startOpening: () => SaveOperation; /** Each goal being read back after a refused or unconfirmed save, and whether its read failed. */ recoveries: Record; /** Read a goal again by id after a refused or unconfirmed save, under this patient and viewer. */ recover: (id: string) => void; } export const useGoalRows = (goals: Goal[]): GoalRows => { const { patientState } = usePatientServiceContext(); const viewerIsPatient = useViewerIsPatient(); const observationService = useObservationServiceContext(); const { fetchObservations, createObservationOnce } = observationService; const { fetchCarePlans } = useCarePlanServiceContext(); const { createGoal, updateGoal, recoverGoal } = useGoalServiceContext(); // Saves and recoveries belong to this patient and viewer; another of either retires them. const [recoveries, setRecoveries] = useState({}); const saveScope = useSaveScope(patientState?.id, () => setRecoveries({})); const startOpening = useCallback(() => saveScope.start(), [saveScope]); // After a refused or unconfirmed save the goal is not edited again until it is read back as it is stored now. Each // goal keeps its own state, and only its latest read-back may settle it. const latestRecovery = useRef(new Map()); const recover = useCallback((id: string) => { const owner = saveScope.start(); latestRecovery.current.set(id, owner); const mine = () => owner.belongs() && latestRecovery.current.get(id) === owner; setRecoveries((now) => ({ ...now, [id]: 'checking' })); recoverGoal(id, owner) .then(() => { if (!mine()) return; setRecoveries(({ [id]: _settled, ...rest }) => rest); }) .catch(() => { if (mine()) setRecoveries((now) => ({ ...now, [id]: 'failed' })); }) .finally(() => owner.retire()); }, [recoverGoal, saveScope]); const { fetchTasks } = useTaskServiceContext(); const signedIn = useAuthServiceContext(); const { currentUser, currentOrganization, wpOrganization } = signedIn; // A logged reading is one save under one operation id; one never confirmed is checked before another is logged. const reading = useObservationOperation('goal-reading', operationPartiesOf(signedIn, patientState?.id), observationService); // The goal opening the held reading was logged from: another goal never sends its own value under that id. const readingFrom = useRef<{ operationId: string; opening?: SaveOperation }>(); useEffect(() => { if (viewerIsPatient) void reading.opening(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [patientState?.id, viewerIsPatient]); const organizationId = currentOrganization?.id ?? wpOrganization?.current?.id ?? undefined; const staffAuthor = currentUser?.fhir?.resourceType === 'Practitioner' && currentUser.fhir.id ? currentUser.fhir : undefined; const [observations, setObservations] = useState([]); const [plans, setPlans] = useState([]); const [tasks, setTasks] = useState([]); const [loadingProgress, setLoadingProgress] = useState(false); const [progressFailed, setProgressFailed] = useState(false); // `owner`: the save this read follows. Retired, its reads are not sent and nothing of them is shown. Each read has // its own identity: only the latest may install, and only the latest ends the loading state - which a retired read // gives up at once. const loadSeq = useRef(0); const load = useCallback(async (owner?: SaveOperation) => { if (!patientState) return; const seq = ++loadSeq.current; const latest = () => seq === loadSeq.current; const current = () => (!owner || owner.belongs()) && latest(); setLoadingProgress(true); owner?.transport.signal.addEventListener('abort', () => { if (!owner.belongs() && latest()) setLoadingProgress(false); }); try { const [observationBundle, planBundle, taskBundle] = await Promise.all([ fetchObservations(patientState, owner?.transport), fetchCarePlans(patientState, owner?.transport), fetchTasks(patientState, owner?.transport), ]); if (!current()) return; setObservations((observationBundle?.entry ?? []).map((entry) => entry.resource as Observation)); setPlans((planBundle?.entry ?? []).map((entry) => entry.resource as CarePlan)); setTasks((taskBundle?.entry ?? []).map((entry) => entry.resource as Task)); setProgressFailed(false); } catch (error) { if (!current()) return; setProgressFailed(true); // The goals themselves still render. A page that refuses to draw a goal because its // readings could not be fetched tells the patient they have no goals, which is worse // than a goal with no bar under it yet. console.error('Failed to load what moves these goals:', error); } finally { if (latest()) setLoadingProgress(false); } }, [patientState, fetchObservations, fetchCarePlans, fetchTasks]); useEffect(() => { void load(); }, [load]); const setOwnGoal = useCallback(async (input: Omit, opening?: SaveOperation) => { if (!patientState) return; const { expressedBy: patientAuthor, ...built } = buildOwnGoal({ ...input, patient: patientState }); // A clinician setting a goal is its author, never the patient. const expressedBy: Reference | undefined = viewerIsPatient ? patientAuthor : staffAuthor && { reference: `Practitioner/${staffAuthor.id}`, display: formatFhirHumanName(staffAuthor.name as HumanName[] | undefined) || undefined, }; // This attempt's deadline starts now; it belongs while the sheet's opening does. const operation = opening ? startOwnedSave(opening) : saveScope.start(); try { await operation.step(() => createGoal({ ...built, ...(expressedBy ? { expressedBy } : {}) }, operation.transport), true); // The goal list is broadcast-driven and refreshes itself; the readings are not, and a new goal // with no bar under it looks broken rather than new. if (operation.belongs()) await load(operation); } finally { operation.retire(); } }, [patientState, createGoal, load, viewerIsPatient, staffAuthor, saveScope]); /** * Change a goal the patient set. * * REBUILT FROM THE ANSWERS AND THEN GIVEN BACK ITS IDENTITY, rather than patched field by field. The * four questions ARE the goal, so composing the new one the same way a create does keeps the two * paths from drifting - a start value written one way on create and another on edit is a goal whose * bar changes meaning when it is touched. * * `expressedBy` and `addresses` are carried over UNCHANGED. Who set a goal is not something editing * it may rewrite, and neither is which plan holds it - the server preserves both too, and a client * that sent something different would simply have it discarded. */ const editOwnGoal = useCallback(async (goal: Goal, input: Omit, opening?: SaveOperation) => { if (!patientState || !goal.id) return; const rebuilt = buildOwnGoal({ ...input, patient: patientState }); // Only what the four questions answer changes. Everything else on the goal - its practice, // notes, categories, due date - was not asked about and is kept as it was. const answered = new Set([AKASHIC_GOAL_DIRECTION_EXTENSION, AKASHIC_GOAL_START_VALUE_EXTENSION]); const { detailRange: _range, detailCodeableConcept: _concept, detailString: _string, detailBoolean: _boolean, detailInteger: _integer, detailRatio: _ratio, ...keptTarget } = goal.target?.[0] ?? ({} as GoalTarget); // Written only over the version the sheet was opened on: if someone else changed this goal meanwhile, the save // is refused (and the sheet says so) rather than bringing back the target, due date or status it opened with. const operation = opening ? startOwnedSave(opening) : saveScope.start(); try { await operation.step(() => updateGoal({ ...goal, description: goal.description?.text === rebuilt.description.text ? goal.description : rebuilt.description, target: [{ ...keptTarget, ...rebuilt.target?.[0] }, ...(goal.target ?? []).slice(1)], extension: [ ...(goal.extension ?? []).filter((extension) => !answered.has(extension.url)), ...(rebuilt.extension ?? []), ], }, goal.meta?.versionId, operation.transport), true); if (operation.belongs()) await load(operation); } finally { operation.retire(); } }, [patientState, updateGoal, load, saveScope]); /** * A reading, then a reload. * * NOTHING IS WRITTEN TO THE GOAL. Progress is derived, so the only thing a reading changes is what * observations exist - and re-reading them is how every surface showing this goal moves at once. */ const logReading = useCallback(async (goal: Goal, value: number, opening?: SaveOperation) => { if (!patientState) return; // This goal's opening, patient and viewer only: leaving any of them retires the reading before it is sent. const operation = opening ? startOwnedSave(opening) : saveScope.start(); let operationId: string; try { operationId = await reading.begin(); } catch (holdError) { if (isSaveRetired(holdError)) return; if (holdError instanceof RecoveryUnavailable || holdError instanceof RecoveryFull) throw new Error(OPERATION_UNAVAILABLE_MESSAGE); throw holdError; } const from = readingFrom.current; if (from?.operationId === operationId && from.opening !== opening) { reading.unconfirmed(operationId); return; } readingFrom.current = { operationId, opening }; try { // A confirmed answer settles its own operation, even one that arrives after the goal moved on. await operation.step(async () => { const created = await createObservationOnce(buildOwnReading({ goal, patient: patientState, value, now: new Date().toISOString(), }), operationId, operation.transport); reading.settled(operationId); return created; }, true); } catch (error) { // Its goal left with the reading perhaps sent: still held for this patient, and checked before any other is logged. if (isSaveRetired(error) || !operation.belongs()) { reading.unconfirmed(operationId); return; } // Not confirmed, fenced unsaved, or its id already used: kept, and checked first. Refused: nothing was written. if (operationUnconfirmed(error) || operationNotYetAdmitted(error) || operationReused(error)) { reading.unconfirmed(operationId); return; } reading.settled(operationId); throw error; } if (operation.belongs()) await load(); }, [patientState, createObservationOnce, load, saveScope, reading.begin, reading.settled, reading.unconfirmed]); const checkReading = useCallback(async () => { if ((await reading.check())?.saved) await load(); }, [reading.check, load]); const finishReading = useCallback(async () => { if ((await reading.finish())?.saved) await load(); }, [reading.finish, load]); return useMemo(() => { const items: GoalListItem[] = []; goals.forEach((goal) => { const row = describeGoalRow(goal, observations); if (!row) return; const practice = goalPractice(goal, { patientId: patientState?.id, currentOrganizationId: organizationId }); const source = describeGoalSource(goal, plans, patientState?.id, { staff: !viewerIsPatient, patientName: patientState?.name?.[0]?.given?.[0], practice, practitionerId: viewerIsPatient ? undefined : staffAuthor?.id, }); items.push({ goal, row, ...(source ? { source } : {}), ...(practice.fromAnotherPractice ? { fromAnotherPractice: true } : {}), }); }); return { working: items.filter((item) => !item.row.reached), reached: items.filter((item) => item.row.reached), observations, tasks, loadingProgress, progressFailed, setOwnGoal, editOwnGoal, logReading, readingEarlier: reading.earlier, checkReading, finishReading, newReading: reading.startNew, startOpening, recoveries, recover, }; }, [goals, observations, plans, tasks, patientState?.id, patientState?.name, viewerIsPatient, staffAuthor?.id, organizationId, loadingProgress, progressFailed, setOwnGoal, editOwnGoal, logReading, reading.earlier, reading.startNew, checkReading, finishReading, startOpening, recoveries, recover]); };