File size: 6,670 Bytes
a878ebb | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 | // ---------------------------------------------------------------------------
// 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;
}
|