feat: support multiple column dimensions in dynamic reports

Combine column dimensions into composite keys instead of using only the
first column dimension, enabling richer pivot tables and charts.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
le king fu 2026-02-22 10:00:42 -05:00
parent 945985c969
commit 04ec221808
3 changed files with 32 additions and 19 deletions

View file

@ -36,19 +36,25 @@ export default function DynamicReportChart({ config, result }: DynamicReportChar
return { chartData: [], seriesKeys: [], seriesColors: {} }; return { chartData: [], seriesKeys: [], seriesColors: {} };
} }
const colDim = config.columns[0]; const colDims = config.columns;
const rowDim = config.rows[0]; const rowDim = config.rows[0];
const measure = config.values[0] || "periodic"; const measure = config.values[0] || "periodic";
// X-axis = first column dimension (or first row dimension if no columns) // X-axis = composite column key (or first row dimension if no columns)
const xDim = colDim || rowDim; const hasColDims = colDims.length > 0;
if (!xDim) return { chartData: [], seriesKeys: [], seriesColors: {} }; if (!hasColDims && !rowDim) return { chartData: [], seriesKeys: [], seriesColors: {} };
// Build composite column key per row
const getColKey = (r: typeof result.rows[0]) =>
colDims.map((d) => r.keys[d] || "").join(" — ");
// Series = first row dimension (or no stacking if no rows, or first row if columns exist) // Series = first row dimension (or no stacking if no rows, or first row if columns exist)
const seriesDim = colDim ? rowDim : undefined; const seriesDim = hasColDims ? rowDim : undefined;
// Collect unique x and series values // Collect unique x and series values
const xValues = [...new Set(result.rows.map((r) => r.keys[xDim]))].sort(); const xValues = hasColDims
? [...new Set(result.rows.map(getColKey))].sort()
: [...new Set(result.rows.map((r) => r.keys[rowDim]))].sort();
const seriesVals = seriesDim const seriesVals = seriesDim
? [...new Set(result.rows.map((r) => r.keys[seriesDim]))].sort() ? [...new Set(result.rows.map((r) => r.keys[seriesDim]))].sort()
: [measure]; : [measure];
@ -59,12 +65,14 @@ export default function DynamicReportChart({ config, result }: DynamicReportChar
if (seriesDim) { if (seriesDim) {
for (const sv of seriesVals) { for (const sv of seriesVals) {
const matchingRows = result.rows.filter( const matchingRows = result.rows.filter(
(r) => r.keys[xDim] === xVal && r.keys[seriesDim] === sv (r) => (hasColDims ? getColKey(r) : r.keys[rowDim]) === xVal && r.keys[seriesDim] === sv
); );
entry[sv] = matchingRows.reduce((sum, r) => sum + (r.measures[measure] || 0), 0); entry[sv] = matchingRows.reduce((sum, r) => sum + (r.measures[measure] || 0), 0);
} }
} else { } else {
const matchingRows = result.rows.filter((r) => r.keys[xDim] === xVal); const matchingRows = result.rows.filter((r) =>
hasColDims ? getColKey(r) === xVal : r.keys[rowDim] === xVal
);
entry[measure] = matchingRows.reduce((sum, r) => sum + (r.measures[measure] || 0), 0); entry[measure] = matchingRows.reduce((sum, r) => sum + (r.measures[measure] || 0), 0);
} }
return entry; return entry;

View file

@ -23,7 +23,7 @@ interface PivotedRow {
function pivotRows( function pivotRows(
rows: PivotResultRow[], rows: PivotResultRow[],
rowDims: string[], rowDims: string[],
colDim: string | undefined, colDims: string[],
measures: string[], measures: string[],
): PivotedRow[] { ): PivotedRow[] {
const map = new Map<string, PivotedRow>(); const map = new Map<string, PivotedRow>();
@ -38,7 +38,9 @@ function pivotRows(
map.set(rowKey, pivoted); map.set(rowKey, pivoted);
} }
const colKey = colDim ? (row.keys[colDim] || "") : "__all__"; const colKey = colDims.length > 0
? colDims.map((d) => row.keys[d] || "").join("\0")
: "__all__";
if (!pivoted.cells[colKey]) pivoted.cells[colKey] = {}; if (!pivoted.cells[colKey]) pivoted.cells[colKey] = {};
for (const m of measures) { for (const m of measures) {
pivoted.cells[colKey][m] = (pivoted.cells[colKey][m] || 0) + (row.measures[m] || 0); pivoted.cells[colKey][m] = (pivoted.cells[colKey][m] || 0) + (row.measures[m] || 0);
@ -107,14 +109,17 @@ export default function DynamicReportTable({ config, result }: DynamicReportTabl
}; };
const rowDims = config.rows; const rowDims = config.rows;
const colDim = config.columns[0] || undefined; const colDims = config.columns;
const colValues = colDim ? result.columnValues : ["__all__"]; const colValues = colDims.length > 0 ? result.columnValues : ["__all__"];
const measures = config.values; const measures = config.values;
// Display label for a composite column key (joined with \0)
const colLabel = (compositeKey: string) => compositeKey.split("\0").join(" — ");
// Pivot the flat SQL rows into one PivotedRow per unique row-key combo // Pivot the flat SQL rows into one PivotedRow per unique row-key combo
const pivotedRows = useMemo( const pivotedRows = useMemo(
() => pivotRows(result.rows, rowDims, colDim, measures), () => pivotRows(result.rows, rowDims, colDims, measures),
[result.rows, rowDims, colDim, measures], [result.rows, rowDims, colDims, measures],
); );
if (pivotedRows.length === 0) { if (pivotedRows.length === 0) {
@ -156,7 +161,7 @@ export default function DynamicReportTable({ config, result }: DynamicReportTabl
{colValues.map((colVal) => {colValues.map((colVal) =>
measures.map((m) => ( measures.map((m) => (
<th key={`${colVal}-${m}`} className="text-right px-3 py-2 font-medium text-[var(--muted-foreground)] border-l border-[var(--border)]"> <th key={`${colVal}-${m}`} className="text-right px-3 py-2 font-medium text-[var(--muted-foreground)] border-l border-[var(--border)]">
{colDim ? (measures.length > 1 ? `${colVal}${measureLabel(m)}` : colVal) : measureLabel(m)} {colDims.length > 0 ? (measures.length > 1 ? `${colLabel(colVal)}${measureLabel(m)}` : colLabel(colVal)) : measureLabel(m)}
</th> </th>
)) ))
)} )}

View file

@ -308,10 +308,10 @@ export async function getDynamicReportData(
} }
} }
// Extract distinct column values // Extract distinct column values (composite key when multiple column dimensions)
const columnDim = config.columns[0]; const colDims = config.columns;
const columnValues = columnDim const columnValues = colDims.length > 0
? [...new Set(rows.map((r) => r.keys[columnDim]))].sort() ? [...new Set(rows.map((r) => colDims.map((d) => r.keys[d] || "").join("\0")))].sort()
: []; : [];
// Dimension labels // Dimension labels