// --------------------------------------------------------------------------- // customer-grid / aggregations.ts // COLUMN SUMMARIES — the per-group subtotal AND the table's own totals row. // `computeAggs` folds every field carrying an `agg` over a bucket of rows and // returns fieldKey -> formatted display string. Non-agg fields are simply // absent from the map, so the footer renderer draws them blank. // // Called ONCE per group inside the useVisibleRows memo (never per cell/frame); // the resulting object is shared by that group's header and footer, and once // more for the ungrouped totals row. // --------------------------------------------------------------------------- import { isNumericFieldType } from "./types"; // ⛔ ONE numeric reading of a stored cell, shared with the CANVAS renderer — see its // note. Two copies is how a cell painted blank while the total under it counted 1,234.50. import { numericOrUndefined } from "./display"; import type { AggName, Field, Row } from "./types"; // ⭐⭐ W29-T74 — IMPORTED, NOT RE-DECLARED, which is what `iconShapes.ts` always claimed. import { FIELD_AGGS, FIELD_AGG_LABELS } from "./iconShapes"; /** * ⭐⭐ WAVE-29 CONTRACT C7 — **THE CLIENT MIRROR of `platform/aios_grid.FIELD_AGGS`**, published * by session C and held in step by a parity gate. The ORDER is the picker's order. * * ⛔ THE MIRROR IS NOW AN IMPORT, AND UNTIL W29-T74 IT WAS A LIE. Both `iconShapes.ts:620` and * `aios_grid.py:1069` said *"ONE CLIENT LIST, IMPORTED — never re-declared"* while this file * re-declared it three lines later. Every runtime consumer (`ColumnMenu`, `CustomerGrid`, * `useVisibleRows`) imports `./aggregations`, so `iconShapes`' copy had ZERO importers and the * parity gate was diffing a literal no production file read, while the live list drifted free * ([[gate-answers-the-wrong-question]]). Re-exported under this module's own name so no call * site had to change — the fix is the import, not a rename cascade. * * ⛔ THREE VOCABULARIES EXIST AND THEY ARE NOT ONE LIST — merging them is the trap on this * contract: * · `FIELD_AGGS` (iconShapes + aios_grid + user_tables) a FIELD's `agg`: the column summary. * · `CHART_AGGS` (aios_grid, `avg`) STORED chart values with live data. Untouchable: * an unknown agg falls back to `sum` and drops a * calendar metric card, with nothing red. * · `ROLLUP_FNS` (user_tables, 16 names) rollup FOLDS. Overlaps; is not the same list. * * ⚠ `average`, not `avg`: `ROLLUP_FNS` already spells it that way and 47 rollups run on it in * production, so a column summary and a rollup fold name the same operation the same way. * ⚠ `median` is in NEITHER of the other two — it is net-new on both sides of this contract. */ export { FIELD_AGGS }; /** How each one reads in the picker. Sentence case, because it is a menu row, not a header. * ⚠ The NAME differs from its source on purpose: `viewModes.tsx` has a module-local `AGG_LABELS` * over `CHART_AGGS` where the same names wear different words, so the icon module's copy is * prefixed. This alias keeps this module's callers reading `AGG_LABELS` and keeps ONE table. */ export const AGG_LABELS: Record = FIELD_AGG_LABELS; /** * What THIS column may be summarised by (C7's semantics, not a preference): * `sum · average · median · min · max` are numeric-only; `count` counts ROWS and so applies to * every type. Offering Sum on a text column would render a number nobody can reconcile. */ export function aggOptions(field: Field): readonly AggName[] { return isNumericFieldType(field.type) ? FIELD_AGGS : FIELD_AGGS.filter((a) => a === "count"); } /** * The numeric value of a cell, or `undefined` — blank and unparseable are NOT zero. * * ⛔ IMPORTED, NOT RE-DERIVED (W29-T81 follow-up). This function and `display.numericIsBlank` * are the same question asked by two surfaces — "is there a number in this cell" — and while * they were two implementations they answered differently: this one stripped `$` and `,`, the * renderer's only trimmed, so `"1,234.50"` was COUNTED in the totals row under a cell that * painted BLANK. The fold's normalisation was the correct one and is now the only one. */ const numOf = numericOrUndefined; function median(sorted: number[]): number { const mid = sorted.length >> 1; return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2; } /** * Fold every field carrying an `agg` over `rows`, formatted to match its column: * currency -> "$" + toLocaleString | everything else -> toLocaleString * Values are rounded to 2 decimals first to kill floating-point drift (1234.5600000001). * * ⛔ **BLANK, NEVER ZERO** (C7, and the codebase's standing law): a numeric fold over a set * holding no numeric value at all returns nothing and the cell stays empty — `0` is a * measurement and this is an admission that there was nothing to measure. Only `count` returns * `0`, because "no rows" genuinely is a count. This is also what `_rollup_fold` already does. * * ⚠ `count` counts ROWS IN SCOPE, not non-blank cells. `ROLLUP_FNS` splits that hair three ways * (`count`/`counta`/`countall`); a column summary does not, and must not grow a second spelling. */ export function computeAggs(rows: Row[], fields: Field[]): Record { const out: Record = {}; for (const f of fields) { const agg = f.agg as AggName | undefined; if (!agg || !FIELD_AGGS.includes(agg)) continue; if (agg === "count") { out[f.key] = rows.length.toLocaleString(); continue; } if (!isNumericFieldType(f.type)) continue; const nums: number[] = []; for (const r of rows) { const n = numOf(r[f.key]); if (n !== undefined) nums.push(n); } if (!nums.length) continue; // blank, never zero let value: number; if (agg === "sum") value = nums.reduce((a, b) => a + b, 0); else if (agg === "average") value = nums.reduce((a, b) => a + b, 0) / nums.length; else if (agg === "median") value = median([...nums].sort((a, b) => a - b)); else if (agg === "min") value = Math.min(...nums); else value = Math.max(...nums); const clean = Math.round(value * 100) / 100; // drop float artifacts out[f.key] = f.type === "currency" ? "$" + clean.toLocaleString() : clean.toLocaleString(); } return out; }