loopable / web /src /customer-grid /aggregations.ts
fsanyoto's picture
Deploy AIOS web (React glide grid + FastAPI slice)
a878ebb verified
Raw
History Blame Contribute Delete
6.67 kB
// ---------------------------------------------------------------------------
// 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;
}