import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { CarePlan, Goal, Observation, Patient, Task } from 'fhir/r4'; import { useAuthServiceContext, useCarePlanServiceContext, useCollaborationTeamServiceContext, useGoalServiceContext, useObservationServiceContext, useTaskServiceContext, } from '../../../services'; import type { CollaborationTeam } from '../../../services/collaborationTeam'; import { describePlanSharing, teamsForPatient, withCarePlanTeam, type PlanSharing } from '../../../utils/carePlanTeam'; import type { NoteDraftStatus } from './useEncounterClinicalNote'; import { isRefusedRead } from '../../../utils/fetchFailureMessage'; import { isRecordChanged } from '../../../utils/recordChanged'; import { isSaveRetired, outcomeUnknown, SaveRetiredError, TIMED_OUT_MAY_HAVE_SAVED, useSaveScope, type SaveOperation, type SaveTransport } from '../../../utils/saveOperation'; import { compareVersions } from '../../../utils/writeThroughHold'; // Whether the plan read answered: an unanswered read is not "no plan". export type CarePlanReadStatus = 'loading' | 'ready' | 'refused' | 'failed'; // What a read answered for the patient, unfiltered: what a change that may have saved is looked for in. type CarePlanRead = { plans: CarePlan[]; tasks: Task[]; goals: Goal[] }; export const NOT_SEEN_YET = 'It is not on the plan yet. A search can trail a save, so reload again shortly; add it again only if you are sure it was not saved.'; import { buildDraftCarePlan, buildPlanGoal, buildPlanInstruction, buildPlanMeasurement, type NewGoalInput, type NewInstructionInput, type NewMeasurementInput, } from '../../../utils/carePlanAuthoring'; import { getTaskCarePlanReference } from '../../../utils/planItem'; import { isCarePlanStopped } from '../../../utils/carePlanStopped'; import { carePlanAuthorship } from '../../../utils/carePlanAuthorship'; import { useOptionalWebSocketServiceContext } from '../../../services/WebSocketService'; const CARE_PLAN_UPDATED = 'com.akashichealthrecords.fhir.careplan.updated'; // How a change sent without an answer is found in a later read. A creation counts only as a resource the read before // it did not hold, so an older look-alike is not taken for it. const isNew = (candidate: { id?: string }, before: { id?: string }[] = []) => !before.some((known) => known.id === candidate.id); // Which plans this practice writes to. Another practice's plan is read beside it, never added to. const mayEditPlan = (candidate: CarePlan, organizationId?: string) => carePlanAuthorship(candidate, { currentOrganizationId: organizationId }).mayEdit; const planSeen = (draft: CarePlan, organizationId: string, before?: CarePlan[]) => (read: CarePlanRead) => read.plans.some((candidate) => isNew(candidate, before) && !isCarePlanStopped(candidate) && candidate.title === draft.title && mayEditPlan(candidate, organizationId)); const taskSeen = (task: Task, planId: string | undefined, before?: Task[]) => (read: CarePlanRead) => read.tasks.some((candidate) => isNew(candidate, before) && candidate.description === task.description && getTaskCarePlanReference(candidate)?.reference === `CarePlan/${planId}`); const addresses = (goal: Goal, planId?: string) => (goal.addresses ?? []).some((reference) => reference.reference === `CarePlan/${planId}`); const goalSeen = (goal: Goal, planId: string | undefined, before?: Goal[]) => (read: CarePlanRead) => read.goals.some((candidate) => isNew(candidate, before) && candidate.description?.text === goal.description?.text && addresses(candidate, planId)); // An update is conditional on the version it was built on: once a later version is read, any retry is made over that. const planMovedOn = (from: CarePlan) => (read: CarePlanRead) => read.plans.some((candidate) => candidate.id === from.id && compareVersions(candidate, from) === 1); // The plan the encounter is writing, and everything the editor needs to draw and change it. // // READS ARE WIDE AND WRITES ARE NARROW. It fetches the patient's plans, tasks, goals and observations // because progress is DERIVED - a goal reads the readings that exist, so the editor cannot be handed a // pre-computed number. It writes one resource at a time, because each Add control is one decision the // clinician made and a batch would make a partial failure unattributable. /** A current plan another practice wrote: shown read-only, with its own items. */ export interface OtherPracticePlan { plan: CarePlan; activities: Task[]; goals: Goal[]; } export interface EncounterCarePlan { /** This practice's current plan, the one Add writes to. Undefined offers a new plan, whatever others hold. */ plan?: CarePlan; otherPracticePlans: OtherPracticePlan[]; activities: Task[]; goals: Goal[]; observations: Observation[]; currentOrganizationId?: string; draftStatus: NoteDraftStatus; /** Said when a change was sent and no answer of ours came back: it may be on the plan, so it was not kept to send again. */ unconfirmed?: string; /** The teams that could hold THIS patient's plan. Empty when the caller is on none. */ teams: CollaborationTeam[]; sharing?: PlanSharing; shareWithTeam: (teamId: string | undefined) => Promise; startPlan: (title: string) => Promise; addInstruction: (input: Omit) => Promise; addMeasurement: (input: Omit) => Promise; addGoal: (input: Omit) => Promise; /** The patient's goals that this plan does not already hold. */ attachableGoals: Goal[]; attachGoal: (goalId: string) => Promise; giveToPatient: () => Promise; /** Reads the plan again; an unconfirmed change is released once a read shows it made. */ reload: () => Promise; /** A reload answered without the unconfirmed change: the clinician may say it was not saved. */ mayRetry: boolean; /** Releases an unconfirmed change the clinician is sure was not saved. */ retry: () => void; readStatus: CarePlanReadStatus; } export const useEncounterCarePlan = (patient?: Patient): EncounterCarePlan => { const { currentOrganization } = useAuthServiceContext(); const { fetchCarePlans, fetchCarePlan, createCarePlan, updateCarePlan } = useCarePlanServiceContext(); const { fetchGoals, fetchGoal, createGoal, updateGoal } = useGoalServiceContext(); const { fetchObservations } = useObservationServiceContext(); const { fetchTasks, createTask } = useTaskServiceContext(); const { listTeams } = useCollaborationTeamServiceContext(); const [plan, setPlan] = useState(); const [othersCurrent, setOthersCurrent] = useState([]); const [tasks, setTasks] = useState([]); const [goals, setGoals] = useState([]); const [observations, setObservations] = useState([]); const [teams, setTeams] = useState([]); const [draftStatus, setDraftStatus] = useState('idle'); const [readStatus, setReadStatus] = useState('loading'); // Only the latest read for the patient on screen may set what the editor shows. const readGeneration = useRef(0); const readFor = useRef(); const lastRead = useRef(); // Plans seen archived or ended for this patient. A search can trail a write and still return the copy // from before the archive, which carries no marker; this keeps such a plan from becoming current again. const stoppedIds = useRef(new Set()); const isStopped = (candidate?: CarePlan) => isCarePlanStopped(candidate) || stoppedIds.current.has(candidate?.id ?? ''); const currentOrganizationId = currentOrganization?.id; // Reads and writes belong to the patient and the practice acting: a switch of either drops what was shown. const scopeKey = patient?.id ? `${patient.id}|${currentOrganizationId ?? ''}` : undefined; // `owner`: the save this read follows; its reads carry it and nothing of them is shown once it is retired. // `bounded`: the whole read, preparation included, ends at the owner's deadline. Answers what it read when that is // what the editor now shows. const load = useCallback(async (owner?: SaveOperation, bounded = false): Promise => { if (!patient?.id) return undefined; // Retired before it starts: it must not reset or outrank the read of whatever replaced it. if (owner && !owner.belongs()) return undefined; const generation = ++readGeneration.current; if (readFor.current !== scopeKey) { readFor.current = scopeKey; stoppedIds.current = new Set(); lastRead.current = undefined; setPlan(undefined); setOthersCurrent([]); setTasks([]); setGoals([]); setObservations([]); } setReadStatus('loading'); try { const searches = () => Promise.all([ fetchCarePlans(patient, owner?.transport), fetchTasks(patient, owner?.transport), fetchGoals(patient, owner?.transport), fetchObservations(patient, owner?.transport), ]); const [plans, taskList, goalList, observationList] = await (bounded && owner ? owner.step(searches) : searches()); if (generation !== readGeneration.current || (owner && !owner.belongs())) return undefined; // The plan the visit is working on: the most recent one this practice can edit. A plan from // another practice is still READ here and listed read-only, but it is never what Add opens, so // its existence does not stop this practice starting its own. // // AN ARCHIVED OR ENDED PLAN IS NEVER IT, wherever it falls in the search. Taking the last result // reopened an archived plan's editor on Add; with none left, the editor offers a new plan instead. const readable = (plans?.entry ?? []).map((entry) => entry.resource as CarePlan); readable.forEach((candidate) => { if (candidate.id && isCarePlanStopped(candidate)) stoppedIds.current.add(candidate.id); }); const current = readable.filter((candidate) => !isStopped(candidate)); const own = current.filter((candidate) => mayEditPlan(candidate, currentOrganizationId)); const found = own[own.length - 1]; setOthersCurrent(current.filter((candidate) => !mayEditPlan(candidate, currentOrganizationId))); // A search can trail a write: a copy older than the plan already held is not taken over it. setPlan((held) => (held && found && held.id === found.id && (compareVersions(held, found) ?? 0) > 0 ? held : found)); const read: CarePlanRead = { plans: readable, tasks: (taskList?.entry ?? []).map((entry) => entry.resource as Task), goals: (goalList?.entry ?? []).map((entry) => entry.resource as Goal), }; lastRead.current = read; setTasks(read.tasks); setGoals(read.goals); setObservations((observationList?.entry ?? []).map((entry) => entry.resource as Observation)); setReadStatus('ready'); return read; } catch (error) { if (generation !== readGeneration.current || (owner && !owner.belongs())) return undefined; console.error('Failed to load the care plan for this encounter:', error); // A failed read is not a failed write: the editor says the read failed and stops edits. A // refusal also drops what was read before, so a retry cannot show it again. if (isRefusedRead(error)) { setPlan(undefined); setOthersCurrent([]); setTasks([]); setGoals([]); setObservations([]); setReadStatus('refused'); } else { setReadStatus('failed'); } return undefined; } }, [patient, scopeKey, currentOrganizationId, fetchCarePlans, fetchTasks, fetchGoals, fetchObservations]); // The opening read belongs to the patient and the person signed in: another person signing in retires it and // reads again for them, so nothing read for the last one is shown. const [viewerReads, setViewerReads] = useState(0); const readScope = useSaveScope(scopeKey, () => setViewerReads((count) => count + 1)); useEffect(() => { const owner = readScope.start(); void load(owner); return () => { readGeneration.current += 1; owner.retire(); }; }, [load, readScope, viewerReads]); // A plan archived elsewhere while this builder is open stops being the plan here at once. const socket = useOptionalWebSocketServiceContext(); const subscribe = socket?.subscribe; const unsubscribe = socket?.unsubscribe; useEffect(() => { if (!subscribe || !unsubscribe || !patient?.id) return; const onUpdated = (event: { data?: CarePlan }) => { const updated = event?.data; if (!updated?.id || !isCarePlanStopped(updated)) return; if (updated.subject?.reference !== `Patient/${patient.id}`) return; stoppedIds.current.add(updated.id); setPlan((held) => (held?.id === updated.id ? undefined : held)); }; subscribe(CARE_PLAN_UPDATED, onUpdated); return () => unsubscribe(CARE_PLAN_UPDATED, onUpdated); }, [subscribe, unsubscribe, patient?.id]); // Loaded SEPARATELY from the plan, and a failure here is swallowed rather than reported. A caller // on no teams is the ordinary case, the route is patient-owned, and a plan that cannot be written // because its team list would not load would be a far worse failure than a missing Shared-with line. useEffect(() => { let live = true; void listTeams() .then((result) => { if (live) setTeams(result); }) .catch(() => { if (live) setTeams([]); }); return () => { live = false; }; }, [listTeams]); /** Only the activities that belong to THIS plan - the patient's other tasks are not plan items. */ const activities = useMemo( () => (plan?.id ? tasks.filter((task) => getTaskCarePlanReference(task)?.reference === `CarePlan/${plan.id}`) : []), [tasks, plan?.id], ); const otherPracticePlans = useMemo( () => othersCurrent.map((other) => ({ plan: other, activities: tasks.filter((task) => getTaskCarePlanReference(task)?.reference === `CarePlan/${other.id}`), goals: goals.filter((goal) => addresses(goal, other.id)), })), [othersCurrent, tasks, goals], ); const planGoals = useMemo( () => (plan?.id ? goals.filter((goal) => (goal.addresses ?? []).some((r) => r.reference === `CarePlan/${plan.id}`)) : []), [goals, plan?.id], ); // An attachment belongs to the patient and plan on screen and the person signed in. const attachScope = useSaveScope(`${patient?.id ?? ''}|${plan?.id ?? ''}`); // A write retired with its patient or person leaves the status to whatever comes next. const [unconfirmed, setUnconfirmed] = useState(); // A write sent with no answer may have landed, and a search can trail it and answer empty: no further change is // sent until a read for this patient and viewer shows it, or the clinician says it was not saved. Holds how to // find that write in a read. const held = useRef<(read: CarePlanRead) => boolean>(); const [mayRetry, setMayRetry] = useState(false); const release = useCallback(() => { held.current = undefined; setUnconfirmed(undefined); setMayRetry(false); }, []); const writeScope = useSaveScope(scopeKey, () => { release(); setDraftStatus('idle'); }); // `deliberate`: the clinician asked for this read, so one that does not show the write says so and offers a retry. const reconcile = useCallback((read: CarePlanRead, deliberate: boolean) => { if (!held.current) return; if (held.current(read)) { release(); } else if (deliberate) { setUnconfirmed(NOT_SEEN_YET); setMayRetry(true); } }, [release]); // `planId`: the plan this write was built on. Refused because it changed, it is read again by id (a search can // still answer the refused version), so the builder shows it as it is now and a retry is made on that. // Answers whether the change's form is done: saved, or sent with no answer (it may be saved, so it is not kept // to send again; the plan is read and the panel says to check it). A refused one keeps its form for a retry. // Each write belongs to the patient (and person signed in) it was started for: an answer arriving after the panel // moved on starts no read and changes nothing shown. // `seen`: whether a read shows this change made, so an unanswered one is released only once it is there. const write = useCallback(async ( work: (transport: SaveTransport) => Promise, seen: (read: CarePlanRead) => boolean, planId?: string, ): Promise => { if (held.current) return false; const operation = writeScope.start(); setDraftStatus('draft-saving'); setUnconfirmed(undefined); try { await operation.step(() => work(operation.transport), true); await load(operation); if (!operation.belongs()) return false; setDraftStatus('draft-saved'); return true; } catch (error) { if (isSaveRetired(error) || !operation.belongs()) return false; console.error('Failed to save the care plan change:', error); if (outcomeUnknown(error)) { held.current = seen; setDraftStatus('idle'); setUnconfirmed(TIMED_OUT_MAY_HAVE_SAVED); // Read with an operation of its own, started while the write still belongs: the timed-out write // cannot admit it, and a recovery retired with its patient or viewer shows nothing. const recovery = writeScope.start(); try { const read = await load(recovery, true); if (!recovery.belongs()) return false; if (read) reconcile(read, false); return true; } finally { recovery.retire(); } } setDraftStatus('draft-failed'); if (!isRecordChanged(error) || !planId) return false; let current: CarePlan | undefined; try { current = await operation.step(() => fetchCarePlan(planId, true, operation.transport)); } catch { return false; } if (isCarePlanStopped(current)) stoppedIds.current.add(planId); setPlan((held) => (held?.id !== planId ? held : isStopped(current) ? undefined : current)); return false; } }, [load, fetchCarePlan, writeScope, reconcile]); // The deliberate check an unconfirmed change asks for. A read that fails or runs out of time keeps the hold. const reload = useCallback(async () => { const owner = writeScope.start(); try { const read = await load(owner, true); if (read && owner.belongs()) reconcile(read, true); } finally { owner.retire(); } }, [load, writeScope, reconcile]); const startPlan = useCallback(async (title: string) => { if (!patient || !currentOrganizationId) return false; const draft = buildDraftCarePlan({ patient, title, organizationId: currentOrganizationId }); return write((transport) => createCarePlan(draft, transport), planSeen(draft, currentOrganizationId, lastRead.current?.plans)); }, [patient, currentOrganizationId, createCarePlan, write]); const addInstruction = useCallback(async (input: Omit) => { if (!plan || !patient || isStopped(plan)) return false; const task = buildPlanInstruction({ ...input, plan, patient }); return write((transport) => createTask(task, transport), taskSeen(task, plan.id, lastRead.current?.tasks)); }, [plan, patient, createTask, write]); const addMeasurement = useCallback(async (input: Omit) => { if (!plan || !patient || isStopped(plan)) return false; const task = buildPlanMeasurement({ ...input, plan, patient }); return write((transport) => createTask(task, transport), taskSeen(task, plan.id, lastRead.current?.tasks)); }, [plan, patient, createTask, write]); const addGoal = useCallback(async (input: Omit) => { if (!plan || !patient || isStopped(plan)) return false; const goal = buildPlanGoal({ ...input, plan, patient }); return write((transport) => createGoal(goal, transport), goalSeen(goal, plan.id, lastRead.current?.goals)); }, [plan, patient, createGoal, write]); /** * Attach the plan to a collaboration team, or detach it. * * IT GRANTS NOBODY ANYTHING. Measured in the api on 2026-09-02: a practitioner on the team already * holds full-clinical access to the patient through the patient-approved referral Consent, and that * grant is patient-wide rather than plan-scoped. This records whose work the plan is. */ const shareWithTeam = useCallback(async (teamId: string | undefined) => { if (!plan || isStopped(plan)) return false; // Built on the plan held here, so written only over its version. return write((transport) => updateCarePlan(withCarePlanTeam(plan, teamId), plan.meta?.versionId, transport), planMovedOn(plan), plan.id); }, [plan, updateCarePlan, write]); /** * The patient's OTHER goals - the ones this plan does not already address. * * THE CASE THIS EXISTS FOR IS THE PATIENT'S OWN GOAL. The design's first scenario is a patient who * set 350 lb to 200 lb with nobody prescribing it; if they are then put on a related plan, the * clinician can bring it in rather than creating a second goal about the same thing. */ const attachableGoals = useMemo( () => (plan?.id ? goals.filter((goal) => !(goal.addresses ?? []) .some((reference) => reference.reference === `CarePlan/${plan.id}`)) : []), [goals, plan?.id], ); /** * Attach an existing goal to this plan. * * IT ADDS TO `addresses` AND REPLACES NOTHING. A goal can serve more than one plan, and a goal the * PATIENT set is not the plan's to take over - attaching says this plan is working towards it, never * that the practice now owns it. `expressedBy` is untouched, so the goals page still says who set it. */ const attachGoal = useCallback(async (goalId: string) => { const listed = goals.find((candidate) => candidate.id === goalId); if (!listed || !plan?.id || isStopped(plan)) return false; const owner = attachScope.start(); // A NARROW CHANGE, MADE ON THE GOAL AS IT IS NOW: read fresh, add this plan only if it is not there yet, and // written conditional on that version. A save that lands in between (a target edited, another plan attached) // is read again and the plan added to it - never written over with the listed copy. const attachTo = (goal: Goal): Goal | undefined => (goal.addresses ?? []).some((reference) => reference.reference === `CarePlan/${plan.id}`) ? undefined : { ...goal, addresses: [...(goal.addresses ?? []), { reference: `CarePlan/${plan.id}`, display: plan.title }] }; try { return await write(async () => { for (let attempt = 1; ; attempt += 1) { if (!owner.belongs()) throw new SaveRetiredError(); const current = await owner.step(() => fetchGoal(goalId, true, owner.transport)); // Read after the chart moved to another patient, plan or viewer: nothing is attached. if (!owner.belongs()) throw new SaveRetiredError(); const next = attachTo(current); if (!next) return current; try { return await owner.step(() => updateGoal(next, current.meta?.versionId, owner.transport), true); } catch (error) { if (!isRecordChanged(error) || error.outOfDate || attempt >= 2) throw error; } } }, (read) => read.goals.some((candidate) => candidate.id === goalId && addresses(candidate, plan.id))); } finally { owner.retire(); } }, [goals, plan, fetchGoal, updateGoal, write, attachScope]); // The moment the plan becomes the patient's to see. Draft until then, which is what the footer says. const giveToPatient = useCallback(async () => { if (!plan || isStopped(plan)) return false; return write((transport) => updateCarePlan({ ...plan, status: 'active' }, plan.meta?.versionId, transport), planMovedOn(plan), plan.id); }, [plan, updateCarePlan, write]); const patientTeams = useMemo( () => teamsForPatient(teams, patient?.id), [teams, patient?.id], ); const sharing = useMemo(() => describePlanSharing(plan, teams), [plan, teams]); return { plan, otherPracticePlans, activities, goals: planGoals, observations, currentOrganizationId, draftStatus, unconfirmed, readStatus, teams: patientTeams, ...(sharing ? { sharing } : {}), shareWithTeam, startPlan, addInstruction, addMeasurement, addGoal, attachableGoals, attachGoal, giveToPatient, reload, mayRetry, retry: release, }; };