// / D3 - the three sentences the three levels say, all built from ONE `CountingView`. // // | level | the question it answers | example | // |---|---|---| // | strip | *is there anything I have not seen?* | `6 unread messages - oldest 3d` | // | row | *is this the only one of these?* | `+ 2 more tasks - 1 form, 1 to review` | // | tier | *what else is in here?* | `+ 4 more this week` | // // These take a `CountingView`, never raw arrays - so a surface cannot quietly count one thing and // describe another. That is the defect (B14 - B16 - B28 - B43), and the type signature is what forbids it. import { formatMoney } from '../money'; import type { CountingView } from './countingSelector'; /** * D3: **counts carry an AGE wherever neglect is possible.** "6 unread" says how many; "oldest 3d" says * whether you are failing. A bare count becomes wallpaper once it never reaches zero. * * The age is omitted below 1 day rather than shown as "0d": today's items are not neglect, and a * permanent "oldest 0d" is exactly the wallpaper this rule exists to avoid. */ export function stripText(view: CountingView, noun: string, pluralNoun?: string): string { if (view.total === 0) return ''; const word = view.total === 1 ? noun : (pluralNoun ?? `${noun}s`); const age = view.oldestDays !== undefined && view.oldestDays >= 1 ? ` · oldest ${view.oldestDays}d` : ''; return `${view.total} ${word}${age}`; } export interface PeerTextOptions { /** "task" / "invoice" - singularised by `total`, not by `more`. */ noun: string; pluralNoun?: string; /** * D3 - staff peer counts group BY PERSON, not by type: *"1 more for Alice - records authorization * expired"*, because the front desk should handle everything for one patient in one interaction. */ groupByPerson?: boolean; /** D3 - invoice peer counts carry the aggregate: the total is the question a patient actually has. */ withTotal?: boolean; } /** * The row-level *"is this the only one of these?"*. * * **The single hidden item is NAMED** - *"1 more for Alice - records authorization expired"* rather * than *"1 more"*. D3 calls that the line between signposted and buried, and it is the difference * between a count that helps and a count that only admits something is missing. */ export function peerText(view: CountingView, options: PeerTextOptions): string { if (view.more === 0) return ''; const { noun, pluralNoun, groupByPerson = false, withTotal = false } = options; const word = view.more === 1 ? noun : (pluralNoun ?? `${noun}s`); let lead = `+ ${view.more} more ${word}`; if (groupByPerson) { const names = [...new Set(view.hidden.map((item) => item.personName).filter(Boolean))]; // Only when the hidden set is ONE person: "1 more for Alice and Bob" reads as one item shared // between them. With several people the plain count is the honest sentence. if (names.length === 1) lead = `${view.more} more for ${names[0]}`; } const detail: string[] = []; // Name the item when there is exactly one - the whole point of the rule. With several, listing them // reproduces the list the count is standing in for. if (view.more === 1 && view.hidden[0]?.label) { detail.push(view.hidden[0].label); } if (withTotal && view.totalCents !== undefined) { // `formatMoney` already takes integer CENTS ("28500 -> $285.00"). Dividing here first produced // "$1.65" for $165.00 - a double conversion that reads as a plausible price, which is exactly the // kind of wrong number a reader cannot spot. The field name carries the unit; trust it. detail.push(`${formatMoney(view.totalCents)} owed in total`); } return detail.length > 0 ? `${lead} · ${detail.join(', ')}` : lead; } /** * The end-of-tier *"what else is in here?"* - `+ 4 more this week`. * `scopeLabel` is the tier's own words, so the sentence stays true to what the tier actually holds. */ export function tierText(view: CountingView, scopeLabel: string): string { if (view.more === 0) return ''; return `+ ${view.more} more ${scopeLabel}`; }