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;
}