import { Fragment, useState, useEffect, useRef, useCallback } from 'react'; import { Scrollable } from "../../../../components/ui/scrollable"; import { useLocation, useNavigate, useParams } from 'react-router-dom'; import { FieldValues, FormProvider, useForm, useWatch } from 'react-hook-form'; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from '../../../../components/ui/alert-dialog'; import { DismissibleAlert } from '../../../../components/ui/dismissible-alert'; import { Disabled } from '../../../../components/ui/disabled'; import { Spacer } from '../../../../components/ui/spacer'; import { Organization, Patient, Questionnaire, QuestionnaireResponse, Task } from 'fhir/r4'; import { isClosedTask, isCustomTask, isQuestionnaireTask, usePatientServiceContext, useTaskServiceContext, useQuestionnaireServiceContext, useFhirServiceContext, useWebSocketServiceContext, useAppointmentServiceContext } from '../../../../services'; import { AppHeader, AppSpinner, CustomTaskDetails, ProfileLayoutContainer } from '../../../../components'; import QuestionnairePreview from '../../QuestionnairesPage/QuestionnairePage/QuestionnairePreview/QuestionnairePreview.tsx'; import { mayAmendNow } from '../../../../utils/submittedAnswersState'; import { changedAnswerCount, previousSentVersion } from '../../../../utils/changedAnswers'; import type { Appointment } from 'fhir/r4'; import { getCustomTaskDueDisplay, getTaskAppointmentId, getTaskStatusDisplay, resolveTaskAppointment, } from '../../../../services/TaskService.tsx'; import { TaskStatusRow, type TaskStatusTone } from '../../../../components/tasks/TaskStatusRow'; import { TaskFacts, dueFactValue } from '../../../../components/tasks/TaskFacts'; import { TaskProgress } from '../../../../components/tasks/TaskProgress'; import { countAnswered, countQuestions, describeChangedQuestions, describeProgress, isLongForm, } from '../../../../utils/questionnaireProgress'; import { useFhirQuestionnaireResponseBroadcast, useFhirQuestionnaireResponses, useFhirTaskBroadcast, useFhirTasks, useFhirAppointments, } from '../../../../hooks'; import { readsForAnother, visitListProvesNoneBooked } from '../../../../utils/visitListProvesNoneBooked'; import { emptyQuestionnaireSubmissionRefusal, QuestionnaireSubmissionAction } from '../../../../utils/questionnaireSubmission'; import { describeServerRefusal } from '../../../../utils/serverRefusalMessage'; import { useAuthServiceContext } from '../../../../services/AuthService'; import { mayCompleteFormsAsProxy, describeProxyCannotCompleteForms, proxyStanding, } from '../../../../utils/proxyFormAuthority'; import { useMyProxies } from '../../../../hooks/useMyProxies'; import { QuestionnaireAnswers } from '../../../../components/QuestionnaireAnswers/QuestionnaireAnswers'; import { formatDateTime, formatLongDate } from '../../../../utils/dateUtils'; import { relayServerFailure } from '../../../../utils/fetchFailureMessage'; // - the autosave's decision and its vocabulary. Kept pure and separate so the ways this // could corrupt a response are testable without the page, its form and its four service contexts. import { AUTOSAVE_DEBOUNCE_MS, describeAutosave, savedAnswerRefusal, shouldAutosave, type AutosaveState, } from '../../../../utils/questionnaireAutosave'; import { oneAtATime } from '../../../../utils/oneAtATime'; import { submittedAtOf } from '../../../../utils/questionnaireFirstSubmission'; import { onViewerChange, viewerEpoch } from '../../../../utils/writeThroughHold'; import { continuePacketAnswer, isPinnedEdition, packetAnswerId, packetPinOf, PIN_UNREADABLE, validatePacketAnswer } from '../../../../utils/packetAnswerContinuation'; import { ORGANIZATION_TAG_SYSTEM } from '../../../../utils/carePlanAuthorship'; // `saveForLater` is gone with the button. It was left in the union after the control was removed, so // the dialog still carried a "Save for Later" label on a branch nothing could reach - a name for a // behaviour that no longer exists, waiting for the next edit to set it and bring the button back in a // dialog. Submit is the only action the patient initiates now; everything else saves itself. type ConfirmationAction = 'submit' | null; // Shape of the WebSocket FHIR broadcast payload (mirrors the local FhirCloudEvent used elsewhere, e.g. App.tsx / // the broadcast hooks). No shared export exists yet, so it's redeclared here to match that convention. interface FhirCloudEvent { data: T; type: string; } /** "N of M answered", watching the form itself so only this line redraws as the patient types. */ const FormProgress = ({ questionnaire, total, toResponse, }: { questionnaire: Questionnaire | undefined; total: number; toResponse: (values: FieldValues) => QuestionnaireResponse | undefined; }) => { const values = useWatch(); const answered = countAnswered(toResponse(values), questionnaire); return ; }; /** * THE SAME SCREEN, ROUTED OR IN A DIALOG - the shape `PatientTaskPage` already uses. * * The overview exists to stop a patient being sent off to another section for quick things, and the * task row was doing exactly that: pressing "Complete task" on the home screen took them off the page * they were reading. Appointments and messages already open in place; this is what let tasks join * them. * * Spencer, 2026-08-14: *"they'd only have to navigate off this page if they actually need the full * page for that item."* * * PROPS ABSENT MEANS ROUTED MODE, and the route is untouched. `/dashboard/home/task/:id` is still a * real destination - it is what the "+ N more" line and any bookmark reach - so this is one component * with two containers rather than a screen that moved. */ export interface TaskPageProps { /** The task to show. Absent = read it from the route, exactly as before. */ taskId?: string; /** How to leave when embedded. Absent = navigate up, exactly as before. */ onExit?: () => void; /** Route container dismissals through the current form save barrier. */ registerExitGuard?: (guard: () => void) => () => void; /** The patient's practices, to name a task's owner that carries only a reference. */ organizations?: Organization[]; } const TaskPageForTask = ({ taskId: propTaskId, onExit, organizations, registerExitGuard }: TaskPageProps = {}) => { const { id: routeId } = useParams(); const id = propTaskId ?? routeId; const navigate = useNavigate(); /* * ONE EXIT, AND BOTH CALLERS GO THROUGH IT. * * The two places that leave this screen - a task deleted underneath the reader, and a submitted * questionnaire three seconds later - both used `navigate('..')`. In a dialog that is not merely * wrong, it is destructive: it would leave the dialog open and navigate the page BEHIND it, so the * patient closes the task and finds themselves somewhere they never asked to be. */ const leave = () => (onExit ? onExit() : navigate('..', { relative: 'path' })); /* * EMBEDDED MEANS THE PAGE IS NOT THE PAGE, and three things change with it. * * THE HEIGHT. The page sets none, routed or embedded. A dialog is already capped and scrolling, so * a second height sets a scrollbar inside its own; on the page a 100% height began under the * breadcrumb and ran past the page ground, leaving a bare band at the foot on a phone. * * THE BACK BUTTON. `AppHeader` navigates by itself when given no `onBack`, so in a dialog its * arrow moves the page BEHIND the dialog while the dialog stays open. That is the same defect * already fixed for this screen's two other exits, arriving by a third door. It is hidden rather * than rewired: the dialog has its own close, and two ways out of one dialog is noise. * * THE HEADER SPACING. The dialog supplies the title, so the page's own header only needs to carry * the autosave line and Submit. */ const embedded = !!onExit; const { subscribe, unsubscribe } = useWebSocketServiceContext(); const auth = useAuthServiceContext(); const { actingAs } = auth; const { patientState } = usePatientServiceContext(); const { fetchTask, updateTask } = useTaskServiceContext(); const { fetchQuestionnaire, fetchQuestionnaireEdition, fetchQuestionnaireResponse, transformFromFhirQuestionnaire, createQuestionnaireResponse, updateQuestionnaireResponse, transformToFhirQuestionnaireResponse, fetchQuestionnaireResponseHistory } = useQuestionnaireServiceContext(); const { fetchAppointment, appointmentsFailedFor, shouldReload } = useAppointmentServiceContext(); const appointmentRead = useFhirAppointments({ autoLoad: false }); const { extractReferenceId, createReferenceObject } = useFhirServiceContext(); const [loading, setLoading] = useState(false); const [submitting, setSubmitting] = useState(false); const [success, setSuccess] = useState(); const [error, setError] = useState(); // - what the autosave has done, said under the form. Never optimistic: `saved` is set // only after the write resolves, because a patient told their answers are safe will close the tab. const savedStanding = useRef<'saved-withdrawn' | 'saved-unconfirmed' | undefined>(undefined); const [autosave, setAutosave] = useState({ kind: 'idle' }); const [questionnaireLoading, setQuestionnaireLoading] = useState(false); const [readState, setReadState] = useState<'pending' | 'ready' | 'failed'>('pending'); const readable = useRef(false); const readRun = useRef(0); const heldUnsentDraft = useRef(undefined); const openingViewer = useRef(viewerEpoch()); const [task, setTask] = useState(); const [questionnaire, setQuestionnaire] = useState(); const [existingQuestionnaireResponse, setExistingQuestionnaireResponse] = useState(); // ONE SAVE AT A TIME. The autosave and Submit share one save path, and a Submit pressed while the // autosave's first create was still in flight created a second QuestionnaireResponse; the record kept // both. Saves now queue, and the created response is held in a ref because a queued save closed over // state from before that create landed. It is kept against the task it belongs to, not cleared by an // effect: the task-loading effect re-runs whenever the task list changes, which the autosave itself causes. const opening = useRef({}); const recoveryEpoch = useRef(0); useEffect(() => { opening.current = {}; return () => { opening.current = undefined; }; }, []); const recoverPacketAnswer = (form: Task, readCurrent: () => boolean = () => true) => { const owner = opening.current; const current = () => owner !== undefined && opening.current === owner && viewerEpoch() === openingViewer.current && readCurrent(); const admit = () => { if (!current()) throw new Error('This form opening has ended.'); }; const subjectPatientId = patientState?.id; if (packetAnswerId(form) && (!subjectPatientId || form.for?.reference !== `Patient/${subjectPatientId}`)) { throw new Error('This form does not belong to the current patient.'); } return continuePacketAnswer(form, { readResponse: id => fetchQuestionnaireResponse(id, { subjectPatientId: subjectPatientId!, admit }), readTask: id => fetchTask(id, { fresh: true }), writeTask: updateTask, current, }); }; const saveQueue = useRef(oneAtATime()); const createdResponse = useRef<{ taskId?: string; response?: QuestionnaireResponse }>({}); const [showConfirmationDialog, setShowConfirmationDialog] = useState(false); // The bound visit and the previous sent version, loaded only for a closed task. const [boundAppointment, setBoundAppointment] = useState(); const [previousResponse, setPreviousResponse] = useState(); // The patient has reopened sent answers to change them. const [amending, setAmending] = useState(false); // Undefined while the bound visit loads; callers treat anything but true as a refusal. const amendableNow = useCallback( () => mayAmendNow(Boolean(task && getTaskAppointmentId(task)), boundAppointment), [task, boundAppointment], ); // A long form's answers are changed on its own page, as they were first given: the overlay sends // the patient here to amend, and amend mode starts once the visit confirms they can still change. const location = useLocation(); const amendOnArrival = useRef((location.state as { amend?: boolean } | null)?.amend === true); useEffect(() => { if (amendOnArrival.current && task?.status === 'completed' && amendableNow() === true) { amendOnArrival.current = false; setAmending(true); } }, [task, amendableNow]); /** * CAN THE PERSON AT THE KEYBOARD ACTUALLY FILE THIS? * * Read off the caller's OWN proxies list, which is the only place a helper can learn their tier: the * route that carries the grant is patients-only and self-scoped, and refuses them. `my-proxies` is * fetched without the acting-as header, so it answers about the person at the keyboard even while they * are switched in. * * `undefined` means not known - a pre-deploy server, a failed read, or not acting for anybody - and * reads as yes. See `mayCompleteFormsAsProxy` for why this fails open. */ const { proxies, listAnswered } = useMyProxies(); const proxyTier = actingAs ? proxyStanding(proxies, actingAs.relatedPersonId, listAnswered) : undefined; /* THE FORM IS NOT THIS READER'S TO FILL IN, and the screen knows it before they start. Declared above the effects, which read it after an early-return render too. Acting-as only: on their own record a patient is never in this state. */ const proxyCannotComplete = !!actingAs && !mayCompleteFormsAsProxy(proxyTier); const [confirmationAction, setConfirmationAction] = useState(null); const spacerMargin = '24px'; useFhirTasks(); useFhirTaskBroadcast(); useFhirQuestionnaireResponses(); useFhirQuestionnaireResponseBroadcast(); const methods = useForm({ defaultValues: {} }); const { handleSubmit, reset, getValues } = methods; /* THE FIRST THING A PATIENT TYPES WAS NEVER AUTOSAVED, and this line is the whole reason. MEASURED as a patient on staging: on a freshly opened questionnaire the first edit produced no save and no announcement; the SECOND edit saved immediately. It recurred after every remount - navigate away and back, or reload - so a patient who answers one question and walks away lost it. That is precisely the case this feature exists for. `formState` is a PROXY. React Hook Form only computes and updates the properties something has subscribed to, and subscription happens by reading the property DURING RENDER. `isDirty` was read in exactly one place - inside the debounce callback - so nothing ever subscribed, and it answered a stale `false` on the first change. Reading it here subscribes. The ref carries it to the timer, because the decision must still be taken at FIRE time: the form can be submitted or emptied during the debounce, and a value captured when the timer was scheduled would describe a world that has since changed. */ const { isDirty } = methods.formState; const isDirtyRef = useRef(isDirty); isDirtyRef.current = isDirty; /** * The reason this submission must not leave the browser, or `undefined` when it may. * * Asked of the TRANSFORMED resource - the thing the server would receive - so it cannot disagree * with the server about what counts as an answer. * * Returns `undefined` when the page has not finished loading rather than blocking: there is * nothing to submit yet either way, and refusing here would report a load state as an empty form. */ const emptySubmissionRefusal = (data: FieldValues, action: QuestionnaireSubmissionAction) => { // `Array.isArray` as well as the three resources: the transform reads `formData.item` directly, so // being asked this before `reset()` has seeded the form would throw inside a click handler - which // would be a new silent failure on the button this card exists to stop failing silently. if (!questionnaire || !patientState || !task || !Array.isArray(data?.item)) { return undefined; } // A form that cannot be answered on its pinned edition is refused here too, rather than thrown in a click. let response: QuestionnaireResponse; try { response = transformToFhirQuestionnaireResponse(data, questionnaire, patientState, task); } catch (error) { return (error as Error).message; } return emptyQuestionnaireSubmissionRefusal(response, action); }; useEffect(() => { const owner = opening.current; const current = () => owner !== undefined && opening.current === owner && viewerEpoch() === openingViewer.current; const initTask = async () => { if (!id || task) { return; } setLoading(true); setError(undefined); try { const response: Task = await fetchTask(id); if (!current()) return; setTask(response); if (isQuestionnaireTask(response)) { await loadQuestionnaire(response); } } catch (error) { if (!current()) return; console.error('Failed to load task:', error); setError(relayServerFailure(error, 'Failed to load task.')); } finally { if (current()) setLoading(false); } }; initTask(); }, [id, fetchTask]); const loadQuestionnaire = async (task: Task | undefined) => { if (!task) return; const owner = opening.current; const run = ++readRun.current; const current = () => owner !== undefined && opening.current === owner && viewerEpoch() === openingViewer.current && run === readRun.current; const admit = () => { if (!current()) throw new Error('This form opening has ended.'); }; if (heldUnsentDraft.current && questionnaire) heldUnsentDraft.current = getValues(); readable.current = false; setReadState('pending'); setError(undefined); setQuestionnaire(undefined); setQuestionnaireLoading(true); try { const questionnaireId = extractReferenceId(task.focus); if (questionnaireId) { if (packetAnswerId(task) && (!patientState?.id || task.for?.reference !== `Patient/${patientState.id}`)) throw new Error('This form does not belong to the current patient.'); // The answers are read before the form and on their own, so a deleted form cannot hide them. const responseId = extractReferenceId(task.output?.[0]?.valueReference); const recovered = !responseId ? await recoverPacketAnswer(task, current) : undefined; admit(); if (recovered) setTask(recovered.task); let questionnaireResponse: QuestionnaireResponse | undefined = recovered?.response; if (responseId || recovered) { questionnaireResponse = recovered?.response ?? await fetchQuestionnaireResponse(responseId as string, packetAnswerId(task) ? { subjectPatientId: patientState!.id!, admit } : undefined); admit(); validatePacketAnswer(task, questionnaireResponse, responseId ?? packetAnswerId(task)!); setExistingQuestionnaireResponse(questionnaireResponse); createdResponse.current = { taskId: task.id, response: questionnaireResponse }; // A form picked up again already has its answers saved, and says when, as the // deck's resumed form does ("All answers saved · 2 minutes ago"). const savedAt = Date.parse(questionnaireResponse?.meta?.lastUpdated ?? questionnaireResponse?.authored ?? ''); if (questionnaireResponse?.status === 'in-progress' && !Number.isNaN(savedAt)) { setAutosave({ kind: 'saved', at: savedAt }); } } // A packet form is read only at the edition it pins, for its own practice: never today's form instead. const pin = packetPinOf(task); const practice = task.meta?.tag?.find(tag => tag.system === ORGANIZATION_TAG_SYSTEM)?.code; if (pin && !practice) throw new Error(PIN_UNREADABLE); const loadedQuestionnaire = pin ? await fetchQuestionnaireEdition(pin, { organizationId: practice!, admit }) : await fetchQuestionnaire(questionnaireId as string); admit(); if (!loadedQuestionnaire || loadedQuestionnaire.resourceType !== 'Questionnaire' || (loadedQuestionnaire.item !== undefined && !Array.isArray(loadedQuestionnaire.item))) throw new Error('The questionnaire could not be read.'); if (pin && !isPinnedEdition(loadedQuestionnaire, pin)) throw new Error(PIN_UNREADABLE); const transformedQuestionnaire = transformFromFhirQuestionnaire(loadedQuestionnaire, questionnaireResponse); if (!questionnaireResponse && heldUnsentDraft.current) { reset(heldUnsentDraft.current, { keepDefaultValues: true }); } else { reset(transformedQuestionnaire); } heldUnsentDraft.current = undefined; setQuestionnaire(loadedQuestionnaire); readable.current = true; setReadState('ready'); } else { throw new Error('The questionnaire reference could not be read.'); } } catch (error) { if (!current()) return; setReadState('failed'); console.error('Failed to load questionnaire:', error); setError(relayServerFailure(error, 'The saved answers could not be checked. Reload this form before continuing.')); } finally { if (current()) setQuestionnaireLoading(false); } }; useEffect(() => { if (!patientState || !task) { return; } const handleTaskEvent = (cloudEvent: FhirCloudEvent) => { const taskPayload: Task = cloudEvent.data; const eventType = cloudEvent.type; if (taskPayload.id === id) { switch (eventType) { case 'com.akashichealthrecords.fhir.task.updated': setTask(taskPayload); break; case 'com.akashichealthrecords.fhir.task.deleted': leave(); break; } } }; subscribe('com.akashichealthrecords.fhir.task.updated', handleTaskEvent); subscribe('com.akashichealthrecords.fhir.task.deleted', handleTaskEvent); return () => { unsubscribe('com.akashichealthrecords.fhir.task.updated', handleTaskEvent); unsubscribe('com.akashichealthrecords.fhir.task.deleted', handleTaskEvent); }; }, [patientState, task, subscribe, unsubscribe, id, navigate]); const handleQuestionnaireSubmission = (data: FieldValues, isCompleted: boolean): Promise => { const owner = opening.current; const observedRecovery = recoveryEpoch.current; return saveQueue.current(() => saveQuestionnaireResponse(data, isCompleted, owner, observedRecovery)); }; const saveQuestionnaireResponse = async (data: FieldValues, isCompleted: boolean, owner: object | undefined, observedRecovery: number) => { const checkOpening = () => { if (!readable.current || viewerEpoch() !== openingViewer.current) throw new Error('This form is not ready to save. Retry reading it first.'); if (task && packetAnswerId(task) && (owner === undefined || opening.current !== owner)) throw new Error('This form opening has ended. Reopen it to continue.'); if (observedRecovery !== recoveryEpoch.current) throw new Error('Previously saved answers were recovered. Review them before continuing.'); }; checkOpening(); if (savedStanding.current) throw new Error('Reload the saved answers before continuing.'); if (!task) { return; } /* ALREADY SUBMITTED MEANS ALREADY SUBMITTED, and this is the guard that makes that true rather than merely apparent. The autosave has already asked this question - `shouldAutosave` refuses on `completed`, and its own note says NOT AFTER SUBMIT. Nothing asked it on the path the patient actually presses. A closed task rendered as a live form, and a second Submit PUT the response and the task again with whatever the form now held, under a confirmation reading "This action cannot be undone". Hiding the button is not enough and must not be the only change: the form is still a `
`, so Enter in a text answer reaches here with no button involved. The refusal belongs at the write. */ // A closed task may be written only while its answers are still amendable. if (isClosedTask(task) && amendableNow() !== true) { const message = 'These answers were used at your visit, so they are part of your record now. Message the front desk if something in them is wrong.'; setError(message); throw new Error(message); } const action: QuestionnaireSubmissionAction = isCompleted ? 'submit' : 'saveForLater'; // Cleared at the START of each attempt rather than on success. A reason that outlives the // attempt it described is its own defect: the patient answers a question, submits again, and is // left reading the old sentence while the submission succeeds behind it. setError(undefined); try { const fhirQuestionnaireResponse = transformToFhirQuestionnaireResponse(data, questionnaire as Questionnaire, patientState as Patient, task); // The chokepoint. Both buttons come through here, so neither can silently send nothing. // The click handlers ask the same question earlier, only so an empty form is not made to sit // through a confirmation first. const refusal = emptyQuestionnaireSubmissionRefusal(fhirQuestionnaireResponse, action); if (refusal) { setError(refusal); return; } fhirQuestionnaireResponse.status = isCompleted ? 'completed' : 'in-progress'; let questionnaireResponse: QuestionnaireResponse; const remembered = createdResponse.current.taskId === task.id ? createdResponse.current.response : undefined; const existing = remembered ?? existingQuestionnaireResponse; if (existing) { checkOpening(); questionnaireResponse = await updateQuestionnaireResponse({ id: existing.id, ...fhirQuestionnaireResponse, meta: { ...fhirQuestionnaireResponse.meta, versionId: existing.meta?.versionId } }, { admit: checkOpening }); // The new version is what the comparison is made against, so it replaces the old one. if (createdResponse.current.taskId === task.id) { createdResponse.current = { taskId: task.id, response: questionnaireResponse }; } setExistingQuestionnaireResponse(questionnaireResponse); } else { // A prior first write can have succeeded without returning its answer or Task link. // Show that saved version before accepting edits; never replace it with this unseen draft. let recovered: Awaited>; try { recovered = await recoverPacketAnswer(task, () => { checkOpening(); return true; }); } catch (readError) { // Retire queued saves as well as the visible Submit; retain the unsent draft. checkOpening(); heldUnsentDraft.current = data; readable.current = false; recoveryEpoch.current += 1; setReadState('failed'); throw readError; } if (recovered) { recoveryEpoch.current += 1; createdResponse.current = { taskId: task.id, response: recovered.response }; setExistingQuestionnaireResponse(recovered.response); setTask(recovered.task); reset(transformFromFhirQuestionnaire(questionnaire as Questionnaire, recovered.response)); throw new Error('Previously saved answers were recovered. Review them before continuing.'); } checkOpening(); questionnaireResponse = await createQuestionnaireResponse(fhirQuestionnaireResponse, task.id, { admit: checkOpening }); createdResponse.current = { taskId: task.id, response: questionnaireResponse }; setExistingQuestionnaireResponse(questionnaireResponse); } checkOpening(); const taskToLink = packetAnswerId(task) ? await fetchTask(task.id as string, { fresh: true }) : task; if (packetAnswerId(task) && ['cancelled', 'entered-in-error', 'failed', 'rejected', 'draft'].includes(taskToLink.status)) { throw new Error('Your answers were saved, but this form is no longer available to answer.'); } const updatedTask = { ...taskToLink, status: isCompleted || taskToLink.status === 'completed' ? 'completed' : 'in-progress', output: [ { type: { text: 'QuestionnaireResponse' }, valueReference: createReferenceObject('QuestionnaireResponse', questionnaireResponse.id as string, questionnaire?.title || 'Untitled') } ] } as Task; if (packetAnswerId(task) && !taskToLink.meta?.versionId) throw new Error('Reload this form to read its current version.'); checkOpening(); const savedTask = await updateTask(updatedTask, packetAnswerId(task) ? { headers: { 'If-Match': `W/"${taskToLink.meta?.versionId}"` }, admit: checkOpening } : undefined); setTask(savedTask); /* A BACKGROUND SAVE MUST NOT MOVE THE PATIENT, and until now it did. MEASURED as a patient on staging: three seconds after the autosave announced "All answers saved", the page navigated itself to the dashboard with the form half-answered. This banner and that navigation belong to a SUBMIT - the patient pressed something, they are finished, and taking them back to the list is the right end to that. The autosave reuses this function deliberately, so it inherited both. The error branch below was already gated for exactly this reason: the card's note says a background failure must not raise the page-level alert because "an alert appearing over a form nobody asked to save reads as the form being broken". The success half was missed. Throwing somebody out of a form they are still filling in is the louder version of the same mistake. `isCompleted` is the only caller distinction and it is sufficient: `true` is the Submit button, `false` saves a draft for autosave or Close. */ if (isCompleted) { setAmending(false); setSuccess(`Questionnaire response saved.`); setTimeout(() => setSuccess(undefined), 3_000); setTimeout(() => leave(), 3_000); } } catch (error) { const standing = savedAnswerRefusal(error); if (standing) { savedStanding.current = standing; setAutosave({ kind: standing }); setSuccess(undefined); setError(describeAutosave({ kind: standing })); throw error; } console.error('Failed to handle questionnaire submission:', error); // THE DEFECT THIS CLOSES. Until now this catch did nothing but `console.error` - which no // patient will ever see - so the screen behaved exactly as it does on success: the dialog // closed and nothing objected. The patient believes the form went in, the practice never // receives it, and the task stays open against someone who is sure they have done it. setError(describeServerRefusal( error, isCompleted ? 'Your answers were not submitted' : 'Your answers were not saved' )); throw error; } }; const onSubmit = async (data: FieldValues) => { setSubmitting(true); try { await handleQuestionnaireSubmission(data, true); } catch (error) { console.error(error); } finally { setSubmitting(false); } }; /** * - THE AUTOSAVE. * * It reuses `handleQuestionnaireSubmission(data, false)` - the exact path *Save for Later* used - * so the resource it writes, the status it sets and the task it updates are unchanged. What changed * is only WHO decides to call it. Writing a second save path would be two answers to one question. * * IT REPORTS ITS OWN FAILURES rather than raising the page's `error`. `handleQuestionnaireSubmission` * sets that on a failed write, which is right for a submit the patient just pressed and wrong for a * background save: an alert appearing over a form nobody asked to save reads as the form being broken. * So the failure is caught here, the page-level error is cleared, and the state line under the * progress carries it - which is also the one place the patient is looking for it. */ const autosaveTimer = useRef | undefined>(undefined); const submittingRef = useRef(false); submittingRef.current = submitting; const autosaveInFlight = useRef(false); const closeInFlight = useRef(false); const [closing, setClosing] = useState(false); // Close owns its flush until both answer and Task have answered. Unmount still retires it. const closeForm = async () => { if (closeInFlight.current || submittingRef.current) return; const owner = opening.current; if (savedStanding.current) { setError(describeAutosave({ kind: savedStanding.current })); return; } if (autosaveTimer.current) { clearTimeout(autosaveTimer.current); autosaveTimer.current = undefined; } if (heldUnsentDraft.current && !readable.current) { setError('Your draft is still here. Retry reading the saved answers before closing.'); return; } if (!isDirtyRef.current || !questionnaire || proxyCannotComplete) { leave(); return; } const refusal = emptySubmissionRefusal(getValues(), 'saveForLater'); if (refusal) { setError(refusal); return; } closeInFlight.current = true; setClosing(true); setAutosave({ kind: 'saving' }); try { await handleQuestionnaireSubmission(getValues(), false); if (opening.current === owner && owner !== undefined) leave(); } catch { // The save path has kept the draft and its failure on this opening. } finally { closeInFlight.current = false; if (opening.current === owner) setClosing(false); } }; useEffect(() => registerExitGuard?.(() => { void closeForm(); })); const onClose = embedded ? () => { void closeForm(); } : undefined; const runAutosave = async () => { if (!readable.current || autosaveInFlight.current || closeInFlight.current || savedStanding.current) return; autosaveInFlight.current = true; setAutosave({ kind: 'saving' }); try { await handleQuestionnaireSubmission(getValues(), false); setAutosave({ kind: 'saved', at: Date.now() }); // The page-level error is cleared on success, because the only thing that could have set it // in this flow is a previous autosave - leaving it would contradict the line saying it saved. setError(undefined); } catch (autosaveError) { // Swallowed on purpose: `handleQuestionnaireSubmission` re-throws so a SUBMIT can surface, // and the state line below is where a background failure belongs instead. // // AND THE PAGE-LEVEL ERROR IT SET ON THE WAY OUT IS CLEARED HERE, WHICH IS THE HALF THAT WAS // MISSING. That function sets `error` for BOTH callers before re-throwing. Right for a submit // the patient pressed; wrong for a save nobody asked for - an alert appearing over a form they // are still filling in reads as the form being broken, and its "Please try again" names a // button this feature deleted, so the patient is told to press something that is not there. // // The comment above this function already claimed the page error was cleared on failure. Only // the SUCCESS path was doing it. Observed in the patient portal on 2026-08-30 with the write // forced to fail: the state line and a page alert both fired for one background save. // // The state line is deliberately left saying the answers were not saved - that sentence is the // whole feature, and quieting both messages would reintroduce the silent-failure defect the // autosave exists to prevent. setError(undefined); // A STATUS MEANS THE SERVER ANSWERED, so this was a refusal rather than an unreachable // practice, and the line must not claim otherwise. const answered = typeof (autosaveError as { status?: unknown })?.status === 'number'; setAutosave({ kind: savedStanding.current ?? (answered ? 'refused' : 'failed') }); } finally { autosaveInFlight.current = false; } }; /** * The debounce. Watches every change and schedules one write after the patient stops. * * `shouldAutosave` is asked at FIRE TIME, not at schedule time. The form can be submitted, or go * empty, inside the two seconds - and a decision taken before the wait would act on a world that has * since changed. That is the whole reason the predicate is a pure function rather than an `if` here. */ useEffect(() => { const subscription = methods.watch(() => { if (autosaveTimer.current) clearTimeout(autosaveTimer.current); autosaveTimer.current = setTimeout(() => fireAutosave(), AUTOSAVE_DEBOUNCE_MS); }); function fireAutosave() { autosaveTimer.current = undefined; const ready = Boolean(readState === 'ready' && questionnaire && patientState && task && !loading && !questionnaireLoading); const decision = shouldAutosave({ ready, // NOT `methods.formState.isDirty` - reading the proxy only from in here is what // made the first edit never save. See the note where `isDirtyRef` is set. dirty: isDirtyRef.current, completed: task?.status === 'completed', // Read through a ref: a flush from the cleanup must see a Submit that has just started. busy: submittingRef.current || autosaveInFlight.current || Boolean(savedStanding.current), // A helper who may not file this form must not write to it either. Walked // 2026-09-15: one toggle fired an autosave POST that came back 403, so the screen // kept trying to save work the server had already refused. refusal: proxyCannotComplete ? 'this proxy may not complete forms' : ready ? emptySubmissionRefusal(getValues(), 'saveForLater') : 'not ready', }); if (decision) void runAutosave(); } return () => { subscription.unsubscribe?.(); // Flush a pending save rather than drop it. if (autosaveTimer.current) { clearTimeout(autosaveTimer.current); fireAutosave(); } }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [methods, questionnaire, patientState, task, loading, questionnaireLoading, submitting, readState]); const handleSubmitClick = (e: React.FormEvent) => { e.preventDefault(); if (!readable.current || savedStanding.current || closeInFlight.current) return; // Asked before the empty check and before the dialog: a patient whose answers are already on // record should not be walked through a confirmation for something that will be refused. if (task && isClosedTask(task) && amendableNow() !== true) { setError('These answers were used at your visit, so they are part of your record now. Message the front desk if something in them is wrong.'); return; } // Asked BEFORE the confirmation dialog. Making someone confirm a submission and then // telling them there was nothing to send is a worse sequence than refusing at the press, and it // is the sequence a browser run watched a patient walk through. const refusal = emptySubmissionRefusal(getValues(), 'submit'); if (refusal) { setError(refusal); return; } setError(undefined); setConfirmationAction('submit'); setShowConfirmationDialog(true); }; const handleConfirmAction = () => { setShowConfirmationDialog(false); if (confirmationAction === 'submit') { handleSubmit(onSubmit)(); } setConfirmationAction(null); }; const handleCancelConfirmation = () => { setShowConfirmationDialog(false); setConfirmationAction(null); }; // - the save-for-later branch is gone with its button. Submit is the only thing that // still asks, because it is the only one the patient initiates. const getConfirmationMessage = () => confirmationAction === 'submit' ? 'Your practice can use these answers now. You can change them until they are used at your visit.' : ''; // Declared above the early returns so the hook runs on every render. Cleared first so a previous // task's visit or history never decides this one. useEffect(() => { setBoundAppointment(undefined); setPreviousResponse(undefined); if (!task || !isClosedTask(task)) return; let cancelled = false; const appointmentId = getTaskAppointmentId(task); if (appointmentId) { fetchAppointment(appointmentId) .then((appointment) => { if (!cancelled) setBoundAppointment(appointment); }) // An unreadable visit leaves amendability undecided, which refuses. .catch(() => undefined); } const responseId = existingQuestionnaireResponse?.id; if (responseId) { fetchQuestionnaireResponseHistory(responseId, 20) .then((history) => { // The most recent earlier version that was sent, which is what "Was:" names. const versions = (history.entry ?? []) .map((entry) => entry.resource) .filter((resource): resource is QuestionnaireResponse => Boolean(resource)); if (!cancelled) { setPreviousResponse(previousSentVersion(versions, existingQuestionnaireResponse?.meta?.versionId)); } }) .catch(() => undefined); } return () => { cancelled = true; }; }, [ task, existingQuestionnaireResponse?.id, existingQuestionnaireResponse?.meta?.versionId, fetchAppointment, fetchQuestionnaireResponseHistory, ]); // In a dialog the close sits on the status row, which is drawn before the task has loaded too. const closeRow = embedded ? : null; if (loading && !task) { return <>{closeRow}; } if (!task && error) { return (
{embedded ? closeRow : { void closeForm(); }}/>} {error}
); } if (!task) { return <>{closeRow}; } if (task && isCustomTask(task)) { return (
{!embedded && { void closeForm(); }}/>} {/* In a dialog the dialog scrolls; a second fixed-height scroller cut the outcomes off. */} {/* In the dialog the task fills the overlay; the page's centred column is for the full page. */} {embedded ? ( ) : ( )}
); } /* THE ONE QUESTION THIS SCREEN NEVER ASKED. `task.status` was read in exactly one place in this component - the autosave guard - and nowhere in what it draws, so a completed task rendered identically to an untouched one: enabled answers, a live Submit, and nothing saying it was done. `isClosedTask` rather than `status === 'completed'` on purpose. It is the repo's own definition of outstanding-versus-closed, and it is the SAME predicate the Completed tab filters with before routing a patient here - so the screen now agrees with the list that sent them to it. It also covers a cancelled or failed task, which must not be answerable either. DECLARED BELOW THE `!task` GUARDS, deliberately. Up with the other handlers it reads more naturally, and `task` is still possibly undefined there - `isClosedTask` would throw on the first render of every questionnaire task before its fetch resolves. */ const taskIsClosed = isClosedTask(task); // Only sent answers can be changed; a cancelled or failed task is closed, not amendable. const amendable = task?.status === 'completed' ? amendableNow() : false; const changedCount = changedAnswerCount(existingQuestionnaireResponse, previousResponse); const changedQuestions = describeChangedQuestions(existingQuestionnaireResponse, previousResponse); const amendedOn = changedCount > 0 ? formatDateTime(existingQuestionnaireResponse?.authored ?? task?.lastModified) : undefined; // When it was first submitted, for the line that says so; an amendment does not move it. With no // response at all, the task's own `lastModified` is the fallback for a task closed some other way. const submittedAt = formatDateTime(existingQuestionnaireResponse ? submittedAtOf(existingQuestionnaireResponse) : task.lastModified); // A sent form is named for where its answers stand, not for the task's workflow code. const status: { label: string; tone: TaskStatusTone } = task.status === 'completed' ? { label: amendable === false ? 'Part of your visit' : changedCount > 0 ? 'Sent · updated' : 'Sent', tone: 'done', } : { label: getTaskStatusDisplay(task), tone: task.status === 'failed' ? 'bad' : task.status === 'cancelled' ? 'ended' : 'open', }; const title = questionnaire?.title || task.description || 'Questionnaire'; const questionCount = countQuestions(questionnaire); const formIsLive = !!questionnaire && (!taskIsClosed || amending) && !proxyCannotComplete; const toResponse = (values: FieldValues): QuestionnaireResponse | undefined => { if (!questionnaire || !patientState || !Array.isArray(values?.item)) { return undefined; } try { return transformToFhirQuestionnaireResponse(values, questionnaire, patientState, task); } catch { return undefined; } }; // A long form is not answered inside the dialog: it opens on its own page, and says why. if (embedded && !taskIsClosed && isLongForm(questionnaire)) { const appointment = resolveTaskAppointment(task, appointmentRead.appointmentsList); const noneBookedIsKnown = visitListProvesNoneBooked({ ...appointmentRead, // Another reader's failed refresh can leave the previous empty bundle in the shared cache. error: appointmentRead.error || appointmentsFailedFor === patientState?.id || shouldReload, }, readsForAnother(auth)); const openFullPage = () => { navigate(`/dashboard/home/task/${task.id}`); onExit?.(); }; return (

{title}

{`${questionCount} questions. Your answers save as you go, so you can return later.`}
); } // `aria-live` because this is the one place a failed autosave is announced; `polite` so it does not // interrupt someone mid-answer. const autosaveLine = ( {describeAutosave(autosave)} {savedStanding.current && } ); const maySubmit = readState === 'ready' && !!questionnaire && !savedStanding.current && (!taskIsClosed || amending) && !proxyCannotComplete; const submitButton = maySubmit ? ( ) : null; const progress = formIsLive && questionCount > 0 ? : null; // In the dialog the questions fill the overlay; the page's centred column is for the full page. const FormColumn = embedded ? Fragment : ProfileLayoutContainer; return (
{readState === 'failed' &&

The form or saved answers could not be checked. Retry before continuing.

} {embedded ? (

{title}

{progress} {autosaveLine}
) : ( <> { void closeForm(); }}> {autosaveLine} {submitButton} {progress &&
{progress}
} )} {/* First send is a success notice, matching CustomTaskDetails. An amendment is the deck's amber .alert.info: DismissibleAlert info, the shared ak-notice. */} {/* A voided questionnaire was never submitted; say so, as the custom-task screen does. */} {task.status === 'cancelled' && ( <>
This questionnaire was cancelled.
{task.statusReason?.text &&
Reason: {task.statusReason.text}
}
)} {task.status === 'completed' && ( <> {changedCount > 0 && (

{`Updated ${formatLongDate(existingQuestionnaireResponse?.authored ?? task.lastModified)}. ${task.requester?.display || task.owner?.display || 'Your practice'} can see both what you sent first and what you changed.`}

)} {submittedAt ? `You submitted this questionnaire on ${submittedAt}.` : 'You have submitted this questionnaire.'} {changedQuestions && ` ${changedQuestions} The practice can see both versions.`} {amendable === true && ' You can still change your answers until they are used at your visit.'} {amendable === false && ' These answers were used at your visit, so they are part of your record now and cannot be edited here. If something in them is wrong, message the front desk.'} )} {/* SAID BEFORE THE WORK, NOT AFTER IT. The server refuses this write from an assist-only proxy, and until now that refusal was the first the reader heard of it - after every answer was typed. This names what is missing and who can change it, which the server's refusal cannot: only the patient can raise a tier, and they do it in one place. */} {proxyCannotComplete && !taskIsClosed && ( <> {describeProxyCannotCompleteForms(actingAs?.subjectName)} )} {error && ( <> setError(undefined)}> {error} )} {success && ( <> setSuccess(undefined)}> {success} )} {/* `answerable` IS WHAT MAKES A FILE QUESTION ANSWERABLE. QuestionnairePreview is the author's preview, the staff read-back AND this form; for every other question type that made no difference, and for a file it was the difference between a control and a disabled one. */} {loading || questionnaireLoading ? ( ) : questionnaire ? ( /* ONCE IT IS SENT IT IS A RECORD, AND A RECORD IS NOT A FORM YOU CANNOT USE. The first version of this wrapped the live questionnaire in `Disabled`, which is what the three staff read-back surfaces do. That put the right content in the wrong clothes: a disabled form reads as one you failed to activate, and disabled controls are skipped or unlabelled by screen readers with greyed text that fails contrast - so the one thing a patient has a legal right to read became the least readable thing on the screen. */ taskIsClosed && !amending ? ( <> {/* Offered only when amendable is true, never while the visit loads. */} {amendable === true && (
)} ) : proxyCannotComplete ? ( /* A FORM SOMEBODY MAY READ AND MAY NOT FILL IN, which is exactly what this tier grants. `Disabled` rather than `answerable={false}`: that prop governs the FILE question alone - it was added for that one type - so passing it false left every other control live. WALKED 2026-09-15 and the switch still toggled, and the toggle fired a write the server refused. The argument against `Disabled` elsewhere on this screen was about a SUBMITTED response, where the answers are a record and a record must not wear a broken form's clothes. This is the other case: a live form the reader may look at and not complete, which is what a disabled form actually means. */ ) : (
) ) : existingQuestionnaireResponse ? ( // The form is gone but the answers are the patient's record: read them back as sent. ) : ( The form is unavailable. )}
{embedded && submitButton && (
{submitButton}
)} {/* Confirmation Dialog */} {/* The app's own confirm, not WordPress's. Same three parts ConfirmDialog drew: a question, the message, and Cancel beside the action. */} { if (!open) handleCancelConfirmation(); }} > {`Send your answers to ${task?.owner?.display || 'your practice'}?`} {getConfirmationMessage()} Keep editing Send
); }; // Everything on the page belongs to one task, so a new task id starts a fresh page rather than // showing the last task's answers under the new id. const TaskPage = (props: TaskPageProps = {}) => { const { id: routeId } = useParams(); const { patientState } = usePatientServiceContext(); const { actingAs } = useAuthServiceContext(); const [viewer, setViewer] = useState(viewerEpoch); useEffect(() => onViewerChange(() => setViewer(viewerEpoch())), []); return ; }; export default TaskPage;