import { useCallback, useEffect, useRef, useState } from 'react'; import type { Encounter, Patient } from 'fhir/r4'; import { useEncounterNotesContext, type NoteRefusal } from './EncounterNotesContext'; import { appendDraftNote } from '../../../services/scribe/renderDraftNote'; import { isRecordChanged } from '../../../utils/recordChanged'; import { isSaveRetired, useSaveScope } from '../../../utils/saveOperation'; import { noteOutcomeUnknown } from '../../../utils/clinicalNoteOutcome'; import { onViewerChange } from '../../../utils/writeThroughHold'; /** `conflict` and `unconfirmed`: the last save was refused or went unanswered, and the saved note is reviewed next. */ export type NoteSaveStatus = 'idle' | 'unsaved' | 'saving' | 'saved' | 'error' | NoteRefusal; // A refusal the store did not see (the save ran out of time around it) is still said as one. const refusalOf = (error: unknown): NoteRefusal | undefined => isRecordChanged(error) ? 'conflict' : noteOutcomeUnknown(error) ? 'unconfirmed' : undefined; /** * E3 - what has happened to the DRAFT, which is a different question from what has happened to * the record. * * `draft-failed` IS THE WHOLE POINT OF HAVING THIS AXIS. E3 tells a clinician to stop thinking about * saving; an autosave that fails silently is therefore WORSE than the old behaviour, because they have * been taught to trust it and they will close the tab. It must be able to say so out loud. */ /** * `appended` now; `present` already in the note by its marker, a confirmed write or the exact block; `held` not * added because the saved note postdates the draft and may hold an edited copy; `none` nothing to merge. */ export type DraftMerge = 'appended' | 'present' | 'held' | 'none'; export type NoteDraftStatus = 'idle' | 'draft-saving' | 'draft-saved' | 'draft-failed' | 'draft-conflict'; /** * How long after the last keystroke the draft is pushed. * * 2s against what it costs: a DynamoDB put per typing pause, on one clinician, on one encounter. Short * enough that walking away mid-sentence loses at most a couple of seconds of typing; long enough that * ordinary prose does not fire a request per word. Not a per-keystroke save - that is what produced the * HealthLake version chain this store exists to avoid, and it would be no better against DynamoDB. */ export const NOTE_DRAFT_DEBOUNCE_MS = 2_000; // Empty draft nothing to commit (an empty FHIR string is invalid; clearing a note is not a delete). // Draft equal to what's persisted in the record. Otherwise there are unsaved changes. function statusFor(draft: string, committed: string): NoteSaveStatus { if (!draft.trim()) return 'idle'; return draft === committed ? 'saved' : 'unsaved'; } /** * Editor-side handle for the encounter clinical note. Typing updates an IN-MEMORY draft (via the * page-level notes store - zero FHIR writes, so no version-per-keystroke); the note reaches the * patient's record only when `saveToRecord` (or Finish encounter, handled by the page) commits it. */ export const useEncounterClinicalNote = (patient?: Patient, encounter?: Encounter, opening = '') => { const notes = useEncounterNotesContext(); const notesRef = useRef(notes); notesRef.current = notes; const [text, setText] = useState(''); const [status, setStatus] = useState('idle'); /** * Whether the committed note has been fetched for this encounter yet. * * EXPOSED BECAUSE THE E2 MERGE MUST NOT RACE THE SEED. `ensureLoaded` seeds the draft from FHIR only * while `touched` is false, and merging a draft SETS `touched` - so a draft that arrived before the * fetch returned would suppress the seed and the practitioner's previously saved note would silently * vanish from the editor, leaving the machine's text alone in the box. */ const [loaded, setLoaded] = useState(false); const [loadFailed, setLoadFailed] = useState(false); const [loadAttempt, setLoadAttempt] = useState(0); const tokenRef = useRef(0); // encounter-scoped guard so a slow load never lands on the wrong encounter const patientId = patient?.id; const encounterId = encounter?.id; // A save or review belongs to the patient, visit and person signed in it started for: once any of them moves on, it // is retired and its answer marks nothing here, including after A -> B -> A. const scope = useSaveScope(patientId && encounterId ? `${patientId}|${encounterId}` : undefined, () => { const entry = encounterId ? notesRef.current.getEntry(encounterId) : undefined; setStatus(statusFor(entry?.draft ?? '', entry?.committedSummary ?? '')); }); const [reviewing, setReviewing] = useState(false); // What the store holds of a review (its basis, its text) is read on render, so a finished review renders again. const [, setReviews] = useState(0); // The store starts again for another person or practice; this note is loaded again for them. useEffect(() => onViewerChange(() => setLoadAttempt((n) => n + 1)), []); // Show this encounter's draft immediately (restored from the store when re-entering), then seed from // FHIR on first load. Depends only on the ids - the store api is read through a ref to avoid re-running // on provider identity churn. useEffect(() => { const token = ++tokenRef.current; setLoaded(false); setLoadFailed(false); if (!patientId || !encounterId) { setText(''); setStatus('idle'); return; } const api = notesRef.current; const seen = api.getEntry(encounterId); setText(seen?.draft ?? ''); setStatus(statusFor(seen?.draft ?? '', seen?.committedSummary ?? '')); void api.ensureLoaded(patient as Patient, encounterId, opening).then(() => { if (token !== tokenRef.current) return; const entry = api.getEntry(encounterId); setLoaded(entry?.loaded === true); setLoadFailed(entry?.loadFailed === true); if (!entry) return; setText(entry.draft); setStatus(statusFor(entry.draft, entry.committedSummary)); }); }, [patientId, encounterId, opening, loadAttempt]); const retryLoad = useCallback(() => setLoadAttempt((n) => n + 1), []); /* E3 - the debounced autosave. It lives in the HOOK rather than the store on purpose. Exactly one note editor may be live at a time - `oneNoteEditorInvariant.test.ts` is a standing guard on that - so there is exactly one timer per encounter, and it dies with the editor. In the store it would need its own lifecycle and would keep firing for an encounter nobody has open. */ const [draftStatus, setDraftStatus] = useState('idle'); const timerRef = useRef | null>(null); const conflictRef = useRef(false); const flushDraft = useCallback(async () => { if (!patientId || !encounterId) return; const token = tokenRef.current; setDraftStatus('draft-saving'); const outcome = await notesRef.current.persistDraft(patientId, encounterId); // The practitioner moved to another encounter while this was in flight; reporting its outcome would // put one encounter's save state on another encounter's screen. if (token !== tokenRef.current) return; if (outcome.status === 'saved') { setDraftStatus('draft-saved'); } else if (outcome.status === 'conflict') { /* AUTOSAVING STOPS HERE, and it does not resume. Retrying would race a colleague for one clinical note on a 2-second timer and the loser's work would vanish; the version is deliberately not advanced server-side either, so even a stray call cannot succeed. The practitioner is told, and their text stays in the box where they can copy it. */ conflictRef.current = true; setDraftStatus('draft-conflict'); } else { setDraftStatus('draft-failed'); } }, [patientId, encounterId]); const onChange = useCallback((next: string) => { if (!encounterId) return; setText(next); notesRef.current.setDraft(encounterId, next); setStatus(statusFor(next, notesRef.current.getEntry(encounterId)?.committedSummary ?? '')); // A conflicted note never autosaves again. See flushDraft. if (conflictRef.current || !loaded) return; if (timerRef.current) clearTimeout(timerRef.current); timerRef.current = setTimeout(() => { void flushDraft(); }, NOTE_DRAFT_DEBOUNCE_MS); }, [encounterId, flushDraft, loaded]); // Encounter-scoped: a pending save belongs to the encounter it was typed in, and a timer that survived // the switch would push one patient's note under another patient's ids. useEffect(() => { conflictRef.current = false; setDraftStatus('idle'); return () => { if (timerRef.current) clearTimeout(timerRef.current); timerRef.current = null; }; }, [patientId, encounterId]); /** * E2 - put a generated draft into the note, exactly once. * * Says whether it appended or found the draft already there, because the caller has to know: `ClinicalNoteEditor` * holds either as the recording this note used (which is what later allows that draft to be resolved on save) * and restores the caret only after an append. * * THE `jobId` CHECK IS THE WHOLE GUARD, and it is not defensive tidiness. The panel refetches on * every remount - juggling encounters, reopening the workspace - and without this, coming back to an * encounter appends the generated note to the end of itself, in a clinical record. * The text comparison is a SECOND, narrower guard for the one case the marker cannot cover: the note * was saved WITH the draft in it but `resolveDraft` failed (it is deliberately non-fatal), so after a * reload the store is re-seeded from FHIR - a fresh entry with no marker - while the server still holds * the draft. Matching the exact rendered block is safe precisely because it is exact: if that block is * already there verbatim, appending it again is certainly wrong. */ const appendDraft = useCallback((jobId: string, body: string) => { if (!encounterId) return; const api = notesRef.current; const entry = api.getEntry(encounterId); const next = appendDraftNote(entry?.draft ?? '', body); setText(next); api.setDraft(encounterId, next); api.markDraftMerged(encounterId, jobId, opening); setStatus(statusFor(next, entry?.committedSummary ?? '')); }, [encounterId, opening]); const mergeDraft = useCallback((jobId: string, draftText: string, draftCreatedAtMs?: number): DraftMerge => { if (!encounterId) return 'none'; const api = notesRef.current; const entry = api.getEntry(encounterId); const marked = entry?.mergedDraftJobId === jobId && entry.markerScope === opening; const written = entry?.committedDraft?.jobId === jobId && entry.committedDraft.scope === opening; if (marked || written) return 'present'; const body = draftText.trim(); if (!body) return 'none'; const current = entry?.draft ?? ''; if (current.includes(body)) { // Already present from a previous session; record it so the check costs nothing next time. api.markDraftMerged(encounterId, jobId, opening); return 'present'; } // A saved note may hold an edited copy whose marker went with the save. Only a note dated before the draft // was made proves it cannot; without that proof the clinician decides rather than getting the original twice. const savedAt = entry?.note?.date ? Date.parse(entry.note.date) : NaN; const savedBeforeDraft = draftCreatedAtMs !== undefined && Number.isFinite(savedAt) && savedAt < draftCreatedAtMs; if (entry?.committedSummary.trim() && !savedBeforeDraft) return 'held'; const next = appendDraftNote(current, body); setText(next); api.setDraft(encounterId, next); api.markDraftMerged(encounterId, jobId, opening); setStatus(statusFor(next, entry?.committedSummary ?? '')); return 'appended'; }, [encounterId, opening]); // Affirmative only: an unloaded or unreadable note, or one with neither the exact block nor a confirmed write of // this recording, proves nothing. const committedHolds = useCallback((jobId: string, draftText: string): boolean => { const entry = encounterId ? notesRef.current.getEntry(encounterId) : undefined; if (!entry?.loaded || entry.loadFailed) return false; if (entry.committedDraft?.jobId === jobId && entry.committedDraft.scope === opening) return true; const body = draftText.trim(); return entry.loadedScope === opening && !!body && entry.committedSummary.includes(body); }, [encounterId, opening]); // Returns whether the note actually reached the record. S6 needs that answer: the scribe // draft is deleted only once its content is committed, and the audio it came from is already gone - so // resolving the draft on a FAILED save would destroy the generated note and leave nothing to retry // with. A void return would have made "did it save?" unanswerable from the caller. const saveToRecord = useCallback(async (): Promise => { if (!patientId || !encounterId) return false; const token = tokenRef.current; const operation = scope.start(); setStatus('saving'); try { const result = await operation.step(() => notesRef.current.commit(patientId, encounterId, operation.transport), true); if (token !== tokenRef.current) return false; const committed = notesRef.current.getEntry(encounterId)?.committedSummary ?? ''; setStatus(statusFor(notesRef.current.getEntry(encounterId)?.draft ?? '', committed)); // Only a write this save made is a saved note: nothing written is nothing for the draft or proposals to follow. return result !== 'noop'; } catch (err) { if (isSaveRetired(err) || token !== tokenRef.current) return false; console.error('Failed to save clinical note', err); setStatus(notesRef.current.getEntry(encounterId)?.refused ?? refusalOf(err) ?? 'error'); return false; } }, [patientId, encounterId, scope]); // Review the saved note on purpose, after a refused or unconfirmed save; it becomes the basis of the next save. const reviewSaved = useCallback(async () => { const review = notesRef.current.reviewSaved; if (!encounterId || !review) return; const token = tokenRef.current; const operation = scope.start(); setReviewing(true); try { await operation.step(() => review(encounterId, operation.transport)); if (token !== tokenRef.current) return; const reviewed = notesRef.current.getEntry(encounterId); setStatus(statusFor(reviewed?.draft ?? '', reviewed?.committedSummary ?? '')); setReviews((n) => n + 1); } catch (err) { if (isSaveRetired(err) || token !== tokenRef.current) return; console.error('Could not read the saved clinical note', err); } finally { if (operation.belongs()) setReviewing(false); } }, [encounterId, scope]); const entry = encounterId ? notesRef.current.getEntry(encounterId) : undefined; // A refused or unconfirmed save stays said, through typing and remounts, until the saved note is reviewed; a review // that reads as the text is still not a save of it. const shownStatus = status !== 'saving' && entry?.refused ? entry.refused : status === 'saved' && entry?.reviewed ? 'unsaved' : status; return { text, onChange, status: shownStatus, saveToRecord, /** Absent when the store cannot read the saved note again. */ reviewSaved: notes.reviewSaved ? reviewSaved : undefined, reviewing, /** The record's note as last reviewed, shown beside the text; it says how the record stands, not who wrote it. */ savedText: entry?.savedText, mergeDraft, appendDraft, committedHolds, loaded, /** The record's note could not be read; the editor must say so and must not offer a save. */ loadFailed, retryLoad, draftStatus, /** * E3 - a different saved draft of this note exists, from this practitioner's other window. * * It can never be a COLLEAGUE's. The draft is keyed per author server-side Spencer's ruling of * 2026-08-13 that practitioners do not share the clinical note; the other people on an encounter use * private notes. */ conflictingServerText: entry?.conflictingServerText, }; };