import type { Appointment, Task } from 'fhir/r4'; import { awaitsUnresolvedNextAppointment, resolveTaskAppointment } from '../services/TaskService.tsx'; /** * THE PATIENT'S TASK LIST GROUPS THE ITEMS THAT SHARE AN APPOINTMENT OR A DUE DATE. * * > Settled 2026-09-06, `product-brief.md`: *"the patient's list groups the items that share an * > appointment or a due date."* * * WHAT IT FIXES, measured as Patient One: three appointments had each auto-assigned the same task, * so the one surface that exists to answer "what do I have to do" showed * * Bring the card you want us to bill, and a photo ID. Due by: appointment on Sun, Sep 6... * Bring the card you want us to bill, and a photo ID. Due by: appointment on Fri, Sep 11... * Bring the card you want us to bill, and a photo ID. Due by: appointment on Wed, Sep 9... * * Every row was correct and the list was wrong: a patient counts three jobs where there is one, and * the repetition is worst for exactly the tasks a practice assigns to every visit. * * THE ORDER OF THE KEYS IS THE POINT. An appointment is a stronger fact than a date - two tasks for * the same visit belong together even if their due dates differ, and two tasks that merely fall on * one day do not become one errand. So appointment first, date second, and never both. * * NOTHING IS EVER DROPPED. Grouping is a display arrangement, not a filter: a task with no * appointment and no due date is the one most likely to be forgotten, so it gets its own group * rather than falling out of a list whose whole promise is completeness. */ export interface PatientTaskGroup { /** Stable across renders; `appointment:{id}`, `date:{yyyy-mm-dd}`, `next-appointment` or `undated`. */ key: string; /** * `appointment` groups carry no heading text of their own - the caller renders the visit, which it * already knows how to phrase. `date` carries the ISO date; `next-appointment` and `undated` carry * nothing. `next-appointment` holds open tasks owed by a next visit not yet seen, which have a deadline. */ kind: 'appointment' | 'date' | 'next-appointment' | 'undated'; appointment?: Appointment; dueDate?: string; tasks: Task[]; } const dayOf = (value: string | undefined): string | undefined => { if (!value) return undefined; const day = value.slice(0, 10); return /^\d{4}-\d{2}-\d{2}$/.test(day) ? day : undefined; }; export function groupPatientTasks( tasks: readonly Task[], appointments: readonly Appointment[], // Injected rather than read inside, so the ordering is testable without moving the clock. nowMs: number = Date.now(), ): PatientTaskGroup[] { const groups: PatientTaskGroup[] = []; const byKey = new Map(); // First appearance fixes the order WITHIN this pass; the groups are sorted below before returning. const place = (key: string, make: () => PatientTaskGroup, task: Task) => { let group = byKey.get(key); if (!group) { group = make(); byKey.set(key, group); groups.push(group); } group.tasks.push(task); }; for (const task of tasks) { // The shared resolver, never a second one: its own note is that one resolver and one map are // what stop two surfaces disagreeing about which visit a task belongs to. // A next-appointment task with no visit this reader can see and no date is still owed by that // visit, so it is grouped as such below rather than as having no deadline. const appointment = resolveTaskAppointment(task, appointments as Appointment[]); if (appointment?.id) { place(`appointment:${appointment.id}`, () => ({ key: `appointment:${appointment.id}`, kind: 'appointment', appointment, tasks: [], }), task); continue; } const day = dayOf(task.restriction?.period?.end); if (day) { place(`date:${day}`, () => ({ key: `date:${day}`, kind: 'date', dueDate: day, tasks: [] }), task); continue; } if (awaitsUnresolvedNextAppointment(task, appointment)) { place('next-appointment', () => ({ key: 'next-appointment', kind: 'next-appointment', tasks: [] }), task); continue; } place('undated', () => ({ key: 'undated', kind: 'undated', tasks: [] }), task); } // SOONEST FIRST, AND THE ORDER IS THIS FUNCTION'S JOB AFTER ALL. // // This used to return the groups in the order their first task happened to appear, on the reasoning // that the caller had already sorted and re-sorting here would overrule it. That reasoning was // wrong in a way that only shows on a real patient: THE CALLER SORTS TASKS, NOT GROUPS. So the // group order was whichever task of each group came first, and a real list came out // // Sep 6 - Sep 9 9:30 - Sep 9 11:15 - Sep 11 - Sep 7 // // - the last group four days earlier than the one above it. On the one surface that exists to answer // "what do I have to do next", a patient reading top to bottom prepares for the wrong visit. // // Undated last, and that is deliberate rather than a fallback: a task nobody has put a deadline on // is not urgent, but it must never be dropped, so it goes to the end rather than out of the list. // A VISIT THAT HAS ALREADY HAPPENED SORTS FIRST, ABOVE EVERYTHING STILL TO COME. // // An item still outstanding from a visit that occurred is the only genuinely LATE thing in the // list, and "what needs doing next" puts late before soon. It is the undated-last rule pointing // the other way: both order by urgency rather than by date alone. // // This is not the cancelled-appointment case. A cancelled visit VOIDS its tasks (2026-07-21). // These were asked for by a visit that really happened, so the practice still wants them - what // changed is only that they can no longer be preparation. const past = (group: PatientTaskGroup): boolean => group.kind === 'appointment' && Boolean(group.appointment?.start) && new Date(group.appointment!.start as string).getTime() < nowMs; const sortKey = (group: PatientTaskGroup): string => { if (past(group)) return `0${group.appointment?.start ?? ''}`; if (group.kind === 'appointment') return `1${group.appointment?.start ?? '9999'}`; if (group.kind === 'date') return `1${group.dueDate ?? '9999'}`; if (group.kind === 'next-appointment') return '2'; return '3'; }; return groups.sort((a, b) => sortKey(a).localeCompare(sortKey(b))); } /** * The day a DATE group is headed with, drawn as the deck draws it: "Friday, Aug 28". * * Longer weekday and no year, where a row own due line carries "Fri, Aug 28, 2026". That is not two * vocabularies by accident. A row states a deadline that may be months out, so it carries the year; * a heading is a label for the rows beneath it and reads better without one. * * PARSED AS A LOCAL DAY, never through the Date constructor own UTC reading of a bare date. A * yyyy-mm-dd string taken as UTC renders as the previous day for every reader west of Greenwich, * which is every reader this product has. */ /** * A VISIT HEADING CARRIES THE YEAR ONLY WHEN IT IS NOT THIS YEAR. * * The approved deck writes `Wed, Aug 26 at 10:45 AM`; the build wrote `Wed, Sep 9, 2026 at 9:30 * AM`, because it reused the DUE-DATE formatter, whose job is different - a due date is quoted * inside a sentence on a row and has to survive being read on its own. * * A heading does not. It sits above its own rows among other headings, nearly all of them in the * same year, so the year is four characters that separate nothing and push the time towards the * count. `formatGroupDay` below already drops it for a date group; a visit group keeping it was * the odd one out on its own screen. * * KEPT WHEN IT IS A DIFFERENT YEAR, which is the one time it earns its space: an item still * outstanding from a visit last November is exactly the row whose year a patient needs. */ /** * A VISIT THAT IS NOT GOING TO HAPPEN. * * Measured on a real patient 2026-09-08: two appointments at the same minute, same practitioner, * same building - one booked with four tasks outstanding, one CANCELLED with its four tasks duly * voided. The list drew both, and the cancelled one, having nothing left, said * *"You're ready for this visit."* * * THAT IS THE ONE SENTENCE ON THIS SCREEN THAT MAKES A CLAIM ABOUT THE FUTURE, and it was being * made about a visit the practice had called off - directly above the identical heading for the * visit that IS happening, with four things still to do under it. * * The server side of the rule works: tasks bound to a cancelled appointment are voided, and they * were. Voiding is what stops the practice asking for them; the group heading was asking anyway. * * NOSHOW AND ENTERED-IN-ERROR GO THE SAME WAY, for the same reason rather than by analogy: nobody * is preparing for either, so there is nothing to be ready for. Every other status - booked, * pending, proposed, arrived, checked-in, waitlist - is a visit somebody is still expecting. */ export const isAbandonedVisit = (appointment: { status?: string } | undefined): boolean => appointment?.status === "cancelled" || appointment?.status === "noshow" || appointment?.status === "entered-in-error"; export const formatGroupVisit = (iso: string, nowMs: number = Date.now()): string => { const when = new Date(iso); if (Number.isNaN(when.getTime())) return iso; const day = new Intl.DateTimeFormat("en-US", { weekday: "short", month: "short", day: "numeric", ...(when.getFullYear() === new Date(nowMs).getFullYear() ? {} : { year: "numeric" }), }).format(when); const time = new Intl.DateTimeFormat("en-US", { hour: "numeric", minute: "2-digit", }).format(when); return `${day} at ${time}`; }; export const formatGroupDay = (isoDay: string): string => { const [year, month, day] = isoDay.split("-").map(Number); if (!year || !month || !day) return isoDay; return new Intl.DateTimeFormat("en-US", { weekday: "long", month: "short", day: "numeric", }).format(new Date(year, month - 1, day)); };