import { createContext, useCallback, useContext, useMemo, useRef, useState } from 'react'; import type { Encounter } from 'fhir/r4'; import { useEncounterServiceContext } from '../../../services/EncounterService'; import { switchActiveEncounter } from './activeEncounter'; import { useSaveScope } from '../../../utils/saveOperation'; import type { QueueItem } from './encounterLifecycle'; /** * sub-task 0+1 - THE ONE ACTIVE ENCOUNTER, hoisted above the router. * * Spencer, 2026-08-05: *"the notes pane should only be for the active encounter the practitioner is in * currently and there should only ever be one 'active' encounter at a time even though they may have * several with the status of the practitioner coming back to them."* * * THE INVARIANT: "active" is THIS SLOT - explicit state, exactly one, **never derived from * `status === 'in-progress'`**. Deriving it from status re-creates the ambiguity the moment the data holds * two `in-progress` encounters, and the data can (that was the bug). Status is the *consequence* of a * switch, not the source of truth for which encounter is active. * * Mounted at App level (above ``) so the active encounter - and the note draft keyed to it - * survives navigation. Before the hoist both died the moment the practitioner left `/overview`. */ export interface ActiveEncounter { item: QueueItem; encounter: Encounter; } /** Where the ONE note editor currently lives. Exactly one site renders it. */ export type NotesLocation = 'docked' | 'floating' | 'meeting'; export interface ActiveEncounterApi { active: ActiveEncounter | undefined; /** * Enter another encounter, or pass `undefined` to leave to the switchboard. * **Parks the previously-active encounter as `onleave`** when it is still `in-progress`. * Never blocks: a failed park is reported, not thrown (see `switchActiveEncounter`). */ setActive: (next: ActiveEncounter | undefined) => Promise; /** * Clear the slot WITHOUT parking. Use ONLY when the caller has ALREADY written this encounter's * terminal/parked status itself (Finish `finished`, Set-on-leave `onleave`). * * WHY THIS EXISTS AND WHY IT IS NOT AN OPTIMISATION: the slot holds the encounter object as it was when * entered. After Finish writes `finished` to FHIR, this slot still holds the STALE copy saying * `in-progress` - so a parking clear would read the stale status, decide it is parkable, and PUT * `onleave` over a **just-finished visit**, reopening a closed clinical record. Terminal transitions * must clear, never park. */ clearActive: () => void; /** Swap in a fresh object for the SAME encounter (e.g. Reopen) without treating it as a switch. */ replaceActive: (next: ActiveEncounter) => void; /** Where the ONE note editor currently lives. Exactly one site renders it. */ notesLocation: NotesLocation; setNotesLocation: (next: NotesLocation) => void; } export function useActiveEncounterStore(): ActiveEncounterApi { const { changeEncounter } = useEncounterServiceContext(); const [active, setActiveState] = useState(); const [notesLocation, setNotesLocationState] = useState('docked'); // Mirror in a ref so a switch reads the true previous value rather than a stale closure, and so two // rapid switches cannot both read the same "previous" and park it twice. const activeRef = useRef(undefined); // A park belongs to the person and practice that switched: another signing in retires it before it is written. const parkScope = useSaveScope('active-encounter'); const commit = useCallback((next: ActiveEncounter | undefined) => { // A notes pane with NO active encounter has nothing to write to, and leaving it non-docked // would let it reappear against the next unrelated encounter, pointed at a different patient. // EVERY path that empties the slot funnels through `commit`, so the reset lives HERE. if (!next) setNotesLocationState('docked'); activeRef.current = next; setActiveState(next); }, []); const setActive = useCallback(async (next: ActiveEncounter | undefined) => { const previous = activeRef.current; commit(next); // switch the UI first - the park is bookkeeping and must not gate responsiveness const operation = parkScope.start(); await switchActiveEncounter({ previous: previous?.encounter, next: next?.encounter, changeEncounter: (id, change) => operation.step(() => changeEncounter(id, change, { transport: operation.transport }), true), }); }, [commit, changeEncounter, parkScope]); const clearActive = useCallback(() => { // The notesLocation reset is authoritative inside `commit` (gated on `!next`) - see its comment. // Nothing to repeat here; this call alone covers it. commit(undefined); }, [commit]); const replaceActive = useCallback((next: ActiveEncounter) => commit(next), [commit]); const setNotesLocation = useCallback((next: NotesLocation) => setNotesLocationState(next), []); return useMemo( () => ({ active, setActive, clearActive, replaceActive, notesLocation, setNotesLocation }), [active, setActive, clearActive, replaceActive, notesLocation, setNotesLocation], ); } const ActiveEncounterContext = createContext(undefined); export const ActiveEncounterProvider = ActiveEncounterContext.Provider; /** The slot when mounted, undefined otherwise - for surfaces that also render outside a clinician session. */ export function useOptionalActiveEncounterContext(): ActiveEncounterApi | undefined { return useContext(ActiveEncounterContext); } export function useActiveEncounterContext(): ActiveEncounterApi { const ctx = useContext(ActiveEncounterContext); if (!ctx) throw new Error('useActiveEncounterContext must be used within an ActiveEncounterProvider'); return ctx; }