import type { Appointment, CommunicationRequest } from "fhir/r4"; import { selectCounts, type Countable } from "../../../utils/counting/countingSelector"; /** * The patient home's ranked queue - ONE list, ordered by what is actually urgent., design D1 * and D2, spec §6. * * NO SECTION MAY BE CREATED FOR A *KIND* OF THING. No "Messages" group, no "Unpaid" group. The * reason is mechanical rather than aesthetic: **any type that becomes its own section can be pushed to * the bottom, and the bottom does not get read** - which is precisely how messages get buried. So * "add a section for it" makes the problem worse, and a clinical question ranks above a 46-day-old * balance because it blocks care. * * AGEING HAS TEETH. An item nobody has actioned is PROMOTED into `Now` and marked as promoted - not * badged with a count. **Counting a neglected item is not surfacing it**; the queue has to self-correct * rather than rely on someone scrolling. * * ONE SELECTOR (D3 / spec §8.1, the highest-risk line in the design). The strip's total, the visible * row and its peer count are derived HERE, from one pass, so the arithmetic **reconciles on screen**: * the strip says 3 tasks, the visible task plus "+2 more" equals 3, and a reader can check us at * a glance. Built any other way we ship the defect we are fixing, inside the feature meant to prevent * it. */ export type QueueKind = "form" | "message" | "payment" | "appointment"; export type QueueTier = "now" | "today" | "week"; export type QueueItem = Countable & { id: string; kind: QueueKind; /** Titled by the THING ITSELF - "Your lipid panel results are ready" - never by its type or sender. */ title: string; /** * `subtitle` WAS HERE AND WAS DELETED, on the reasoning that **the TIER is how this queue states * urgency** - *Now - Before end of day - This week* - so a date on the row would be a second, competing * statement of the same fact. That was correct while the bands existed. * * THE BANDS ARE GONE. Spencer's 2026-08-24 mockup replaces them with one *Needs attention* heading and * moves the deadline onto the row - which is the change to §6 that note said would have to come first. * With no band, a row that carries no date states no urgency at all, so the field is not competing with * anything any more: it is the only thing left saying when. * * `dueLine` below is that field, and it is a rendered SENTENCE rather than the raw `due` timestamp the * ranking reads. The old note's real warning still holds and is why it is worded that way: a * written-but-unread field is a trap, because it reads as a rendered one. */ /** * What this ONE item is called, and what its button offers - overriding the per-kind defaults. * * PER ITEM, WHERE THE ROUTE BELOW IS PER KIND, AND THE DIFFERENCE IS NOT AN INCONSISTENCY. * `QueueKind` is a GROUPING device - it decides ranking, peer counting and "where do I see all of * these", and every outstanding Task shares one kind on purpose so the strip's arithmetic * reconciles. But a questionnaire and a custom task are not the same THING, and announcing a * custom task as "FORM - Start form" hands the patient a checkbox after promising a form. * * Still derived in ONE place per source (see `taskItems`), never at a call site - a per-item * field is only safe while exactly one function decides it. */ kindLabel?: string; actionLabel?: string; /** * Always shown, never inferred. A patient at three practices gets messages, bills and forms from * all three, and the worked example is deliberate: a lab result FROM Cedar Valley Labs ORDERED BY the * Great River doctor. Saying both is what stops a patient calling the wrong practice - or paying one. */ organizationName?: string; practitionerName?: string; /** When it needs doing. Absent means "no deadline the patient can see". Read by the RANKING. */ due?: string; /** * What the row SAYS about when - "Due by: appointment on Wed 26 Aug, 10:45 AM", "Sent 2d ago". The * rendered sentence, not the timestamp above it, because the two are not the same thing: `due` is * absent on a message that still has a recency worth printing, and a task's line is a phrase the * task-due ruling settled rather than a date this file could format. * * DERIVED IN ONE PLACE PER SOURCE, like `kindLabel` - see `usePatientQueue`. A task's line comes from * `getCustomTaskDueDisplay`, the same producer the patient's task list and the front desk read, so the * home cannot invent a second phrasing of one fact. That is the defect that had a staff screen saying * "Soonest upcoming appointment" about a visit the patient had already arrived for. */ dueLine?: string; /** How far a grouped item has got, such as a packet: "1 of 3 submitted". */ progressLine?: string; /** * Where the row goes when it CANNOT be handled here. R5 (Spencer, 2026-08-14): *"they'd only have to * navigate off this page if they actually need the full page for that item."* So this is the exception, * not the default - and it stays required because every item needs a destination even once most of them * open in place. */ to: string; /** * R5 - what this row OPENS, right here, instead of leaving the page. * * The overview exists to prevent being sent off to another section for quick things, and every list * on it was doing the opposite: an appointment click landed the patient on the appointments LIST, where * they had to find the same appointment and click it again. That is not a dead control - it is a * downgrade that reads as a feature, which is why it survived review. * * A discriminated union rather than a callback: the queue is DERIVED data, and a function on a derived * item cannot be compared, serialized or tested for equality. Adding a kind here is how the next surface * opts in. */ opens?: | { kind: "appointment"; appointment: Appointment } | { kind: "message"; request: CommunicationRequest } /* * A TASK CARRIES ONLY ITS ID, unlike the two above which carry the resource. * * Not an inconsistency. An appointment and a message are fully described by what the queue has * already fetched, so passing the resource saves a round trip and the dialog renders immediately. * A task's screen needs its questionnaire, its existing response and its autosave state - none of * which the queue holds - so handing it a stale Task would mean the dialog either refetched * anyway or rendered from data that is one revision behind. */ | { kind: "task"; taskId: string } | { kind: "referral"; referralId: string }; }; /** * Where the "+ N more" line goes - the LIST for that kind, never the row's own destination. * * THE DEFECT THIS EXISTS TO FIX, measured in a browser: the peer line ran `navigate(item.to)`, which is * the CARD'S OWN item. So clicking *"+ 1 more task - Complete questionnaire: Patient health * questionnaire"* landed on **"Lose 10 pounds"** - the task rendered directly above the line that was * clicked, and the questionnaire it named was never reached. The LABEL describes the other items and the * DESTINATION described this one; they were derived from different things. * * ONE SHARED HANDLER SERVES ALL FOUR KINDS, so it was wrong for every one of them at once - tasks and * payments were observed, messages and appointments follow from the same code path. * * Keyed on KIND rather than carried per item, deliberately: "where do I see all of these" is a property * of the kind, and a per-item field is a per-item opportunity to get it wrong. */ export const LIST_ROUTE_FOR_KIND: Record = { // `/patient/tasks`, NOT `/patient/questionnaire-responses`. Both routes exist; only this one lists // EVERY outstanding task. The questionnaire list would drop a manually-assigned task like "Lose 10 // pounds" - which is exactly the item that exposed this bug, so sending the patient there would move // the defect rather than remove it. form: "/dashboard/patient/tasks", message: "/dashboard/messages", payment: "/dashboard/my-invoices", appointment: "/dashboard/my-appointments", }; export type RankedItem = QueueItem & { tier: QueueTier; /** Raised into `Now` by age rather than by its own deadline. Rendered as a mark, never as a count. */ promoted: boolean; /** Other items of the SAME KIND in this tier, hidden behind this one. */ peerCount: number; /** The peer line: "+2 more". */ peerLabel?: string; }; export type QueueTierView = { tier: QueueTier; label: string; items: RankedItem[]; /** Everything in this tier, including peers - the number a tier footer would show. */ total: number; }; const dayMs = 24 * 60 * 60 * 1000; const TIER_LABEL: Record = { now: "Now", today: "Before end of day", week: "This week", }; /** * How long an un-actioned item waits before it is promoted. A DAY, not an hour: promotion is meant * to catch neglect, and something that arrived this morning is not neglected - promoting it would make * `Now` mean "recent", which is the opposite of the intent. */ export const PROMOTE_AFTER_DAYS = 1; const endOfDay = (now: Date) => { const end = new Date(now); end.setHours(23, 59, 59, 999); return end.getTime(); }; const waitedDays = (item: QueueItem, now: Date): number => { if (!item.since) return 0; const started = new Date(item.since).getTime(); if (Number.isNaN(started)) return 0; return Math.floor((now.getTime() - started) / dayMs); }; /** Urgency, and ONLY urgency - the item's kind never decides its tier. */ export const tierFor = (item: QueueItem, now: Date): QueueTier => { if (!item.due) return "week"; const due = new Date(item.due).getTime(); if (Number.isNaN(due)) return "week"; if (due <= now.getTime()) return "now"; return due <= endOfDay(now) ? "today" : "week"; }; /** Plain "+2 more" for every kind, per the deck - the card's icon already names the kind. */ const peerLabelFor = (peers: QueueItem[], now: Date): string | undefined => { if (peers.length === 0) return undefined; return `+${selectCounts(peers, { now }).total} more`; }; /** * The ranked list. * * One card per KIND per tier is visible; the rest of that kind sit behind its peer count. That is what * keeps the surface short without a per-type section - the grouping is a counting device, not a * heading, and the order is still urgency. */ export const buildPatientQueue = (items: QueueItem[], now: Date = new Date()): QueueTierView[] => { const ranked = items.map((item) => { const promoted = tierFor(item, now) !== "now" && waitedDays(item, now) >= PROMOTE_AFTER_DAYS; return { item, tier: promoted ? ("now" as const) : tierFor(item, now), promoted }; }); return (["now", "today", "week"] as const).flatMap((tier) => { const inTier = ranked.filter((entry) => entry.tier === tier); if (inTier.length === 0) return []; const kinds = [...new Set(inTier.map((entry) => entry.item.kind))]; const visible = kinds.map((kind) => { const ofKind = inTier.filter((entry) => entry.item.kind === kind); const [lead, ...peers] = ofKind; const peerItems = peers.map((entry) => entry.item); return { ...lead.item, tier, promoted: lead.promoted, // Arithmetic on the SAME slice the label is built from, so "visible + more === total" holds // by construction rather than by two expressions agreeing. peerCount: peerItems.length, peerLabel: peerLabelFor(peerItems, now), }; }); return [{ tier, label: TIER_LABEL[tier], items: visible, total: inTier.length }]; }); }; /** * THE INVARIANT, exported so a surface can assert it rather than restate it: every tier's visible * cards plus their peer counts equal what that tier holds. If this is ever false the screen is showing * a number that disagrees with its own list. */ export const queueReconciles = (view: QueueTierView): boolean => view.items.reduce((sum, item) => sum + 1 + item.peerCount, 0) === view.total;