// / decision D3 - ONE selector feeds all three counting levels. // // Why this module exists // A header count that disagrees with the list under it is **the most common defect in this app.** Four // shipped instances, all the same bug wearing different clothes: // // B14 an empty state scoped to UPCOMING said "you don't have any upcoming or past appointments", // ~300px above a past appointment. // B16 `Inbox (0)` above two listed conversations - the tab counted UNREAD while its siblings counted // totals, so the row meant two things at once. // B28 "1 Appointment" for a day whose only appointment was CANCELLED. // B43 the questionnaires page claimed zero above two listed questionnaires. // // Every one was written by someone reasonable: each count was correct **for the thing it counted**. That // is precisely why they are not catchable by review - nothing looks wrong in the source. They are only // visible when the number and the list are on screen together, which is why a WALK found all four. // // THE BUILD CONSTRAINT (D3, and the highest-risk line in the design doc): the strip, the row // peer-count and the tier footer MUST read one selector. Built any other way we ship the defect we are // fixing, inside the feature meant to prevent it. // // THE PROPERTY THAT MAKES IT CHECKABLE: the arithmetic **reconciles on screen**. The strip says 3 // tasks; the visible task plus "+2 more tasks" equals 3. A reader can verify us at a glance - and so can // a test, which is the whole reason to shape it this way rather than as three tidy helpers. // // DO NOT let a component derive its own count once this exists. That is the defect. /** Anything countable at any of the three levels. */ export interface Countable { id: string; /** * When this item entered the state being counted (ISO). Drives "oldest 3d". * NOT the item's creation date - the age that matters is how long it has been WAITING. */ since?: string; /** Who the item concerns. Staff peer counts group by PERSON, not by type (D3). */ personId?: string; personName?: string; /** Money, for invoice aggregates (D3: the total is the question a patient actually has). */ amountCents?: number; /** What this item IS, in the product's voice - used when naming the single other item. */ label?: string; } export interface CountingViewOptions { /** How many the surface will actually render. Omit for "all of them". */ visibleLimit?: number; /** Injected so tests are deterministic; never read the clock inside a selector. */ now?: Date; } export interface CountingView { /** Every item in scope. THE number, for every level. */ total: number; visible: Countable[]; hidden: Countable[]; /** * How many are not shown. Always `total - visible.length` - never recomputed from a filter, which * is how the two halves drift apart. */ more: number; /** Whole days since the oldest item started waiting; `undefined` when nothing is in scope. */ oldestDays?: number; /** Distinct people represented, for the staff peer-count rule. */ people: Array<{ personId: string; personName?: string; count: number }>; /** Sum of `amountCents` across everything in scope, or `undefined` when nothing carries money. */ totalCents?: number; } const dayMs = 24 * 60 * 60 * 1000; /** * The single derivation. Every level reads THIS. * * `hidden` and `more` come from one slice, not from two independent filters. The four defects above * are all one shape - a number derived separately from the list it describes - so the selector's job is * to make that separation impossible, not merely to compute the right answer once. */ export function selectCounts(items: readonly Countable[], options: CountingViewOptions = {}): CountingView { const { visibleLimit, now = new Date() } = options; const all = [...items]; const limit = visibleLimit === undefined ? all.length : Math.max(0, visibleLimit); const visible = all.slice(0, limit); const hidden = all.slice(limit); const ages = all .map((item) => (item.since ? now.getTime() - new Date(item.since).getTime() : undefined)) .filter((ms): ms is number => ms !== undefined && Number.isFinite(ms)); const byPerson = new Map(); for (const item of all) { if (!item.personId) continue; const entry = byPerson.get(item.personId) ?? { personId: item.personId, personName: item.personName, count: 0, }; entry.count += 1; byPerson.set(item.personId, entry); } const monied = all.filter((item) => typeof item.amountCents === 'number'); return { total: all.length, visible, hidden, // Deliberately arithmetic on the SAME slice, so `visible.length + more === total` holds by // construction rather than by agreement between two expressions. more: hidden.length, oldestDays: ages.length > 0 ? Math.floor(Math.max(...ages) / dayMs) : undefined, people: [...byPerson.values()], totalCents: monied.length > 0 ? monied.reduce((sum, item) => sum + (item.amountCents ?? 0), 0) : undefined, }; } /** * THE INVARIANT, exported so it can be asserted anywhere rather than restated. * * If this is ever false the surface is showing a number that disagrees with its own list - which is the * entire defect class this module exists to end. */ export function reconciles(view: CountingView): boolean { return view.visible.length + view.more === view.total; } /** * A QUALIFIER within a view - "3 appointments - **1 cancelled**". * * EXISTS SO A QUALIFIER CANNOT DRIFT FROM THE COUNT IT QUALIFIES. B28's fix originally derived the * cancelled tally from its own `.filter()` over a separate array; that is the same shape as the defect * itself - two numbers in one sentence, computed from two places, free to disagree. Counting *within the * view* means both halves of the sentence describe the same set by construction. * * Counts over `view.total`'s items, NOT over `view.visible`. A qualifier that silently described only * the rendered page would be a fresh instance of exactly this bug. */ export function countWhere(view: CountingView, predicate: (item: Countable) => boolean): number { return [...view.visible, ...view.hidden].filter(predicate).length; }