import { selectCounts, type Countable, type CountingView } from "../../../utils/counting/countingSelector"; /** * The patient home's account summary - four counts that ARE the navigation., design D3/D3b. * * It is not a header decoration. Every item links into its own full list, so one control answers * both *"is there anything I haven't seen?"* and *"take me there"* - which is why removing the left * nav without building this made the home feel like it had lost something. It had. * * THE HIGHEST-RISK THING HERE IS THE ONE SELECTOR. A header count that disagrees with the list * beneath it is already the most common defect in this app - four separate findings from the design * walk are that same bug. So this derives every number through `selectCounts`, the module built for * exactly that, and never counts anything itself. * The property that makes it checkable: **the arithmetic reconciles on screen**. `reconciles()` is * exported from the selector so a test can assert it rather than trusting this file. * * Do NOT unify this with the front desk's version. Same question, two readers, different next move: * a patient's day is usually empty and the real question is *when am I next due*; the front desk's is * *what is happening now*. */ export type SummaryItemId = "appointments" | "messages" | "tasks" | "invoices"; export type SummaryItem = { id: SummaryItemId; /** * The number. Rendered in the NAVIGATE role, because the count is the link. * * `undefined` MEANS NOT YET KNOWN, AND IT IS NOT THE NUMBER ZERO. Measured in a browser * 2026-08-14: returning to the home in-app showed *"0 invoices unpaid"* for ~2s before admitting there * were two, worth $245 - with a cold-load control that never showed it, so this is an in-app * navigation defect rather than a first-paint one. * * IT IS A CONSEQUENCE OF A GOOD RULING. Spencer settled that all four tiles render including zeros, * because the band IS the navigation and a door that hides itself cannot be learned. Correct - and it * makes `0` a legitimate rendered state, so *"not loaded"* and *"nothing owed"* became visually * identical. The answer is a THIRD state, never re-hiding the tile. * * A FALSE ZERO IS NOT A LOADING STATE, IT IS AN ASSERTION *. Say nothing until the answer is known. */ count: number | undefined; /** The noun, in the patient's words. Ink - never coloured. */ label: string; /** * The one fact that makes the count actionable. * * SPLIT IN TWO BECAUSE ONLY HALF OF IT IS COLOURED. Spencer settled on 2026-08-13 that the * COUNT is blue and the VALUE is amber, and that the words around them stay muted - *"colour the * label as well and the item becomes a stripe with nothing standing out."* * `lead` is the muted preamble ("Total:", "Next:"), `value` is the amber fact itself. * Not one string split at render: "Next: Wed Aug 26, 11:00 AM" has no separator that also works * for "oldest 64d", and guessing one is how a comma ends up amber. */ fact?: { lead?: string; value: string }; to: string; }; /** * EVERY SOURCE IS `readonly Countable[] | undefined`, and `undefined` is load-bearing: it means the * answer has not arrived, which is a different fact from an empty list. An empty array is a real answer * ("nothing waiting"); `undefined` is the absence of one. */ export type AccountSummaryInput = { /** Scheduled, still-upcoming appointments. */ appointments: readonly Countable[] | undefined; /** ISO start of the next one - "Next" alone hides that there are others, so it is the FACT, not the count. */ nextAppointmentStart?: string; /** Unread messages. `since` carries when each arrived, which is what "oldest 3d" reads. */ messages: readonly Countable[] | undefined; /** Forms and questionnaires waiting on the patient. */ tasks: readonly Countable[] | undefined; /** Unpaid invoices, each carrying `amountCents`. */ invoices: readonly Countable[] | undefined; }; const plural = (count: number, one: string, many: string) => (count === 1 ? one : many); /** `$165.00`. Cents in, so no float ever reaches the arithmetic. */ export const formatMoney = (cents: number): string => `$${(cents / 100).toFixed(2).replace(/\B(?=(\d{3})+(?!\d))/g, ",")}`; /** * US date order: `Thu Aug 21, 2:30 pm`. Month before day - the market is the United States, and * day-first is silently ambiguous for the first twelve days of every month. */ export const formatNextAppointment = (iso: string): string => { const when = new Date(iso); if (Number.isNaN(when.getTime())) return ""; // `Thu Aug 21` - the locale puts a comma after the weekday and the spec does not; the one comma in // this string separates the date from the time. const day = when .toLocaleDateString("en-US", { weekday: "short", month: "short", day: "numeric" }) .replace(/^(\w+),/, "$1"); const time = when .toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" }) .replace(/\s?([AP])M$/i, (_, meridiem: string) => ` ${meridiem.toLowerCase()}m`); return `${day}, ${time}`; }; /** `oldest 3d`. Absent below a day: "oldest 0d" reads as a defect, not as freshness. */ const oldestFact = (view: CountingView): { lead: string; value: string } | undefined => view.oldestDays && view.oldestDays > 0 ? { lead: "oldest", value: `${view.oldestDays}d` } : undefined; /** * The four items, in the order a patient scans them. * * **ALL FOUR ALWAYS RENDER, ZEROES INCLUDED** - Spencer, 2026-08-14: *"they should remain even if * the count is zero."* This REVERSES the rule that used to live here (*"counts render only when * non-zero... a row of noughts is not complete information"*), and the reversal is not a preference: * * **The band IS the navigation, and navigation that disappears when empty is a door that hides * itself.** Under the old rule the strip changed shape per patient and per day, so nobody could learn * where anything was - and a patient with no unread messages had no way to reach messages from the * home at all. A zero is also an ANSWER: *"nothing waiting"* is what most patients come here to find * out, and it is worth a row. * * The old reasoning was about the 390px phone, and that concern is real but belongs to LAYOUT, not * to the data: `ak-home__summary` puts the four on a 2×2 grid below ~1000px of container width. Do not * re-add a filter here to solve a spacing problem. */ export const buildPatientAccountSummary = ( input: AccountSummaryInput, now: Date = new Date(), ): SummaryItem[] => { // `selectCounts` is still the ONE selector - D3 is untouched. What changes is that a source which // has not arrived is not handed to it at all, because there is nothing to count yet. const appointments = input.appointments && selectCounts(input.appointments, { now }); const messages = input.messages && selectCounts(input.messages, { now }); const tasks = input.tasks && selectCounts(input.tasks, { now }); const invoices = input.invoices && selectCounts(input.invoices, { now }); // THE LABEL PLURALISES ON THE COUNT, so an unknown count has no number to pluralise on. The PLURAL // form is the honest default: "invoices unpaid" with no number reads as a heading, while "invoice // unpaid" would quietly assert exactly one. const labelFor = (view: CountingView | undefined, one: string, many: string) => view ? plural(view.total, one, many) : many; const items: SummaryItem[] = [ { id: "appointments", count: appointments?.total, // Spencer, 2026-09-09: just "appointments". "Scheduled" was doing no work for a patient - // every appointment in their own portal is one that is scheduled, so the word only made the // longest label in the band longer and pushed the tile to truncate at 390. label: labelFor(appointments, "appointment", "appointments"), // No fact while the count is unknown: "Next: Fri Aug 21" beside a blank number would be a // definite statement wrapped around an absent one. fact: appointments && input.nextAppointmentStart ? { lead: "Next:", value: formatNextAppointment(input.nextAppointmentStart) } : undefined, to: "/dashboard/my-appointments", }, { id: "messages", count: messages?.total, // Spencer, 2026-09-09: *"change 1 unread to 1 new message"*. The old label answered the // right question with the wrong noun - "1 unread" makes the reader supply "unread what?", and // this tile is also the only route to messages from the home when the count is zero, where // "0 unread" read as a fragment rather than a door. // IT PLURALISES NOW, which the hardcoded string could not: it was the one label in the band // that did not go through `labelFor`, so it would have said "1 new messages". // THE COUNT IS STILL UNREAD, and that is the settled split - a patient's badge counts what // arrived and has not been read, while staff counts count what is awaiting a reply. Only the // wording moved here; nothing about what is being counted changed. label: labelFor(messages, "new message", "new messages"), fact: messages && oldestFact(messages), to: "/dashboard/messages", }, { id: "tasks", // No separate "signature" count. To a patient a signature request is just something to do - // that word is ours, not theirs. count: tasks?.total, // "FORMS" "TASKS" (Spencer, 2026-08-14, overriding the 08-13 mock). The count was ALREADY // every outstanding Task - questionnaires and manually assigned work alike - so the old word was // simply describing it wrongly: a patient with "Lose 10 pounds" on their list was told they had // a form to fill in. This is a LABEL fix, not a scope change; the arithmetic never moved. label: labelFor(tasks, "task to complete", "tasks to complete"), fact: tasks && oldestFact(tasks), // THE LIST OF EVERY TASK, not the questionnaire list. This used to send the patient // to `/dashboard/patient/questionnaire-responses`, which mounts a QUESTIONNAIRE list - while the // count above is every outstanding Task. A patient told "3 tasks to complete" clicked through to a // list that could not contain "Lose 10 pounds". // It follows from Spencer's own 08-14 ruling rather than being a new decision: he settled that // this reads "tasks to complete" and "includes both questionnaires and manually assigned tasks". // A door narrower than the number it sits under contradicts that. // A DESTINATION change, never a scope change - the arithmetic above does not move, and a test // pins that it still counts both kinds. to: "/dashboard/patient/tasks", }, { id: "invoices", count: invoices?.total, label: labelFor(invoices, "unpaid invoice", "unpaid invoices"), // Age is the practice's question; the AMOUNT is the patient's. // And a total is never shown against an unknown count - "$245.00" beside a blank is the same // false certainty in the other direction. fact: invoices && invoices.totalCents !== undefined ? { lead: "Total:", value: formatMoney(invoices.totalCents) } : undefined, to: "/dashboard/my-invoices", }, ]; // No filter. See the note above the function: every removal here is a door the patient can no // longer see, and the shape of the band is what makes it learnable. return items; }; /** * The four views, exposed so a caller can assert the arithmetic rather than take this module's word * for it - and so the ranked queue reads the SAME derivation rather than a second one. */ export const accountSummaryViews = ( input: AccountSummaryInput, now: Date = new Date(), ): Record => ({ // A not-yet-loaded source counts as EMPTY here, and that is correct for this function specifically: // it exposes the arithmetic, and the arithmetic over nothing is zero. The distinction between "none" // and "not known yet" belongs to the RENDERED item, which is where a false zero misleads a patient. appointments: selectCounts(input.appointments ?? [], { now }), messages: selectCounts(input.messages ?? [], { now }), tasks: selectCounts(input.tasks ?? [], { now }), invoices: selectCounts(input.invoices ?? [], { now }), });