/** * - a long questionnaire saves itself, and says so honestly. * * > **Spencer, 2026-08-24:** *"Sometimes they can be long or the patient has to wait until they get * > information for something and end up needing to come back and complete later."* * * THE MACHINERY WAS NOT MISSING; THE SAVE WAS. `TaskPage` already loads an existing * `QuestionnaireResponse` and re-fills the form from it, and `in-progress` is a status the resource * already has - so resuming worked. It only ever happened when the patient thought to press *Save for * Later*, and the moment they are most likely to leave is the moment they are least likely to be * thinking about it: they have gone to find the insurance card, or somebody came to the door. * * MODELLED ON THE ENCOUNTER NOTE, deliberately, rather than invented again. Its hazard * note is the whole reason this file is shaped around failure: * * > *"Telling a clinician to stop thinking about saving makes a SILENT failure worse than no autosave at * > all: before this they knew the note lived in the tab; now they have been taught it is safe, and they * > will close it. So the failure states are the feature here, not the happy path."* * * Replace *clinician* with *patient* and it is this card. * * THIS FILE IS PURE. The decision of WHETHER to write is separated from the writing so it can be * exercised without standing up the page, its form, its four service contexts and a WebSocket - which is * the same reason `bookingFlowSteps` and `patientSystemTasks` are pure. The page owns the timer and the * request; everything that could be got wrong lives here. */ /** What the patient is told, under the progress line. `saved` is never claimed about an unwritten form. */ export type AutosaveState = | { kind: 'saved-withdrawn' } | { kind: 'saved-unconfirmed' } | { kind: 'idle' } | { kind: 'saving' } | { kind: 'saved'; at: number } /** The write did not reach the server - a transport failure, where retrying can help. */ | { kind: 'failed' } /** * The server answered and said no. * * SEPARATE FROM `failed` BECAUSE THE SENTENCE MUST NOT GUESS AT A CAUSE IT HAS. Walked 2026-09-14: an * assist-only related person answering a questionnaire for somebody else was told "we could not reach * your practice" while the server had answered promptly and refused. Telling a person the practice is * down, when the practice has just declined, sends them to ring a receptionist about an outage nobody * is having. */ | { kind: 'refused' }; export interface AutosaveDecisionInput { /** The questionnaire, the patient and the task have all loaded and the form has been seeded. */ ready: boolean; /** The patient has changed something since the last write. */ dirty: boolean; /** The form has been submitted - the response is `completed` and this must stop. */ completed: boolean; /** A submit or an autosave is already in flight. */ busy: boolean; /** * Why this content may not be sent, from the SAME predicate the submit button asks * (`emptyQuestionnaireSubmissionRefusal`). `undefined` means it may. */ refusal: string | undefined; } /** * May an autosave run right now? * * Each `false` below is a way this feature could corrupt something rather than merely fail to help, which * is why they are enumerated rather than collapsed into one guard: * * **NOT BEFORE THE FORM IS SEEDED.** `reset()` fills the form from the stored response *after* the * fetch. A write in that window would PUT an empty response over answers the patient already gave - * autosave destroying the thing it exists to protect. * **NOT WHEN NOTHING CHANGED.** Otherwise every re-render is a PUT of the whole resource: a request * storm, and a conflict generator against the patient's own next keystroke. * **NOT AN EMPTY FORM.** A patient who opens a questionnaire and reads it has not started it. Writing * then creates an `in-progress` response containing nothing, which reads to staff as begun-and- * abandoned - a false claim about a person who simply looked. * **NOT AFTER SUBMIT.** A completed response must not be dragged back to `in-progress` by a trailing * timer, which would reopen a task the practice has already been told is done. * **NOT WHILE ONE IS IN FLIGHT.** Two overlapping PUTs of one resource race, and the loser silently * wins on the server. */ export const shouldAutosave = ({ ready, dirty, completed, busy, refusal, }: AutosaveDecisionInput): boolean => ready && dirty && !completed && !busy && refusal === undefined; /** * THE SENTENCE THE PATIENT READS, and the reason it is here rather than inline in the component: it is * the half that can lie. Every other part of this feature merely fails; this part can tell somebody their * answers are safe when they are not, and they will act on it by closing the tab. * * `saved` NEVER carries an optimistic reading. The page sets it only after the write resolves. * A failure says where the answers ARE, because that is the only thing the patient can act on in that * moment - telling them a save failed without telling them the work is still on screen invites exactly * the reaction that loses it. */ export const describeAutosave = (state: AutosaveState, now: number = Date.now()): string => { switch (state.kind) { case 'saved-withdrawn': return 'Your answers were saved, but this form was withdrawn. Reload to read its current status.'; case 'saved-unconfirmed': return "Your answers were saved, but we could not confirm the form's status or link. Reload to read back the saved answers before continuing."; case 'saving': return 'Saving…'; case 'saved': return `All answers saved${relativeMinutes(state.at, now)}`; case 'failed': return 'Not saved — we could not reach your practice. Your answers are still on this screen.'; case 'refused': // No cause, because this line does not know it - the page-level message does. What it owes // the reader is the same thing every other branch owes them: where the answers are. return 'Not saved. Your answers are still on this screen.'; case 'idle': default: // Deliberately empty rather than "Not saved yet". Before the patient has changed anything // there is nothing to save, and a line implying otherwise on an untouched form is noise that // teaches them to ignore the one place this feature speaks. return ''; } }; /** * " - 2 minutes ago", or nothing under a minute. * * Under a minute it says just "All answers saved". A counter ticking from "0 minutes ago" is both * uglier and less true than saying nothing - the patient has watched it save; the age only starts * mattering once they have been away from it. */ const relativeMinutes = (at: number, now: number): string => { const minutes = Math.floor(Math.max(0, now - at) / 60_000); if (minutes < 1) return ''; if (minutes === 1) return ' · 1 minute ago'; if (minutes < 60) return ` · ${minutes} minutes ago`; const hours = Math.floor(minutes / 60); if (hours < 24) return hours === 1 ? ' · 1 hour ago' : ` · ${hours} hours ago`; // A draft picked up again days later: "743 hours ago" is a number nobody reads. const days = Math.floor(hours / 24); return days === 1 ? ' · 1 day ago' : ` · ${days} days ago`; }; /** * How long after the last change to write. * * NOT PER KEYSTROKE. Each save is a PUT of the whole response, so a per-character write is a request * storm against a resource the patient is still editing. Two seconds is long enough to coalesce typing * and short enough that closing the tab in frustration still catches the last answer. * And there is no retry timer. A failed write is reported and waits for the next change - retrying on * a loop would hammer a server that is already refusing and bury the failure under noise. */ export const AUTOSAVE_DEBOUNCE_MS = 2_000; /** Persistence is explicit; a failed standing check does not establish withdrawal. */ export const savedAnswerRefusal = (error: unknown): 'saved-withdrawn' | 'saved-unconfirmed' | undefined => { const body = (error as { body?: { saved?: unknown; code?: unknown } } | null)?.body; if (body?.saved !== true) return undefined; return body.code === 'FORM_CLOSED' ? 'saved-withdrawn' : 'saved-unconfirmed'; };