1414 * - `summary` / `tabular` → one grouped table (`rows` + `values`).
1515 * - `matrix` → a true cross-tab (ADR-0021 D2): `rows` down × `columns`
1616 * across, measures in the cells. One dataset query over all dimensions,
17- * pivoted client-side. (No totals row/column: measures like `avg` cannot
18- * be re-aggregated from bucketed values without drifting from the semantic
19- * layer — the governance red line. A matrix without `columns` degrades to
20- * the flat grouped table.)
17+ * pivoted client-side. Totals (per-row subtotals, per-column subtotals,
18+ * grand total) are SERVER-supplied: the selection asks for
19+ * `totals: { groupings: [rows, columns, []] }` and the renderer only
20+ * places the returned pre-aggregated rows. It never re-aggregates bucketed
21+ * values client-side — measures like `avg` cannot be recombined without
22+ * drifting from the semantic layer (the ADR-0021 governance red line) —
23+ * so an older server that returns no `totals` renders the plain cross-tab
24+ * with no totals row/column. A matrix without `columns` degrades to the
25+ * flat grouped table.
2126 * - `joined` → a vertical stack of blocks, each its own dataset-bound table,
2227 * with the report-level `runtimeFilter` merged into every block.
2328 *
@@ -35,8 +40,14 @@ import { mergeFilters } from './mergeFilters';
3540
3641type Row = Record < string , unknown > ;
3742
43+ /** One server-computed totals grouping: `dimensions: []` is the grand total. */
44+ interface DatasetTotals {
45+ dimensions : string [ ] ;
46+ rows : Row [ ] ;
47+ }
48+
3849interface DatasetCapableSource {
39- queryDataset ?: ( dataset : string , selection : unknown ) => Promise < { rows : Row [ ] } > ;
50+ queryDataset ?: ( dataset : string , selection : unknown ) => Promise < { rows : Row [ ] ; totals ?: DatasetTotals [ ] } > ;
4051}
4152
4253/** A report (or joined block) bound to a dataset. Field access is permissive — */
@@ -118,14 +129,21 @@ function useDatasetRows(
118129 measures : string [ ] ,
119130 runtimeFilter : Record < string , unknown > | undefined ,
120131 dataSource : unknown ,
132+ totalsGroupings ?: string [ ] [ ] ,
121133) {
122- const [ state , setState ] = React . useState < { status : 'idle' | 'loading' | 'ok' | 'error' ; rows : Row [ ] ; error ?: string } > ( {
134+ const [ state , setState ] = React . useState < {
135+ status : 'idle' | 'loading' | 'ok' | 'error' ;
136+ rows : Row [ ] ;
137+ totals ?: DatasetTotals [ ] ;
138+ error ?: string ;
139+ } > ( {
123140 status : 'idle' ,
124141 rows : [ ] ,
125142 } ) ;
126143
127144 const rfKey = JSON . stringify ( runtimeFilter ?? null ) ;
128- const signature = `${ dataset } |${ dimensions . join ( ',' ) } |${ measures . join ( ',' ) } |${ rfKey } ` ;
145+ const totalsKey = JSON . stringify ( totalsGroupings ?? null ) ;
146+ const signature = `${ dataset } |${ dimensions . join ( ',' ) } |${ measures . join ( ',' ) } |${ rfKey } |${ totalsKey } ` ;
129147 React . useEffect ( ( ) => {
130148 const src = dataSource as DatasetCapableSource | undefined ;
131149 if ( ! src || typeof src . queryDataset !== 'function' ) {
@@ -143,9 +161,16 @@ function useDatasetRows(
143161 dimensions,
144162 measures,
145163 ...( runtimeFilter && Object . keys ( runtimeFilter ) . length > 0 ? { runtimeFilter } : { } ) ,
164+ ...( totalsGroupings ? { totals : { groupings : totalsGroupings } } : { } ) ,
146165 } )
147166 . then ( ( res ) => {
148- if ( ! cancelled ) setState ( { status : 'ok' , rows : Array . isArray ( res ?. rows ) ? res . rows : [ ] } ) ;
167+ if ( ! cancelled ) {
168+ setState ( {
169+ status : 'ok' ,
170+ rows : Array . isArray ( res ?. rows ) ? res . rows : [ ] ,
171+ totals : Array . isArray ( res ?. totals ) ? res . totals : undefined ,
172+ } ) ;
173+ }
149174 } )
150175 . catch ( ( e ) => {
151176 if ( ! cancelled ) setState ( { status : 'error' , rows : [ ] , error : String ( ( e as Error ) ?. message ?? e ) } ) ;
@@ -271,7 +296,9 @@ function bucketLabel(dims: string[], row: Row): string {
271296 * True cross-tab for `type: 'matrix'` — one dataset query over
272297 * `[...rows, ...columns]`, pivoted client-side. Cells show every measure
273298 * (single measure → one column per across-bucket; multiple → one column per
274- * across-bucket × measure).
299+ * across-bucket × measure). Totals come from the SAME query via
300+ * `totals: { groupings: [rows, columns, []] }` — pre-aggregated server-side,
301+ * never recombined here; a response without `totals` renders no totals UI.
275302 */
276303function DatasetMatrixTable ( {
277304 dataset,
@@ -290,7 +317,12 @@ function DatasetMatrixTable({
290317 dataSource ?: unknown ;
291318 onDrill ?: ( args : DatasetDrillArgs ) => void ;
292319} ) {
293- const state = useDatasetRows ( dataset , [ ...rows , ...columnsAcross ] , values , runtimeFilter , dataSource ) ;
320+ // Row subtotals, column subtotals, and the grand total ([]), in that order.
321+ const state = useDatasetRows ( dataset , [ ...rows , ...columnsAcross ] , values , runtimeFilter , dataSource , [
322+ rows ,
323+ columnsAcross ,
324+ [ ] ,
325+ ] ) ;
294326
295327 const pivot = React . useMemo ( ( ) => {
296328 if ( state . status !== 'ok' ) return null ;
@@ -338,6 +370,19 @@ function DatasetMatrixTable({
338370 } ) ) ,
339371 ) ;
340372
373+ // Server-supplied totals: match each grouping by its `dimensions` array,
374+ // then match its rows to the pivot headers via the same bucketId. Absent
375+ // (older server) → every map stays empty and no totals UI renders.
376+ const findTotals = ( dims : string [ ] ) =>
377+ state . totals ?. find ( ( t ) => Array . isArray ( t . dimensions ) && t . dimensions . join ( ',' ) === dims . join ( ',' ) ) ?. rows ;
378+ const rowTotalById = new Map < string , Row > ( ) ;
379+ for ( const r of findTotals ( rows ) ?? [ ] ) rowTotalById . set ( bucketId ( rows , r ) , r ) ;
380+ const colTotalById = new Map < string , Row > ( ) ;
381+ for ( const r of findTotals ( columnsAcross ) ?? [ ] ) colTotalById . set ( bucketId ( columnsAcross , r ) , r ) ;
382+ const grandTotal = findTotals ( [ ] ) ?. [ 0 ] ;
383+ const showTotalCol = rowTotalById . size > 0 ;
384+ const showTotalRow = colTotalById . size > 0 ;
385+
341386 return (
342387 < div className = "overflow-auto max-h-[70vh] rounded-md border" data-testid = "dataset-matrix" >
343388 < table className = "w-full text-xs" >
@@ -353,6 +398,16 @@ function DatasetMatrixTable({
353398 { cc . header }
354399 </ th >
355400 ) ) }
401+ { showTotalCol &&
402+ values . map ( ( measure ) => (
403+ < th
404+ key = { `total-${ measure } ` }
405+ className = "px-2 py-1.5 text-right font-medium whitespace-nowrap"
406+ data-testid = "matrix-total-col-header"
407+ >
408+ { values . length === 1 ? 'Total' : `Total · ${ measure } ` }
409+ </ th >
410+ ) ) }
356411 </ tr >
357412 </ thead >
358413 < tbody >
@@ -378,8 +433,42 @@ function DatasetMatrixTable({
378433 </ td >
379434 ) ;
380435 } ) }
436+ { showTotalCol &&
437+ values . map ( ( measure ) => (
438+ < td
439+ key = { `total-${ measure } ` }
440+ className = "px-2 py-1 text-right tabular-nums whitespace-nowrap font-medium"
441+ data-testid = "matrix-row-total"
442+ >
443+ { formatCell ( rowTotalById . get ( rh . id ) ?. [ measure ] ) }
444+ </ td >
445+ ) ) }
381446 </ tr >
382447 ) ) }
448+ { showTotalRow && (
449+ < tr className = "border-t bg-muted/30 font-medium" data-testid = "matrix-total-row" >
450+ { rows . length > 0 && (
451+ < td colSpan = { rows . length } className = "px-2 py-1 whitespace-nowrap" >
452+ Total
453+ </ td >
454+ ) }
455+ { cellCols . map ( ( cc ) => (
456+ < td key = { `${ cc . col . id } -${ cc . measure } ` } className = "px-2 py-1 text-right tabular-nums whitespace-nowrap" >
457+ { formatCell ( colTotalById . get ( cc . col . id ) ?. [ cc . measure ] ) }
458+ </ td >
459+ ) ) }
460+ { showTotalCol &&
461+ values . map ( ( measure ) => (
462+ < td
463+ key = { `grand-${ measure } ` }
464+ className = "px-2 py-1 text-right tabular-nums whitespace-nowrap"
465+ data-testid = "matrix-grand-total"
466+ >
467+ { formatCell ( grandTotal ?. [ measure ] ) }
468+ </ td >
469+ ) ) }
470+ </ tr >
471+ ) }
383472 </ tbody >
384473 </ table >
385474 </ div >
0 commit comments