import type { Appointment, Task } from "fhir/r4"; import { resolveTaskAppointment } from "../services/TaskService.tsx"; import { AKASHIC_TASK_TYPE_SYSTEM } from "./referralQueue"; import { taskName } from "./taskName"; /** * What a patient still has to do BEFORE a particular visit.. * * Spencer, 2026-08-14: *"Also needs to show in the dialog when they click an appointment **as well as * anywhere else that appointment is listed**."* this is a **property of the appointment row**, not a * feature of one screen. Any new surface that lists an appointment inherits it, which is why the rule * lives here rather than inside the one component that happened to implement it. * * THE STATE THIS FIXES: a patient meets an appointment in THREE places and could see its tasks in * ONE. The home's appointment card showed them; the dialog you get by clicking that card showed none; * the appointments list showed none. And the newest surface was the emptiest - the dialog was added to * restore a lost click, and it restored the click without the content. * * THE JOIN IS ONLY AS GOOD AS THE STORED REFERENCE, and that limit belongs to a different card. A task * assigned *"due by your next appointment"* is resolved ONCE at assignment and stored as a frozen DATE * with no appointment reference, so nothing can join it to a visit and it silently points at whichever * appointment was next that day (, claude-3, both repos). Tasks stored WITH a reference * (`this-appointment` / `specific-appointment`) join correctly today; the rest start working here the * moment that card lands, with no change to this file. Do not paper over it with a date comparison - * that reproduces the freeze on the read side. */ /** * `completed` IS IN THIS SET ON PURPOSE. The block shows what was ASKED of the patient for this visit * and how much of it is done - so a finished task must still appear, ticked. Dropping it would make * "you completed everything" look identical to "nothing was ever asked". * `cancelled`, `rejected` and `entered-in-error` are absent: those were withdrawn, and showing them * would ask a patient about work nobody wants any more. */ export const DISPLAY_APPOINTMENT_TASK_STATUSES = new Set([ "draft", "requested", "received", "accepted", "ready", "in-progress", "on-hold", "failed", "completed", ]); /** * The task in the patient's words, never the resource's. * * - THE QUESTIONNAIRE'S NAME IS ALREADY ON THE TASK, one line down from where the patient's * task list was looking. Both paths that assign a questionnaire write the title twice: into * `description` ("Complete questionnaire:...") **and** onto `focus.display`, which is the Reference to the * Questionnaire. A task missing the first still carries the second. * * SO THE CARD'S "TWO FIXES, NOT EQUIVALENT" HAS A THIRD ANSWER, and it is the cheap one that is also * the right one. The card weighed snapshotting the title at assignment against resolving it at render: * - the snapshot is arguably correct - *"a task is a historical record of what somebody was asked to * do"*, so the name at the time is the honest one; * - resolving costs a fetch per row and needs its own fallback when the questionnaire is gone. * **The snapshot already exists.** `focus.display` was written at assignment, is exactly the name the * patient was asked under, and costs nothing to read. Neither fix was needed; the list just had to look. * * THE ORDER IS THE DESIGN, and each step is a worse-but-honest answer than the one above it: * 0. a custom task's title, which the task's own sheet already heads with * 1. `description` what the assigner chose to call it * 2. `focus.display` the questionnaire's own title, snapshotted at assignment * 3. `code.text` the KIND of thing it is - "Questionnaire". Thin, but true and actionable. * 4. a coding display the same, from a coded system * 5. "Task" the floor, and it must stay: a task with nothing resolvable still has to * render a row a patient can press. A fix that assumed a title always resolves * would turn a bad label into a BLANK one, which is the card's own control. * * Named for what it does rather than where it is used - it was `appointmentTaskTitle`, and that name * is why the patient's task list grew its own one-line version (`task.description || "Task name not * available"`) instead of calling this. **A name that describes one caller invites the second caller to * write their own.** */ export const customTaskTitle = (task: Task): string | undefined => task.code?.coding?.some((coding) => coding.system === AKASHIC_TASK_TYPE_SYSTEM && coding.code === "custom") ? taskName(task) : undefined; export const taskDisplayTitle = (task: Task): string => customTaskTitle(task) || task.description?.trim() || task.focus?.display?.trim() || task.code?.text?.trim() || task.code?.coding?.find((coding) => coding.display?.trim())?.display?.trim() || "Task"; /** * Which tasks belong to which appointment. * * Built ONCE over the whole list rather than filtered per row: `resolveTaskAppointment` is the shared * resolver the front desk's day view also uses, and running it per row per render turns one pass into * N×M. It is also the reason the answer cannot drift between surfaces - one resolver, one map. */ export const tasksByAppointmentId = ( tasks: readonly Task[], appointments: readonly Appointment[], ): Map => { const byAppointment = new Map(); for (const task of tasks) { if (!task.id || !DISPLAY_APPOINTMENT_TASK_STATUSES.has(task.status)) continue; const appointmentId = resolveTaskAppointment(task, appointments as Appointment[])?.id; if (!appointmentId) continue; byAppointment.set(appointmentId, [...(byAppointment.get(appointmentId) ?? []), task]); } return byAppointment; }; export type AppointmentTaskSummary = { total: number; done: number; /** `"2 of 3 tasks done before this visit"` - the compact form, for a row that must read without opening. */ sentence: string; }; /** * The one-line form, for a list row. * * RETURNS `undefined` WHEN NOTHING WAS ASSIGNED, and that distinction is the card's own rule: *"No * indicator at all must mean NO TASKS WERE ASSIGNED, distinct from all-complete."* Drawing "nothing" for * both makes an unmet requirement indistinguishable from a met one - and this is the surface a patient * scans to find out whether they can just turn up. * * It never says "0 of 0". A row that reports an empty requirement is noise on every appointment that * never had one, which is most of them. */ export const appointmentTaskSummary = ( tasks: readonly Task[] | undefined, ): AppointmentTaskSummary | undefined => { if (!tasks || tasks.length === 0) return undefined; const done = tasks.filter((task) => task.status === "completed").length; const total = tasks.length; const noun = total === 1 ? "task" : "tasks"; return { total, done, // "All 2 tasks done" rather than "2 of 2": a patient who is ready should read that in one glance, // not do the arithmetic themselves. sentence: done === total ? `All ${total === 1 ? "" : `${total} `}${noun} done before this visit` : `${done} of ${total} ${noun} done before this visit`, }; }; /** * Where a task opens for a patient. * * The route the home's appointment card has always used - kept identical rather than re-derived, * because two surfaces sending the same task to two pages is the defect this whole card is about. */ export const patientTaskRoute = (task: Task): string => `/dashboard/home/task/${task.id}`;