import { withRead, type ReadOptions } from '../hooks/anchorView/readOptions'; import { isSaveRetired, type SaveTransport } from '../utils/saveOperation'; import { compareVersions, holdWrite, isWithheldReceipt, overlayHeldBundle, sameViewerSince, startWrite, writtenSince } from '../utils/writeThroughHold'; import { ifMatch } from '../utils/recordChanged'; import { useState, useCallback, createContext, useContext, ReactNode, useEffect, useRef } from 'react'; import { Appointment, Attachment, Bundle, Encounter, Observation, Organization, Patient, Practitioner, Questionnaire, Reference, Task, TaskRestriction } from 'fhir/r4'; import { environment } from '../environments/environment.ts'; import { useFhirServiceContext } from './FhirService.tsx'; import { useHttpService } from './HttpService.tsx'; import { useOptionalAuthServiceContext } from './AuthService.tsx'; import { formatFhirHumanName } from '../utils/fhirHumanName.tsx'; import { useEncounterServiceContext } from './EncounterService.tsx'; import { usePatientServiceContext } from './PatientService.tsx'; import { formatFhirCodeableConcept } from '../utils/fhirCodeableConcept.tsx'; import { applyArchive, isArchived, type ArchiveReason } from '../utils/archivedResource'; import { statusLabel } from '../utils/fhirStatusVocabulary'; import { fetchAllPagesAsBundle } from '../utils/fetchAllPagesAsBundle'; import { answeredMeasurementWrites, getTaskMeasurement, isUntouchedAnswerReading } from '../utils/measurementTask'; const READING_ALREADY_RECORDED = 'This reading was already recorded by another answer just now. Nothing was replaced; look at it before answering again.'; const READING_CHANGED_BY_CARE_TEAM = 'Your care team has changed this reading, so your answer did not replace it. Ask them about it.'; import { everyStepTicked, withTickedStep } from '../utils/taskChecklist'; import { completedWithMessage } from '../utils/taskCompletionMessage'; import { ANSWER_ALREADY_SENT, answerRefusalCode, type AnswerScope, buildRequestedDocument, forgetPendingAnswer, isDocumentRequestTask, isFiledAnswer, keepPendingAnswer, promotePendingAnswer, newSendMark, pendingAnswerMark, pendingAnswerFor, refusalSavedAnswer, refusedBeforeSending, SAVED_NOT_SETTLED, SAVED_PRIVATE, SEND_UNCONFIRMED, settledRequestOf, unsettledAnswerText, withDocumentRequestExtension, } from '../utils/documentRequestTask'; import { searchPageSize } from '../utils/pageSizeOverride'; import { useCacheOwner } from '../utils/cacheOwner'; import { mayMissRecentWrite } from '../utils/recentWritesUnresolved'; import { customTaskCodeText } from '../utils/customTaskEdit'; const API_URL = environment.fhirBaseApi; // Answering a measurement writes a READING as well as closing the task, and a reading is not a task - // it lives on the clinical host. Named here rather than reached for inline so the two bases cannot be // confused again by somebody who only sees `API_URL` above. const OBSERVATION_API_URL = environment.fhirClinicalApi; // A DocumentReference lives on the foundation API, which is neither of the two above. const DOCUMENT_API_URL = environment.fhirFoundationApi; // Settling a request against its filed answer is served by the org-authorization stack. const SETTLE_API_URL = environment.orgAuthorizationApi; export const AKASHIC_TASK_TYPE_SYSTEM = 'https://akashichealthrecords.com/fhir/CodeSystem/task-type'; export const AKASHIC_TASK_STATUS_REASON_SYSTEM = 'https://akashichealthrecords.com/fhir/CodeSystem/task-status-reason'; export const AKASHIC_TASK_DUE_MODE_EXTENSION = 'https://akashichealthrecords.com/fhir/extensions/task-due-mode'; export const AKASHIC_TASK_RECURRENCE_FREQUENCY_EXTENSION = 'https://akashichealthrecords.com/fhir/extensions/task-recurrence-frequency'; export const AKASHIC_TASK_APPOINTMENT_EXTENSION = 'https://akashichealthrecords.com/fhir/extensions/task-appointment'; export const AKASHIC_CUSTOM_TASK_CODE = 'custom'; export const AKASHIC_QUESTIONNAIRE_TASK_CODE = 'questionnaire'; export const PATIENT_UNABLE_TO_COMPLETE_CODE = 'patient-unable-to-complete'; export const STAFF_WITHDRAWN_CODE = 'staff-withdrawn'; /** Names the withdrawal that cancelled a Task, so its own outcome can be told from anyone else's. */ export const TASK_WITHDRAWAL_OPERATION_URL = 'https://akashichealthrecords.com/fhir/StructureDefinition/task-withdrawal-operation'; /** One confirmed withdrawal: its frozen reason, operation and version, and the write order of the viewer it began under. */ export type WithdrawalAttempt = Readonly<{ task: Task; taskId: string; reason: string; operation: string; expectedVersion: string; startedIn: number; /** The acting-as session it began in; switching away and back is a new session, never this one. */ actingAsSession: number; }>; /** What became of a withdrawal, from its own answer or its exact-id readback. */ export type WithdrawalOutcome = | { kind: 'confirmed'; task: Task } | { kind: 'refused'; error: unknown } | { kind: 'cancelled-other'; task: Task } | { kind: 'outstanding-same-version' } | { kind: 'changed'; status: string; task: Task } | { kind: 'unreadable' } | { kind: 'retired' }; /** A withdrawal whose outcome is still unknown, kept for its Task so a reopened form checks it instead of sending anew. */ export type UnresolvedWithdrawal = Readonly<{ attempt: WithdrawalAttempt; standing: 'sending' | 'outstanding-same-version' | 'unreadable' }>; /** The viewer a withdrawal began under is gone; nothing more is sent or read for it. */ export class WithdrawalRetired extends Error { constructor() { super('The withdrawal belongs to a viewer who has gone.'); this.name = 'WithdrawalRetired'; } } export class WithdrawalVersionMissing extends Error { constructor() { super('This task could not be checked. Reload it and try again.'); this.name = 'WithdrawalVersionMissing'; } } const CHANGED_WITHDRAWAL_CODES: readonly string[] = ['TASK_VERSION_CHANGED', 'TASK_CHANGED_WHILE_SAVING', 'TASK_NOT_OUTSTANDING']; const withdrawalOperationOf = (task: Task): string | undefined => task.statusReason?.extension?.find((extension) => extension.url === TASK_WITHDRAWAL_OPERATION_URL)?.valueString; // A Task this attempt can classify and order: its own id, a status and a version. Anything less is not shown. const usableWithdrawalTask = (read: unknown, taskId: string): read is Task => (read as Task | null)?.resourceType === 'Task' && (read as Task).id === taskId && typeof (read as Task).status === 'string' && typeof (read as Task).meta?.versionId === 'string'; const confirmsWithdrawal = (answer: unknown, attempt: WithdrawalAttempt): answer is Task => usableWithdrawalTask(answer, attempt.taskId) && answer.status === 'cancelled' && withdrawalOperationOf(answer) === attempt.operation; /** A refusal made before any write, a refusal saying the Task changed, or an answer that proves nothing. */ const classifyWithdrawalFailure = (error: unknown): 'refused' | 'changed' | 'unknown' => { const status = (error as { status?: unknown } | null)?.status; const code = (error as { body?: { code?: unknown } } | null)?.body?.code; if (typeof code === 'string' && CHANGED_WITHDRAWAL_CODES.includes(code)) return 'changed'; if (code === 'TASK_VERSION_REQUIRED') return 'refused'; return status === 400 || status === 403 || status === 404 || status === 409 ? 'refused' : 'unknown'; }; export type AkashicTaskKind = 'custom' | 'questionnaire' | 'legacy'; export type CustomTaskDueMode = | 'this-appointment' | 'next-appointment' | 'specific-appointment' | 'date' | 'recurring'; export type CustomTaskRecurrenceFrequency = 'daily' | 'weekly' | 'monthly' | 'annually'; export interface CreateCustomTaskInput { patient: Patient; /** The task's name, carried in code.text as a checklist template's is; description keeps the instructions. */ title?: string; instructions: string; priority?: Task['priority']; dueMode?: CustomTaskDueMode; dueDate?: string; recurrenceFrequency?: CustomTaskRecurrenceFrequency; appointment?: Appointment; /** What the patient is asked to send; blank or absent asks for no file. */ documentRequest?: string; /** The action this task belongs to: it is sent and shown only while that action still is. */ transport?: SaveTransport; } export interface CreateQuestionnaireTaskInput { patient: Patient; questionnaire: Questionnaire; priority?: Task['priority']; dueDate?: string; appointment?: Appointment; /** The action this task belongs to: it is sent and shown only while that action still is. */ transport?: SaveTransport; } const CUSTOM_TASK_DUE_MODES: CustomTaskDueMode[] = [ 'this-appointment', 'next-appointment', 'specific-appointment', 'date', 'recurring' ]; const CUSTOM_TASK_RECURRENCE_FREQUENCIES: CustomTaskRecurrenceFrequency[] = [ 'daily', 'weekly', 'monthly', 'annually' ]; const isCustomTaskDueMode = (value: string | undefined): value is CustomTaskDueMode => Boolean(value && CUSTOM_TASK_DUE_MODES.includes(value as CustomTaskDueMode)); const isCustomTaskRecurrenceFrequency = ( value: string | undefined ): value is CustomTaskRecurrenceFrequency => Boolean( value && CUSTOM_TASK_RECURRENCE_FREQUENCIES.includes( value as CustomTaskRecurrenceFrequency ) ); const getTaskCodeExtensionValue = (task: Task, url: string): string | undefined => task.extension?.find(extension => extension.url === url)?.valueCode; const getTaskReferenceExtensionValue = ( task: Task, url: string ): Reference | undefined => task.extension?.find(extension => extension.url === url)?.valueReference; export const getTaskAppointmentReference = (task: Task): Reference | undefined => { const appointmentReference = getTaskReferenceExtensionValue( task, AKASHIC_TASK_APPOINTMENT_EXTENSION ); if (appointmentReference?.reference?.startsWith('Appointment/')) { return appointmentReference; } const basedOnAppointment = task.basedOn?.find(reference => reference.reference?.startsWith('Appointment/') ); if (basedOnAppointment) { return basedOnAppointment; } if (task.focus?.reference?.startsWith('Appointment/')) { return task.focus; } return undefined; }; export const getTaskAppointmentId = (task: Task): string | undefined => getTaskAppointmentReference(task)?.reference?.split('/')[1]; export const getCustomTaskDueMode = (task: Task): CustomTaskDueMode => { const declaredMode = getTaskCodeExtensionValue( task, AKASHIC_TASK_DUE_MODE_EXTENSION ); if (isCustomTaskDueMode(declaredMode)) { return declaredMode; } const recurrenceFrequency = getTaskCodeExtensionValue( task, AKASHIC_TASK_RECURRENCE_FREQUENCY_EXTENSION ); if (isCustomTaskRecurrenceFrequency(recurrenceFrequency)) { return 'recurring'; } if (task.focus?.reference?.startsWith('Appointment/')) { return 'next-appointment'; } if (task.restriction?.period?.end) { return 'date'; } return 'next-appointment'; }; export const getCustomTaskRecurrenceFrequency = ( task: Task ): CustomTaskRecurrenceFrequency | undefined => { const frequency = getTaskCodeExtensionValue( task, AKASHIC_TASK_RECURRENCE_FREQUENCY_EXTENSION ); return isCustomTaskRecurrenceFrequency(frequency) ? frequency : undefined; }; export const formatCustomTaskDueDate = (value: string): string => { const isDateOnly = /^\d{4}-\d{2}-\d{2}$/.test(value); let date: Date; if (isDateOnly) { const [year, month, day] = value.split('-').map(Number); date = new Date(year, month - 1, day); } else { date = new Date(value); } if (Number.isNaN(date.getTime())) { return value; } const formattedDate = new Intl.DateTimeFormat('en-US', { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' }).format(date); if (isDateOnly) { return formattedDate; } const formattedTime = new Intl.DateTimeFormat('en-US', { hour: 'numeric', minute: '2-digit' }).format(date); return `${formattedDate} at ${formattedTime}`; }; export const getAppointmentTypeDisplay = (appointment: Appointment): string | undefined => appointment.appointmentType?.text?.trim() || appointment.appointmentType?.coding?.find(coding => coding.display?.trim())?.display?.trim(); export const getAppointmentOrganizationDisplay = (appointment: Appointment): string | undefined => appointment.participant?.find(participant => participant.actor?.reference?.startsWith('Organization/') )?.actor?.display?.trim(); export const getAppointmentPractitionerDisplay = (appointment: Appointment): string | undefined => appointment.participant?.find(participant => participant.actor?.reference?.startsWith('Practitioner/') || participant.actor?.reference?.startsWith('PractitionerRole/') )?.actor?.display?.trim(); export const getAppointmentReferenceDisplay = (appointment: Appointment): string => { const appointmentDate = appointment.start ? formatCustomTaskDueDate(appointment.start) : undefined; const practitioner = getAppointmentPractitionerDisplay(appointment); const appointmentType = getAppointmentTypeDisplay(appointment); return [appointmentDate, practitioner, appointmentType] .filter((value): value is string => Boolean(value)) .join(' · ') || 'Scheduled appointment'; }; const recurrenceFrequencyLabels: Record = { daily: 'Daily', weekly: 'Weekly', monthly: 'Monthly', annually: 'Annually' }; /** * "Due by: your next appointment - not scheduled yet". * * The tail is the approved patient-portal deck's wording, and it says out loud what this label already * MEANT: it is returned when the appointment has no start, which is the case where no visit is booked. The * old text left the patient to infer that from an absence, and the two readings are not the same - "your * next appointment" reads as though one exists and they have simply not been told when. * * A CONSTANT BECAUSE IT IS COMPARED, NOT ONLY RENDERED. `getAppointmentDueSummary` below tests the label * to decide whether it is a complete sentence or needs a date appended, so a literal typed in two places * would silently start appending a date to a sentence that already ends. */ export const DUE_BY_UNSCHEDULED_NEXT_APPOINTMENT = 'your next appointment \u2014 not scheduled yet'; /** * "Due by: your next appointment", for a task whose visit this reader cannot see and cannot show is * missing: it carries a due date, which says one is booked, or the visit list cannot prove none is. */ export const DUE_BY_NEXT_APPOINTMENT = 'your next appointment'; /** * An open task owed by the next visit that no visit this reader can see answers yet. A task with no due * extension is in this mode by default, so it has a deadline even though it names no date. */ export const awaitsUnresolvedNextAppointment = (task: Task, resolvedAppointment?: Appointment): boolean => !isClosedTask(task) && !resolvedAppointment && !task.restriction?.period?.end && getCustomTaskDueMode(task) === 'next-appointment'; /** * WHAT THE APPOINTMENT IS TO THIS PATIENT RIGHT NOW - never how the code chose it. * * THE DEFECT THIS EXISTS FOR, measured on a patient's Tasks tab 2026-08-17: a task read * *"Soonest upcoming appointment - Mon, Aug 17, 2026 at 10:00 AM"* - a time that had already passed, on a * visit the patient had already arrived for. *"Soonest upcoming appointment"* is the **rule** that * selected it. A rule is true when it runs and keeps being printed afterwards, so the label goes stale the * moment that visit begins - which, for a task due at that visit, is exactly when the patient is looking. * * This does NOT change how the appointment is resolved. moved that from a frozen date to an * intent resolved on read, and that is correct. * * A KNOWN LIMIT, stated rather than hidden: an appointment whose start has passed reads *"This visit"* * even if it finished some time ago. That is reachable only through `resolveTaskAppointment`'s * due-date-match branch, which - unlike its soonest-upcoming branch - applies no future filter. Two labels * is what the observed defect needs; a third for *"the visit it was due at has been and gone"* is a * separate sentence, and inventing it here would put a third phrasing of one fact on the same screen. */ export const getAppointmentDueLabel = ( dueMode: CustomTaskDueMode | undefined, resolvedAppointment?: Appointment, now: number = Date.now() ): string | null => { // Both of these NAME a visit. Whether staff assigned it at one or picked a specific one is about // how the task was set up and changes nothing the patient can act on, so they read the same. if (dueMode === 'this-appointment' || dueMode === 'specific-appointment') return 'appointment on'; if (dueMode !== 'next-appointment') return null; const start = resolvedAppointment ? getTaskAppointmentStartTimestamp(resolvedAppointment) : undefined; // Unknown start is NOT treated as under way. With nothing to place it in time the ordering claim is // the only one still supported, and it is what the patient needs when the visit is not scheduled yet. if (start === undefined) return DUE_BY_UNSCHEDULED_NEXT_APPOINTMENT; return start > now ? 'next appointment on' : 'this visit'; }; export const getCustomTaskDueDisplay = ( task: Task, resolvedAppointment?: Appointment, now: number = Date.now(), noneBookedIsKnown: boolean = true ): string => { const dueMode = getCustomTaskDueMode(task); const dueDate = resolvedAppointment?.start || task.restriction?.period?.end; const appointmentDisplay = resolvedAppointment ? getAppointmentReferenceDisplay(resolvedAppointment) : getTaskAppointmentReference(task)?.display?.trim(); // A tail that already names WHEN stands alone; one that trails off takes the date. "Due by: your // next appointment" is a complete sentence, where "Due by: appointment on" is not. const getAppointmentDueSummary = (label: string): string => { if (label === DUE_BY_UNSCHEDULED_NEXT_APPOINTMENT || label === 'this visit') { return `Due by: ${label}`; } if (appointmentDisplay) { return `Due by: ${label} ${appointmentDisplay}`; } // A task pinned to a visit we cannot resolve or date. It must not borrow "your next // appointment": that names a different visit, and this one was pinned deliberately. return dueDate ? `Due by: ${label} ${formatCustomTaskDueDate(dueDate)}` : 'Due by: your appointment'; }; if (dueMode === 'next-appointment' && !resolvedAppointment && (task.restriction?.period?.end || !noneBookedIsKnown || !followsSoonestUpcomingAppointment(task))) { return 'Due by: ' + DUE_BY_NEXT_APPOINTMENT; } const appointmentLabel = getAppointmentDueLabel(dueMode, resolvedAppointment, now); if (appointmentLabel) { return getAppointmentDueSummary(appointmentLabel); } if (dueMode === 'recurring') { const frequency = getCustomTaskRecurrenceFrequency(task); // The repeat is the TAIL, so the row still opens with the same two words as every other one. const repeats = frequency ? `repeats ${recurrenceFrequencyLabels[frequency].toLowerCase()}` : 'repeats'; return dueDate ? `Due by: ${formatCustomTaskDueDate(dueDate)} · ${repeats}` : `Due by: ${repeats}`; } return dueDate ? `Due by: ${formatCustomTaskDueDate(dueDate)}` : 'No due date'; }; /** * - does this due mode name ONE appointment, or describe a relationship to be resolved later? * * EXPORTED SO THE WRITE-SIDE RULE IS TESTABLE. It lived inline in the create path, where the only way * to exercise it was to stand up the whole service provider - so a mutation that re-pinned * `next-appointment` changed nothing any test could see. That is the defect this card is about, hiding * in the one place no test reached. * * `this-appointment` and `specific-appointment` genuinely NAME a visit: the reference is the fact, and * re-resolving it later would quietly turn it into a different task. * `next-appointment` is a RELATIONSHIP. Storing the appointment that happened to be next at assignment * freezes it against a visit that will pass. */ export const taskDueModePinsAnAppointment = (dueMode: CustomTaskDueMode): boolean => dueMode !== 'next-appointment'; /** * The visit a new custom task names. A pinned mode stores it as the fact; next-appointment only offers it * as a candidate, because a visit booked a moment ago is not yet searchable and the server picks the soonest. */ export const customTaskAppointmentLink = ( dueMode: CustomTaskDueMode, appointment?: Appointment ): { extension?: NonNullable[number]; basedOn?: Reference[] } => { if (!appointment?.id) { return {}; } const display = getAppointmentReferenceDisplay(appointment); const reference: Reference = { reference: `Appointment/${appointment.id}`, ...(display && { display }) }; const extension = { url: AKASHIC_TASK_APPOINTMENT_EXTENSION, valueReference: reference }; return taskDueModePinsAnAppointment(dueMode) ? { extension, basedOn: [reference] } : { extension }; }; export const getAkashicTaskKind = (task: Task): AkashicTaskKind => { const taskTypeCode = task.code?.coding?.find( coding => coding.system === AKASHIC_TASK_TYPE_SYSTEM )?.code; if (taskTypeCode === AKASHIC_CUSTOM_TASK_CODE) { return 'custom'; } if (taskTypeCode === AKASHIC_QUESTIONNAIRE_TASK_CODE) { return 'questionnaire'; } if (task.focus?.reference?.startsWith('Questionnaire/')) { return 'questionnaire'; } return 'legacy'; }; export const isCustomTask = (task: Task | undefined): boolean => Boolean(task && getAkashicTaskKind(task) === 'custom'); export const isQuestionnaireTask = (task: Task | undefined): boolean => Boolean(task && getAkashicTaskKind(task) === 'questionnaire'); /** * CAN THE PATIENT'S OWN TASK SCREEN ACTUALLY DO THIS TASK? Every place that decides where to send a * patient asks THIS, never the individual kinds. * * `TaskPage` at `/dashboard/home/task/{id}` handles both: a **custom** task renders `CustomTaskDetails` * with "I completed this task", and a **questionnaire** task renders the questions with Submit and Save * for Later, creating the QuestionnaireResponse and moving the Task to `completed`. * * THE DEFECT THIS EXISTS TO CLOSE. Two routers independently asked `isCustomTask` alone, so a * questionnaire task went somewhere else in both: the home queue's "Start form" landed the patient on a * raw FHIR Task editor (Task Status, Intent, Priority, *"Part of composite task"*), and the task list * opened a read-only Task Details dialog. **Neither contained a single question or any way to finish** - * so no patient had ever completed an assigned questionnaire, and one had sat `requested` since June 21. * The screen was built and correct the whole time. Only the two decisions about where to go were wrong, * and they were wrong SEPARATELY, which is why this is one predicate and not two more call sites. * * `legacy` is excluded deliberately: it has no Questionnaire focus, so the questions branch would * render an empty form with a Submit button. */ export const hasPatientFacingTaskScreen = (task: Task | undefined): boolean => isCustomTask(task) || isQuestionnaireTask(task); /** * IS THIS TASK THE PATIENT'S OWN TO DO? Everything the patient sees a task list on asks THIS. * * THE DEFECT IT CLOSES. `Task?patient=` returns every Task whose `for` is this patient, and `for` * means "who it is ABOUT" - so a referral the practice must send lands there too, and the patient was * shown it as something THEY must do, due by their next appointment. They cannot do it, and being told * to is worse than not being told: it manufactures an obligation they cannot discharge. * * OWNER CANNOT BE THE AXIS, and this is the thing to know before "improving" this. It reads like the * right field - the referral's owner is the Organization - but `Task/post.ts` sets `owner` to the * Organization for EVERY task it creates, questionnaires and custom tasks included. Filtering on it * empties the patient's list entirely, which satisfies the complaint by deleting the feature. * * AN ALLOWLIST, NEVER AN EXCLUSION LIST. Excluding referrals leaves the next internal task type waiting * to appear on a patient's list the day someone adds one; naming what IS the patient's means a new * internal type is invisible to them by default. Fail closed. * * WHY IT IS NOT SIMPLY `hasPatientFacingTaskScreen`, AND THIS IS THE WHOLE SUBTLETY. * `getAkashicTaskKind` answers `legacy` for two genuinely different things: * - a task from before the code system, carrying NO task-type coding at all. Those are the patient's; * they predate the typing and they have always been on this list. * - a task that explicitly DECLARES a type, where that type is not a patient-facing one. A referral * is this: `system: akashic task-type, code: referral`. It is not untyped, it is typed as * something else. * Filtering on the KIND alone erases both, which removes real patient tasks along with the referral. * * SO THE RULE IS: a patient-facing kind, OR no declared type at all. An explicitly-typed task we do not * recognise as the patient's is withheld - so the next internal task type is invisible to them the day * it is written, without anyone remembering to exclude it. That is the allowlist property, kept. */ const hasDeclaredAkashicTaskType = (task: Task): boolean => (task.code?.coding ?? []).some( (coding) => coding.system === AKASHIC_TASK_TYPE_SYSTEM && Boolean(coding.code), ); export const isPatientsOwnTask = (task: Task | undefined): boolean => Boolean(task) && (hasPatientFacingTaskScreen(task) || !hasDeclaredAkashicTaskType(task as Task)); const TASK_APPOINTMENT_STATUSES = new Set([ 'proposed', 'pending', 'booked', 'arrived', 'checked-in', 'waitlist' ]); const ACTIVE_TASK_STATUSES = new Set([ 'draft', 'requested', 'received', 'accepted', 'ready', 'in-progress', 'on-hold' ]); // A task is "outstanding" while its status is active (still actionable); everything else // (completed / failed / cancelled / rejected / entered-in-error) is "closed". export const isOutstandingTask = (task: Task): boolean => ACTIVE_TASK_STATUSES.has(task.status); export const isClosedTask = (task: Task): boolean => !ACTIVE_TASK_STATUSES.has(task.status); const getTaskDueTimestamp = (task: Task): number | undefined => { const dueDate = task.restriction?.period?.end; if (!dueDate) { return undefined; } const timestamp = Date.parse(dueDate); return Number.isFinite(timestamp) ? timestamp : undefined; }; const getTaskAppointmentStartTimestamp = ( appointment: Appointment ): number | undefined => { if (!appointment.start) { return undefined; } const timestamp = Date.parse(appointment.start); return Number.isFinite(timestamp) ? timestamp : undefined; }; const findAppointmentMatchingTaskDueDate = ( task: Task, appointments: Appointment[] ): Appointment | undefined => { const dueTimestamp = getTaskDueTimestamp(task); if (dueTimestamp === undefined) { return undefined; } return appointments.find(appointment => { const startTimestamp = getTaskAppointmentStartTimestamp(appointment); return ( startTimestamp !== undefined && Math.abs(startTimestamp - dueTimestamp) < 60_000 ); }); }; /** * Resolves the appointment a task belongs to, including relative * "soonest upcoming appointment" tasks that were created before an * appointment existed and legacy appointment tasks that only retained a due date. */ /** * Whether a task with no visit of its own is matched to the soonest upcoming one. Only then does finding * none mean none is booked; a task naming a visit, or a legacy task, was never looked up that way. */ export const followsSoonestUpcomingAppointment = (task: Task): boolean => { if (getTaskAppointmentId(task)) return false; const kind = getAkashicTaskKind(task); return (kind === 'custom' && getCustomTaskDueMode(task) === 'next-appointment') || (kind === 'questionnaire' && getTaskDueTimestamp(task) === undefined); }; export const resolveTaskAppointment = ( task: Task, appointments: Appointment[], nowTimestamp = Date.now() ): Appointment | undefined => { const appointmentId = getTaskAppointmentId(task); if (appointmentId) { return appointments.find(appointment => appointment.id === appointmentId); } const kind = getAkashicTaskKind(task); const dueMode = kind === 'custom' ? getCustomTaskDueMode(task) : undefined; const dueDateAppointment = findAppointmentMatchingTaskDueDate(task, appointments); if ( dueDateAppointment && ( kind === 'questionnaire' || dueMode === 'this-appointment' || dueMode === 'next-appointment' || dueMode === 'specific-appointment' ) ) { return dueDateAppointment; } if ( !followsSoonestUpcomingAppointment(task) || !ACTIVE_TASK_STATUSES.has(task.status) ) { return undefined; } return appointments .filter(appointment => TASK_APPOINTMENT_STATUSES.has(appointment.status)) .map(appointment => ({ appointment, startTimestamp: getTaskAppointmentStartTimestamp(appointment) })) .filter( (candidate): candidate is { appointment: Appointment; startTimestamp: number } => candidate.startTimestamp !== undefined && candidate.startTimestamp >= nowTimestamp ) .sort((left, right) => left.startTimestamp - right.startTimestamp)[0] ?.appointment; }; //, SETTLED - THE SECOND VOCABULARY IS GONE. Spencer ruled 2026-08-14: one map, and `failed` // reads "Unable to complete" everywhere. // // This used to be a five-case switch keeping gentler words than the shared map ('failed' "Unable to // complete", 'cancelled' "Withdrawn", 'rejected' "Declined", 'entered-in-error' "Removed"), on the // grounds that a Task is something the PATIENT is asked to do. // // THE PREMISE WAS WRONG, AND MEASURING IT SETTLED THE DECISION. These words are not patient-facing, // they are TASK-facing, and STAFF READ THEM TODAY: the clinician's view of a patient's tasks // (PatientTasks) and the receptionist workspace (WorkspaceTaskDetail) both render this, alongside the // patient's own task page. The split was by RESOURCE TYPE, never by audience - so "patient-facing // surfaces legitimately speak differently" described a boundary that did not exist in the code. // // AND THREE OF THE FOUR FAILED ON ACCURACY RATHER THAN TONE: // - 'rejected' "Declined" COLLIDES. `declined` is already its own code in the shared map // (Medication*, the patient declined a drug), so two distinct states would render as one word on the // same chart - the performer rejecting a task, and a patient refusing a medication. // - 'entered-in-error' "Removed" ASSERTS SOMETHING ELSE. FHIR means *this record should never have // existed*; "Removed" means *it existed and was taken away*. On a medication that is the difference // between never prescribed and discontinued, and on the evidentiary record it is a misstatement. // - 'cancelled' "Withdrawn" overloads a word already in play beside `revoked` and `superseded`. // Only "Unable to complete" survived, so it was adopted GLOBALLY rather than kept here. // // Do not reintroduce a surface-specific override. `oneStatusVocabularyNoSecondMap.test.ts` compares // every known status against `statusLabel` and fails on the first one that disagrees with itself. /** * Does this task's status tell a patient anything their LIST does not already say? * * Spencer, 2026-08-25, looking at a list where every row read *"Requested"*: *"isn't everything * requested? I'm thinking that has zero value and can be dropped."* * * READ THE CIRCUMSTANCE, WHICH THE QUOTE ALONE LOSES: *looking at a list where every row read * "Requested"*. The argument is that a word true of EVERY ROW separates nothing - the same * argument, verbatim, that later took the Routine chip off the patient's task rows. It is about * UNIFORMITY, not about status words. * * SO THE GENERAL FORM IS THE ONE TO KEEP: a status word true of every row of the list it sits in * is dead. That kills "Requested", "Received", "Accepted" and "Completed" on a tab that already * says so, and beside a long title such a word wraps, which is why it never sat in the same place * twice. * * THIS WAS ONCE READ AS KILLING FIVE WORDS AND IT KILLED FOUR. Corrected 2026-09-08 by * claude-4 from the 2026-08-25 log rather than from the quote: **Outstanding is not a synonym for * in-progress, it is its superset.** A patient who half-answered a questionnaire resumes; one who * never opened it starts. The tab cannot separate those two and the word can - so `in-progress` * was never uniform, and it has its own predicate below rather than a place in this set. * * WHAT SURVIVES IS WHAT THE TAB CANNOT SAY. A task that was cancelled, or that could not be * completed, is sitting in a list the patient is scanning for things to DO - and the reason it is * inert is exactly what they came to find out. Dropping these would leave a row that simply never * responds. */ const STATUS_WORTH_SAYING: ReadonlySet = new Set([ 'cancelled', 'failed', 'entered-in-error', 'rejected', 'on-hold', ]); export const isExceptionalTaskStatus = (task: Task): boolean => Boolean(task.status) && STATUS_WORTH_SAYING.has(task.status); /** * Started and not finished - the one ordinary status the tab genuinely cannot say. * * SEPARATE FROM THE SET ABOVE RATHER THAN ADDED TO IT, because that set means *this row is inert * and here is why*, and a half-finished questionnaire is the opposite of inert: it is the row a * patient can pick straight back up. Folding them together would give it that set's alarm * treatment wherever the two are read as one. */ export const isResumableTaskStatus = (task: Task): boolean => task.status === 'in-progress'; export const getTaskStatusDisplay = (task: Task): string => // 'Status unknown' is the honest answer for a code we have no words for - never the raw code, which // is the original defect (`task.status.replace(/-/g, ' ')` title-cased: the enum wearing the shape of // a deliberate label). statusLabel(task.status) ?? 'Status unknown'; export const useTaskService = () => { const { get, post, put, delete: del } = useHttpService(); const { patientState } = usePatientServiceContext(); const { transformToCodeableConcept, transformFromCodeableConcept, createReferenceObject, formatDateTime, formatDateTimeForInput, formatDateTimeForDisplay, addFhirPeriodIfApplicable, addPeriodControlsIfApplicable, unpackBundle, transformToReferenceCollection, transformFromReferenceCollection, extractReferenceId } = useFhirServiceContext(); const { getEncounterDisplay } = useEncounterServiceContext(); const [tasksList, setTasksList] = useState([]); const [shouldReload, setShouldReload] = useState(false); const [lastUpserted, setLastUpserted] = useState(null); const [unsaved, setUnsaved] = useState(false); const [tasksBundle, setTasksBundle] = useState | null>(null); const tasksBundleRef = useRef | null>(null); const lastFetchTimeRef = useRef(0); useEffect(() => { tasksBundleRef.current = tasksBundle; }, [tasksBundle]); // WHOSE RECORDS ARE THESE? The reset below fires only when there is NO patient, which moving // between two charts never is - so the previous patient's bundle survived the switch and was // served as a cache hit, with no request and nothing on screen to say the list was not theirs. const cacheOwner = useCacheOwner('Task', () => { setLastUpserted(null); setTasksBundle(null); setTasksList([]); lastFetchTimeRef.current = 0; }); // The patient whose last task read failed, so every reader can tell failed from empty. const [tasksFailedFor, setTasksFailedFor] = useState(); useEffect(() => { if (!patientState) { setTasksBundle(null); setTasksList([]); lastFetchTimeRef.current = 0; cacheOwner.release(); } }, [patientState, cacheOwner.release]); const fetchTasks = useCallback(async (patient: Patient, transport?: SaveTransport, options?: ReadOptions): Promise> => { const bundle = await fetchAllPagesAsBundle((pageParam) => { const params = new URLSearchParams(); params.append('_count', searchPageSize('100')); params.append('patient', patient.id as string); // params.append('status:not', 'completed'); if (pageParam) params.set('page', pageParam); return get>(`${API_URL}/Task?${params.toString()}`, withRead({ ...transport }, options)); }); /* * THE HOLD WAS ONLY ON THE POLLING VARIANT, and this is the plain one - which is what the care * plan editor calls. Measured in the product: a weekly measurement added to a plan answered 201 * and the very next `?patient=` search returned an empty bundle, so the editor drew "Nothing * yet" over an activity that existed. The practitioner's only reading is that the button did * not work, and pressing it again makes a second one. * * Same defect the care plan fetch had, one resource type across. A fix on the polling read alone * leaves every caller of the plain one exactly as broken. */ return overlayHeldBundle('Task', bundle, (task) => { const extracted = extractReferenceId(task.for); const taskPatientId = Array.isArray(extracted) ? extracted[0] : extracted; return taskPatientId === patient.id; }) as Bundle; }, [get, extractReferenceId]); const fetchTasksWithPolling = useCallback(async (patient: Patient, forceReload = false): Promise> => { const now = Date.now(); const cacheAge = now - lastFetchTimeRef.current; const cacheFresh = cacheAge < 5 * 60 * 1000; // cacheOwner.isFor is the load-bearing clause: a fresh bundle belonging to the PREVIOUS // patient satisfies every other condition here and would be returned as a hit. // A list the API said may be missing a recent change is asked again rather than served as the answer. if (!forceReload && !shouldReload && !writtenSince('Task', lastFetchTimeRef.current) && tasksBundleRef.current && cacheFresh && cacheOwner.isCurrentFor(patient.id) && !mayMissRecentWrite(tasksBundleRef.current)) { return tasksBundleRef.current; } const operation = cacheOwner.begin(); /* * WHAT USED TO BE HERE AND WHY IT COULD NOT WORK. A retry loop polled this search up to ten * times at three seconds, waiting for the id that had just been written to turn up. It was * guessing at a lag it cannot know: a Task written by the redeem handler was still not * findable at 2m47s, so thirty seconds of polling ran out and served the list anyway, without * the task. It read as solved and was not. * * LENGTHENING IT IS THE TRAP, not the fix. A longer loop holds the user on a stale list for * longer while hammering a search that is already answering correctly - and the lag is not a * constant to tune against. Measured the same day, a RelatedPerson written from the patient * portal was findable inside 14.4 seconds. Any timeout is wrong at one end or the other. * * A SEARCH INDEX LAGS A WRITE; THE WRITE'S OWN RESPONSE DOES NOT. `createTask` already had * the created resource in hand and let the refresh throw it away, so it is held instead and * merged into whatever the search returns - in FRONT of it, so a searched copy of the same id * overwrites the held one and the server's account always wins. It is released the moment a * search returns that id, and in any case once the hold expires, so a task completed or * archived elsewhere cannot be pinned to the list. */ let bundle: Bundle; try { bundle = await fetchTasks(patient); } catch (failure) { // A retired read's failure is not the current list's. if (cacheOwner.publishes(operation)) setTasksFailedFor(patient.id); throw failure; } // The bundle already carries this viewer's held writes (fetchTasks), so it is published as it is. if (!cacheOwner.publishes(operation)) return cacheOwner.answerFor(operation, bundle); setTasksFailedFor(undefined); setTasksBundle(bundle); setTasksList(unpackBundle(bundle).filter((r) => !isArchived(r))); setLastUpserted(null); setShouldReload(false); lastFetchTimeRef.current = now; // Stamped WITH the data, never before it - on a throw the owner must still name the list // that is actually in state. cacheOwner.claim(patient.id, operation); return bundle; // `lastUpserted` is no longer read here - the hold replaced the wait for it - so it is not a // dependency. It is still SET by a write, because it is what the write has to say. }, [fetchTasks, unpackBundle, extractReferenceId, shouldReload]); const fetchTask = useCallback(async (id: string, options?: { fresh?: boolean }): Promise => { const cachedTask = !options?.fresh && cacheOwner.isCurrent(cacheOwner.claimed) ? tasksList.find(task => task.id === id) : undefined; if (cachedTask) { return cachedTask; } return get(`${API_URL}/Task/${id}`); }, [get, tasksList]); const fetchTaskHistory = useCallback(async (id: string, options?: ReadOptions): Promise> => { return get>(`${API_URL}/Task/${id}/_history`, options); }, [get]); const createTask = useCallback(async (task: Task, transport?: SaveTransport): Promise => { const extractedPatientId = extractReferenceId(task.for); const patientId = Array.isArray(extractedPatientId) ? extractedPatientId[0] : extractedPatientId; if (!patientId) { throw new Error('Cannot create Task without a Patient beneficiary.'); } const params = new URLSearchParams(); params.append('for', patientId); const createdTask = await post(`${API_URL}/Task?${params.toString()}`, task, { ...transport }); // Not shown for an action retired while it was answered. transport?.admit(); setTasksList(prev => [ createdTask, ...prev.filter(existingTask => existingTask.id !== createdTask.id) ]); if (tasksBundleRef.current) { setTasksBundle({ ...tasksBundleRef.current, entry: [ { resource: createdTask }, ...(tasksBundleRef.current.entry || []).filter( entry => entry.resource?.id !== createdTask.id ) ] }); } setLastUpserted(createdTask.id); setShouldReload(false); return createdTask; }, [post, extractReferenceId]); // What a stored task change does to this viewer: held over lagging searches and shown in place in the lists. const showTaskWrite = useCallback((updatedTask: Task, taskId: string, startedIn: number) => { holdWrite(updatedTask, Date.now(), startedIn); setTasksList(prev => prev.map(existingTask => existingTask.id === taskId ? updatedTask : existingTask ) ); if (tasksBundleRef.current?.entry) { setTasksBundle({ ...tasksBundleRef.current, entry: tasksBundleRef.current.entry.map(entry => ({ ...entry, resource: entry.resource?.id === taskId ? updatedTask : entry.resource })) }); } setLastUpserted(updatedTask.id || taskId); setShouldReload(false); }, []); const updateTask = useCallback(async (task: Task, transport?: Partial & { headers?: Record }): Promise => { const taskId = task.id; if (!taskId) { throw new Error('Cannot update Task without an id.'); } const startedIn = startWrite(); const responseTask = await put(`${API_URL}/Task/${taskId}`, task, { ...transport }); const updatedTask: Task = responseTask?.resourceType === 'Task' && responseTask.id ? responseTask : task; // A save that answered with no resource is held as what was sent. showTaskWrite(updatedTask, taskId, startedIn); return updatedTask; }, [put, showTaskWrite]); /** * Closes a document request against the answer the server holds for it, for a sender who may not read that answer * back. Undefined only when the server proves nothing is filed for it, so a file can be chosen and sent. */ const settleDocumentRequestTask = useCallback(async ( task: Task, options?: { transport?: Partial; scope?: AnswerScope; held?: string }, ): Promise => { const taskId = task.id; if (!taskId) throw new Error('Cannot settle a request without an id.'); const startedIn = startWrite(); // Only the send held when this check began is settled by its answer; one kept since stays held. const held = options?.held ?? pendingAnswerMark(options?.scope); let answer: unknown; try { answer = await put(`${SETTLE_API_URL}/Task/${taskId}/settle-answer`, {}, { ...options?.transport }); } catch (error) { if (answerRefusalCode(error) === 'NO_SAVED_ANSWER') { forgetPendingAnswer(options?.scope, held); return undefined; } throw error; } const settled = settledRequestOf(answer, task); if (!settled) throw new Error(SAVED_NOT_SETTLED); forgetPendingAnswer(options?.scope, held); options?.transport?.admit?.(); showTaskWrite(settled, taskId, startedIn); return settled; }, [put, showTaskWrite]); // A confirmed save whose request is not closed yet: closed now, or said plainly to still need checking. const settleSavedAnswer = useCallback(async ( task: Task, options?: { transport?: Partial; scope?: AnswerScope; held?: string }, ): Promise => { let settled: Task | undefined; try { settled = await settleDocumentRequestTask(task, options); } catch (error) { if (isSaveRetired(error)) throw error; throw new Error(unsettledAnswerText(error)); } if (!settled) throw new Error(SAVED_NOT_SETTLED); return settled; }, [settleDocumentRequestTask]); const deleteTask = useCallback(async (task: Task): Promise => { const deletedTask = await del(`${API_URL}/Task/${task.id}`); setTasksList(prev => prev.filter(existingTask => existingTask.id !== task.id) ); if (tasksBundleRef.current?.entry) { setTasksBundle({ ...tasksBundleRef.current, entry: tasksBundleRef.current.entry.filter( entry => entry.resource?.id !== task.id ) }); } setShouldReload(false); return deletedTask; }, [del]); const archiveTask = useCallback(async (task: Task, reason: ArchiveReason, note?: string): Promise => { const archivedTask = await put(`${API_URL}/Task/${task.id}`, applyArchive(task, reason, note)); setTasksList(prev => prev.filter(existingTask => existingTask.id !== task.id) ); if (tasksBundleRef.current?.entry) { setTasksBundle({ ...tasksBundleRef.current, entry: tasksBundleRef.current.entry.filter( entry => entry.resource?.id !== task.id ) }); } setShouldReload(false); return archivedTask; }, [put]); const createQuestionnaireTask = useCallback(async ({ patient, questionnaire, priority = 'routine', dueDate, appointment, transport, }: CreateQuestionnaireTaskInput): Promise => createTask(buildQuestionnaireTask({ patient, questionnaire, priority, dueDate, appointment, }), transport), [createTask]); const createCustomTask = useCallback(async ({ patient, title, instructions, priority = 'routine', dueMode = 'next-appointment', dueDate, recurrenceFrequency, appointment, documentRequest, transport, }: CreateCustomTaskInput): Promise => { const patientId = patient.id; const trimmedInstructions = instructions.trim(); if (!patientId) { throw new Error('Cannot assign a Custom Task to a Patient without an id.'); } if (!trimmedInstructions) { throw new Error('Custom Task instructions are required.'); } if (!isCustomTaskDueMode(dueMode)) { throw new Error('Select a supported Custom Task due option.'); } if ((dueMode === 'date' || dueMode === 'recurring') && !dueDate) { throw new Error( dueMode === 'recurring' ? 'Select the first due date for the recurring task.' : 'Select a due date for the task.' ); } if ( (dueMode === 'date' || dueMode === 'recurring') && dueDate && !/^\d{4}-\d{2}-\d{2}$/.test(dueDate) ) { throw new Error('Custom Task due dates must use YYYY-MM-DD format.'); } const requiresAppointment = dueMode === 'this-appointment' || dueMode === 'specific-appointment'; if (requiresAppointment && !appointment?.id) { throw new Error( dueMode === 'this-appointment' ? 'The appointment must be saved before this task can be assigned.' : 'Select a scheduled appointment for this task.' ); } if (appointment && (!appointment.id || !appointment.start)) { throw new Error('The selected appointment is missing its id or start time.'); } if ( dueMode === 'recurring' && !isCustomTaskRecurrenceFrequency(recurrenceFrequency) ) { throw new Error('Select how often the task should recur.'); } /* - "DUE BY YOUR NEXT APPOINTMENT" IS AN INTENT, NOT A DATE, AND MUST NOT BE PINNED. Spencer, 2026-08-14: *"store the intent."* The MODE was already persisted; what froze the task was writing the RESOLVED appointment beside it. `resolveTaskAppointment` short-circuits on a stored appointment reference - and again on a due date that matches an appointment's start - so a `next-appointment` task stayed bolted to whichever visit was next at assignment. Measured live: a task read *"Due: Soonest upcoming appointment - Fri, Aug 14"* while the patient's next appointment was the 17th. A live phrase on a dead date. `next-appointment` stores the MODE with no resolved date; any visit it names is only a candidate. The read path already resolves the soonest upcoming visit correctly; it simply never got the chance. `this-appointment` and `specific-appointment` KEEP their reference: those name one visit on purpose, and re-resolving them later would silently become a different task. A stored snapshot of a derived fact is the frozen-snapshot bug this codebase has already paid for once, where seat limits froze the same way and stranded every org onboarded before a plan change. */ const pinsAnAppointment = taskDueModePinsAnAppointment(dueMode); const appointmentLink = customTaskAppointmentLink(dueMode, appointment); const resolvedDueDate = (pinsAnAppointment ? appointment?.start : undefined) || ((dueMode === 'date' || dueMode === 'recurring') ? dueDate : undefined); // The same extension a template's file request copies onto its task, so the patient side reads both alike. const extensions: NonNullable = withDocumentRequestExtension([ { url: AKASHIC_TASK_DUE_MODE_EXTENSION, valueCode: dueMode }, ...(dueMode === 'recurring' && recurrenceFrequency ? [ { url: AKASHIC_TASK_RECURRENCE_FREQUENCY_EXTENSION, valueCode: recurrenceFrequency } ] : []), ...(appointmentLink.extension ? [appointmentLink.extension] : []) ], documentRequest); const customTask: Task = { resourceType: 'Task', status: 'requested', intent: 'order', priority, extension: extensions, ...(appointmentLink.basedOn ? { basedOn: appointmentLink.basedOn } : {}), code: { coding: [ { system: AKASHIC_TASK_TYPE_SYSTEM, code: AKASHIC_CUSTOM_TASK_CODE, display: 'Custom task' } ], text: customTaskCodeText(title) }, description: trimmedInstructions, for: createReferenceObject( 'Patient', patientId, formatFhirHumanName(patient.name) ), ...(resolvedDueDate ? { restriction: { period: { end: resolvedDueDate } } } : {}) }; return createTask(customTask, transport); }, [createReferenceObject, createTask]); const completeCustomTask = useCallback(async (task: Task, message?: string): Promise => { if (!isCustomTask(task)) { throw new Error('Only Custom Tasks can be completed with this action.'); } return updateTask(completedWithMessage(task, message)); }, [updateTask]); // Ticking the last step closes the task, unless it still waits on a file or a reading. const tickChecklistStep = useCallback(async (task: Task, stepId: string): Promise => { const ticked = withTickedStep(task, stepId, new Date().toISOString()); const closes = everyStepTicked(ticked) && !isDocumentRequestTask(task) && !getTaskMeasurement(task); return updateTask({ ...ticked, status: closes ? 'completed' : 'in-progress' }); }, [updateTask]); // Answering a MEASUREMENT is two writes, and the reading goes first. // // A goal's progress is derived from the observations that exist, so closing the task without the // reading would tick the activity off as done and leave the bar exactly where it was, with nothing // saying why. That failure is silent and unrepairable. The other order repairs itself: an unclosed // task is answered again, and because the reading's id comes from the task, the retry REPLACES it // rather than leaving a goal counting one weigh-in twice. const completeMeasurementTask = useCallback(async ( task: Task, value: number, carePlanReference?: string, // The answering save's own: a retired answer sends nothing more, the task close included. transport?: SaveTransport, ): Promise => { if (!isCustomTask(task)) { throw new Error('Only Custom Tasks can be completed with this action.'); } const writes = answeredMeasurementWrites(task, value, { now: new Date().toISOString(), carePlanReference }); for (const write of writes) { if (write.kind === 'observation') { const reading = write.resource as Observation; /* * THE CLINICAL HOST, NOT THIS FILE'S `API_URL`. Tasks live on the base API and * Observations on the clinical one, and this is the one place in the codebase that * writes across that line - so it is the one place that can get it wrong, and it did. * * Measured in the product: the patient pressed "Record this reading" and the PUT went to * `fhir-base`, where no such route exists. The browser reported it as a CORS failure * because the preflight is what came back wrong, so the visible error named the wrong * problem entirely - the reading was never refused, it was sent to the wrong building. */ /* * THE SUBJECT GOES IN THE QUERY, AND ON A CREATE IT IS THE ONLY PLACE IT CAN GO. * * This is a PUT to an id the client chose, so it CREATES the observation - answering * the same occurrence twice must replace the reading rather than add a second one, or * a goal counts one weigh-in twice. The authorizer runs before any handler and never * receives a body, so for a resource that does not exist yet there is nothing for it * to read the subject from except the query string. * * Measured in the product: without it the write is refused, and the authorizer says so * in as many words - "this is a create, and the request carries no subject the * authorizer can see." */ // `extractReferenceId` can hand back an array, and a subject is one patient. Taking the // first is not a guess: `reading.subject` is a single Reference, built one line above // from the task's own `for`. const extracted = extractReferenceId(reading.subject); const subject = Array.isArray(extracted) ? extracted[0] : extracted; const params = new URLSearchParams({ subject: subject ?? '' }); // A first answer creates the reading (the server writes it only where there is none). Answered // again, the reading its earlier answer saved is replaced over the version read - but only while it is // still just that answer. One the care team has changed, or one another answer recorded while this one // was being created (412), is not replaced, and the task is not closed. const readingUrl = `${OBSERVATION_API_URL}/Observation/${reading.id}`; try { await put(`${readingUrl}?${params.toString()}`, reading, { ...transport }); } catch (error) { const status = (error as { status?: unknown } | null)?.status; if (status === 412) throw new Error(READING_ALREADY_RECORDED); if (status !== 428) throw error; transport?.admit(); const stored = await get(readingUrl, { ...transport }); if (!isUntouchedAnswerReading(stored, reading)) throw new Error(READING_CHANGED_BY_CARE_TEAM); await put(`${readingUrl}?${params.toString()}`, reading, { headers: ifMatch(stored.meta?.versionId ?? ''), ...transport }); } } } const closed = writes.find((write) => write.kind === 'task')!.resource as Task; transport?.admit(); return updateTask(closed, transport); }, [get, put, updateTask, extractReferenceId]); /** * A REQUESTED FILE, FILED BY THE SERVER AND THEN SETTLED - IN THAT ORDER. * * The answer is one POST carrying the request version the sender opened. The server reserves one answer per * request, files it, and closes the request against what it filed, so a retry lands on the same answer and the * client never names the document on the Task. A readable answer and a receipt for one this viewer may not read * back settle the same way. DocumentReferences live on the foundation API, not this file's `API_URL`. */ const completeDocumentRequestTask = useCallback(async ( task: Task, attachment: Attachment, options?: { onSaved?: (message: string) => void; transport?: Partial; scope?: AnswerScope }, ): Promise => { if (!isCustomTask(task)) { throw new Error('Only Custom Tasks can be completed with this action.'); } const transport = options?.transport; const scope = options?.scope; const settle = { transport, scope }; // A send this viewer already made for this request is settled, never resent; only the server's proof that // nothing is filed lets a new file go. if (pendingAnswerFor(scope)) { const settled = await settleDocumentRequestTask(task, settle); if (settled) return settled; } const document = buildRequestedDocument(task, attachment, { now: new Date().toISOString(), appointmentReference: getTaskAppointmentReference(task), }); const send = newSendMark(); let sent = false; let filed: unknown; try { filed = await post(`${DOCUMENT_API_URL}/DocumentReference`, document, { ...transport, // Kept before the request leaves, so a lost answer still reads as a send to check. onSend: () => { transport?.onSend?.(); sent = true; keepPendingAnswer(scope, 'unknown', send); }, }); } catch (error) { if (isSaveRetired(error)) throw error; // A late answer moves only this send's own hold; a newer send's, or one already settled, is left as it is. if (answerRefusalCode(error) === 'ANSWER_ALREADY_SAVED') { promotePendingAnswer(scope, send); transport?.admit?.(); options?.onSaved?.(ANSWER_ALREADY_SENT); return settleSavedAnswer(task, { ...settle, held: 'saved:' + send }); } if (!sent) throw error; if (refusalSavedAnswer(error)) { promotePendingAnswer(scope, send); throw new Error(unsettledAnswerText(error)); } if (refusedBeforeSending(error)) { forgetPendingAnswer(scope, 'unknown:' + send); throw error; } throw new Error(SEND_UNCONFIRMED); } const withheld = isWithheldReceipt(filed); // An empty or unrelated answer is no proof of filing: the send is checked, not repeated. if (!withheld && !isFiledAnswer(filed, task)) throw new Error(SEND_UNCONFIRMED); promotePendingAnswer(scope, send); transport?.admit?.(); if (withheld) options?.onSaved?.(SAVED_PRIVATE); return settleSavedAnswer(task, { ...settle, held: 'saved:' + send }); }, [post, settleDocumentRequestTask, settleSavedAnswer]); const markCustomTaskUnable = useCallback(async (task: Task, explanation: string): Promise => { if (!isCustomTask(task)) { throw new Error('Only Custom Tasks can be marked unable to complete with this action.'); } const trimmedExplanation = explanation.trim(); if (!trimmedExplanation) { throw new Error('Please explain why this task cannot be completed.'); } return updateTask({ ...task, status: 'failed', statusReason: { coding: [ { system: AKASHIC_TASK_STATUS_REASON_SYSTEM, code: PATIENT_UNABLE_TO_COMPLETE_CODE, display: 'Patient unable to complete' } ], text: trimmedExplanation } }); }, [updateTask]); // Staff withdraw (cancel) a task the patient no longer needs to act on - e.g. a form assigned // by mistake. Kind-agnostic (custom or questionnaire). Sets status 'cancelled'; the patient // sees it labeled "Withdrawn". A reason is required and stored on statusReason. // Operations with a send whose outcome was never proved: a later refusal of the same operation does not settle them. const uncertainWithdrawalsRef = useRef(new Set()); // The unknown attempt per Task, so closing the form never loses it; only an outcome that settles it removes it. const unresolvedWithdrawalsRef = useRef(new Map()); const actingAsSessionRef = useOptionalAuthServiceContext()?.actingAsSessionRef; const actingAsSession = useCallback((): number => actingAsSessionRef?.current ?? 0, [actingAsSessionRef]); // An attempt is live while its viewer is unchanged and the acting-as session it began in is still the current one. const withdrawalLive = useCallback((attempt: WithdrawalAttempt): boolean => sameViewerSince(attempt.startedIn) && actingAsSession() === attempt.actingAsSession, [actingAsSession]); // Every send and read of an attempt is admitted again at the send itself, so a retired attempt never leaves. const admittedFor = useCallback((attempt: WithdrawalAttempt) => (): void => { if (!withdrawalLive(attempt)) throw new WithdrawalRetired(); }, [withdrawalLive]); // Only a live attempt holding its Task's slot sets or settles it: a late answer of a retired or earlier attempt // neither replaces a successor nor brings a settled attempt back. const recordWithdrawal = useCallback((attempt: WithdrawalAttempt, outcome: WithdrawalOutcome): WithdrawalOutcome => { const unresolved = unresolvedWithdrawalsRef.current; if (!withdrawalLive(attempt) || unresolved.get(attempt.taskId)?.attempt !== attempt) return outcome; if (outcome.kind === 'outstanding-same-version' || outcome.kind === 'unreadable') { unresolved.set(attempt.taskId, Object.freeze({ attempt, standing: outcome.kind })); } else if (outcome.kind !== 'retired') { unresolved.delete(attempt.taskId); } return outcome; }, [withdrawalLive]); // A live attempt takes its Task's slot only when no other live attempt holds it; that one stays authoritative. const admitWithdrawal = useCallback((attempt: WithdrawalAttempt): boolean => { const held = unresolvedWithdrawalsRef.current.get(attempt.taskId); if (!withdrawalLive(attempt) || (held && held.attempt !== attempt && withdrawalLive(held.attempt))) return false; unresolvedWithdrawalsRef.current.set(attempt.taskId, Object.freeze({ attempt, standing: 'sending' })); return true; }, [withdrawalLive]); const unresolvedWithdrawal = useCallback((taskId: string): UnresolvedWithdrawal | undefined => { const unresolved = unresolvedWithdrawalsRef.current.get(taskId); return unresolved && withdrawalLive(unresolved.attempt) ? unresolved : undefined; }, [withdrawalLive]); // Shows a withdrawal's proved Task only to the viewer it began under, and never over a newer version. Each holder // checks again when its update is evaluated; lastUpserted and reload are left to their own writers. const publishWithdrawal = useCallback((read: Task, attempt: WithdrawalAttempt): void => { const { taskId, startedIn } = attempt; if (!withdrawalLive(attempt) || !usableWithdrawalTask(read, taskId)) return; const newer = (existing?: Task) => existing !== undefined && compareVersions(existing, read) === 1; holdWrite(read, Date.now(), startedIn); setTasksList(prev => (!withdrawalLive(attempt) ? prev : prev.map(existing => (existing.id === taskId && !newer(existing) ? read : existing)))); setTasksBundle(prev => (!withdrawalLive(attempt) || !prev?.entry ? prev : { ...prev, entry: prev.entry.map(entry => (entry.resource?.id === taskId && !newer(entry.resource) ? { ...entry, resource: read } : entry)), })); }, [withdrawalLive]); // Staff withdraw (cancel) a task the patient no longer needs to act on - e.g. a form assigned by mistake. The // reason, operation and the version the person saw are fixed here, once, for every send and check of this attempt. const beginWithdrawal = useCallback((task: Task, reason: string): WithdrawalAttempt => { const trimmedReason = reason.trim(); if (!trimmedReason) { throw new Error('Please provide a reason for withdrawing this task.'); } if (!task.id || !task.meta?.versionId) { throw new WithdrawalVersionMissing(); } return Object.freeze({ task, taskId: task.id, reason: trimmedReason, operation: crypto.randomUUID(), expectedVersion: task.meta.versionId, startedIn: startWrite(), actingAsSession: actingAsSession(), }); }, [actingAsSession]); // The one exact-id read of a withdrawal: it says what is stored now, and only its own marker proves this attempt. const readBack = useCallback(async (attempt: WithdrawalAttempt): Promise => { let read: unknown; try { read = await get(`${API_URL}/Task/${attempt.taskId}`, { admit: admittedFor(attempt) }); } catch (error) { return error instanceof WithdrawalRetired ? { kind: 'retired' } : { kind: 'unreadable' }; } if (!usableWithdrawalTask(read, attempt.taskId)) { return { kind: 'unreadable' }; } publishWithdrawal(read, attempt); if (read.status === 'cancelled') { if (withdrawalOperationOf(read) !== attempt.operation) return { kind: 'cancelled-other', task: read }; uncertainWithdrawalsRef.current.delete(attempt.operation); return { kind: 'confirmed', task: read }; } if (isOutstandingTask(read) && read.meta?.versionId === attempt.expectedVersion) { return { kind: 'outstanding-same-version' }; } return { kind: 'changed', status: read.status, task: read }; }, [admittedFor, get, publishWithdrawal]); const readBackWithdrawal = useCallback(async (attempt: WithdrawalAttempt): Promise => recordWithdrawal(attempt, await readBack(attempt)), [readBack, recordWithdrawal]); // Sends this attempt once, conditioned on the version the person saw. Only a matched answer confirms it; anything // else is read back here, by the service, so the outcome is settled even after the form that asked has gone. const sendWithdrawal = useCallback(async (attempt: WithdrawalAttempt): Promise => { const body: Task = { ...attempt.task, status: 'cancelled', statusReason: { coding: [ { system: AKASHIC_TASK_STATUS_REASON_SYSTEM, code: STAFF_WITHDRAWN_CODE, display: 'Withdrawn by staff' } ], text: attempt.reason, extension: [{ url: TASK_WITHDRAWAL_OPERATION_URL, valueString: attempt.operation }], } }; // Another live attempt of this Task is still unknown: it is checked, never answered by a second send. if (!admitWithdrawal(attempt)) return { kind: 'retired' }; let answer: unknown; let sent = false; try { answer = await put(`${API_URL}/Task/${attempt.taskId}`, body, { headers: ifMatch(attempt.expectedVersion), admit: admittedFor(attempt), onSend: () => { sent = true; }, }); } catch (error) { if (error instanceof WithdrawalRetired) { if (sent) uncertainWithdrawalsRef.current.add(attempt.operation); return { kind: 'retired' }; } const failure = classifyWithdrawalFailure(error); if (failure === 'refused' && !uncertainWithdrawalsRef.current.has(attempt.operation)) { return recordWithdrawal(attempt, { kind: 'refused', error }); } if (failure === 'unknown') uncertainWithdrawalsRef.current.add(attempt.operation); return readBackWithdrawal(attempt); } if (confirmsWithdrawal(answer, attempt)) { uncertainWithdrawalsRef.current.delete(attempt.operation); publishWithdrawal(answer, attempt); return recordWithdrawal(attempt, { kind: 'confirmed', task: answer }); } uncertainWithdrawalsRef.current.add(attempt.operation); return readBackWithdrawal(attempt); }, [admitWithdrawal, admittedFor, put, publishWithdrawal, readBackWithdrawal, recordWithdrawal]); const withdrawTask = useCallback(async (task: Task, reason: string): Promise => { const outcome = await sendWithdrawal(beginWithdrawal(task, reason)); if (outcome.kind === 'confirmed') return outcome.task; throw outcome.kind === 'refused' ? outcome.error : new Error('The withdrawal could not be confirmed.'); }, [beginWithdrawal, sendWithdrawal]); const transformToFhirTask = useCallback((item: any, patient?: Patient, practitioner?: Practitioner, encounterState?: Encounter, organization?: Organization): Task => { const { partOf, status, statusReason, businessStatus, intent, priority, description, focus, encounter, executionPeriod, authoredOn, lastModified, performerType, location, reasonCode, note, restriction, input, output } = item; let transformedRestriction: TaskRestriction | undefined = undefined; if (restriction) { transformedRestriction = { ...(restriction.repetitions && { repetitions: restriction.repetitions }), ...(restriction.recipient && restriction.recipient.length !== 0 && { recipient: restriction.recipient }), }; } const transformedFocus: Reference[] = transformToReferenceCollection(focus); let task: Task = { resourceType: 'Task', status: status, intent: intent, ...(input && input.length !== 0 && { input: input }), ...(output && output.length !== 0 && { output: output }), ...(description && { description: description }), ...(priority && { priority: priority }), ...(performerType && performerType.length !== 0 && { performerType: transformToCodeableConcept(performerType) }), ...(transformedFocus && transformedFocus.length !== 0 && { focus: transformedFocus[0] }), ...(patient && { for: createReferenceObject('Patient', patient.id as string, formatFhirHumanName(patient.name)) }), ...(practitioner && { requester: createReferenceObject('Practitioner', practitioner.id as string, formatFhirHumanName(practitioner.name)) }), ...(organization && { owner: createReferenceObject('Organization', organization.id as string, organization?.name) }), ...(encounterState && { encounter: createReferenceObject('Encounter', encounterState?.id as string, getEncounterDisplay(encounterState)) }), ...(encounter && { encounter: encounter }), ...(partOf && partOf.length !== 0 && { partOf: partOf }), ...(statusReason && { statusReason: transformToCodeableConcept(statusReason) }), ...(businessStatus && { businessStatus: transformToCodeableConcept(businessStatus) }), ...(authoredOn && { authoredOn: formatDateTime(authoredOn) }), ...(lastModified && { lastModified: formatDateTime(lastModified) }), ...(location && { location: location }), ...(reasonCode && reasonCode.length !== 0 && { reasonCode: transformToCodeableConcept(reasonCode) }), ...(note && note.length !== 0 && { note: note }), ...(transformedRestriction && Object.keys(transformedRestriction).length !== 0 && { restriction: transformedRestriction }) }; if (executionPeriod) { task = addFhirPeriodIfApplicable(task, item, 'executionPeriod'); } if (restriction && restriction.period) { if (!task.restriction) { task.restriction = {}; } task.restriction = addFhirPeriodIfApplicable(task.restriction, restriction, 'period'); } return task; }, [createReferenceObject, transformToCodeableConcept, formatDateTime, addFhirPeriodIfApplicable, getEncounterDisplay, transformToReferenceCollection]); const transformFromFhirTask = useCallback((task: Task): any => { const { partOf, status, statusReason, businessStatus, intent, priority, code, description, focus, encounter, authoredOn, lastModified, performerType, location, reasonCode, note, input, output } = task; const formData: any = { status: status, intent: intent, priority: priority, description: description ?? '', code: transformFromCodeableConcept(code), performerType: transformFromCodeableConcept(performerType ?? []), focus: transformFromReferenceCollection(focus ? [focus]: focus), authoredOn: formatDateTimeForInput(authoredOn), lastModified: formatDateTimeForInput(lastModified), partOf: partOf ?? [], statusReason: transformFromCodeableConcept(statusReason), businessStatus: transformFromCodeableConcept(businessStatus), encounter: encounter, location: location, reasonCode: transformFromCodeableConcept(reasonCode), note: note ?? [], input: input, output: output }; return addPeriodControlsIfApplicable(formData, task, 'executionPeriod'); }, [transformFromCodeableConcept, addPeriodControlsIfApplicable, transformFromReferenceCollection, formatDateTimeForInput]); const getTaskDisplay = (task: Task): string => { if (!task) { return 'Unknown Task'; } const description = task.description || formatFhirCodeableConcept(task.code) || 'Task'; if (isCustomTask(task)) { return description; } // - the human label, never the code. `statusLabel` returns `undefined` for a code we // have no words for, and this omits the status entirely in that case rather than falling back to // the raw value - the fallback IS the defect. `utils/fhirStatusVocabulary.ts` const statusText = statusLabel(task.status); const status = statusText ? ` · ${statusText}` : ''; let dateSuffix = ''; if (task.authoredOn) { dateSuffix = ` (${formatDateTimeForDisplay(task.authoredOn)})`; } return `${description}${status}${dateSuffix}`; }; return { // WHO THE CACHED LIST IS FOR, so a consumer can ask whether it belongs to the patient on // screen rather than trusting an app-wide context with no patient in its name. cachedForPatientId: cacheOwner.ownerId, tasksFailedFor, tasksBundle, tasksList, setTasksList, shouldReload, setShouldReload, lastUpserted, setLastUpserted, unsaved, setUnsaved, fetchTasks, fetchTasksWithPolling, fetchTask, fetchTaskHistory, createTask, updateTask, deleteTask, archiveTask, createQuestionnaireTask, createCustomTask, completeCustomTask, tickChecklistStep, completeMeasurementTask, completeDocumentRequestTask, settleDocumentRequestTask, markCustomTaskUnable, withdrawTask, beginWithdrawal, sendWithdrawal, readBackWithdrawal, unresolvedWithdrawal, withdrawalLive, transformToFhirTask, transformFromFhirTask, getTaskDisplay, getTaskStatusDisplay }; }; const TaskServiceContext = createContext | undefined>(undefined); /** Builds the Task posted by a questionnaire assignment. */ export const buildQuestionnaireTask = ({ patient, questionnaire, priority = 'routine', dueDate, appointment, }: CreateQuestionnaireTaskInput): Task => { const patientId = patient.id; const questionnaireId = questionnaire.id; const questionnaireTitle = questionnaire.title?.trim() || questionnaire.name?.trim() || 'Questionnaire'; if (!patientId) { throw new Error('Cannot assign a Questionnaire without a Patient id.'); } if (!questionnaireId) { throw new Error('Cannot assign a Questionnaire without a Questionnaire id.'); } if (appointment && !appointment.id) { throw new Error('Cannot attach a Questionnaire task to an Appointment without an id.'); } const appointmentReference = appointment?.id ? { reference: `Appointment/${appointment.id}`, display: getAppointmentReferenceDisplay(appointment), } : undefined; const resolvedDueDate = appointment?.start || dueDate; return { resourceType: 'Task', status: 'requested', intent: 'order', priority, ...(appointmentReference ? { basedOn: [appointmentReference], extension: [ { url: AKASHIC_TASK_APPOINTMENT_EXTENSION, valueReference: appointmentReference, }, { url: AKASHIC_TASK_DUE_MODE_EXTENSION, valueCode: 'this-appointment', }, ], } : {}), code: { coding: [ { system: AKASHIC_TASK_TYPE_SYSTEM, code: AKASHIC_QUESTIONNAIRE_TASK_CODE, display: 'Questionnaire', }, ], text: 'Questionnaire', }, description: `Complete questionnaire: ${questionnaireTitle}`, focus: { reference: `Questionnaire/${questionnaireId}`, display: questionnaireTitle, }, for: { reference: `Patient/${patientId}`, display: formatFhirHumanName(patient.name), }, ...(resolvedDueDate ? { restriction: { period: { end: resolvedDueDate, }, }, } : {}), }; }; export const TaskServiceProvider: React.FC<{ children: ReactNode }> = ({ children }) => { const taskService = useTaskService(); return ( {children} ); }; export const useTaskServiceContext = () => { const context = useContext(TaskServiceContext); if (context === undefined) { throw new Error('useTaskServiceContext must be used within a TaskServiceProvider'); } return context; };