| // --------------------------------------------------------------------------- | |
| // 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<AggName, string> = 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<string, string> { | |
| const out: Record<string, string> = {}; | |
| 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; | |
| } | |