From 7b18544085b77b05f4b82d3e24eb0da17c74ffb3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carl-Gerhard=20Lindesva=CC=88rd?= Date: Mon, 24 Nov 2025 13:06:46 +0100 Subject: [PATCH] fix report table --- .../components/report-chart/area/chart.tsx | 178 +++---- .../report-chart/common/report-table-utils.ts | 21 +- .../report-chart/common/report-table.tsx | 486 ++++++++++++------ packages/db/src/engine/compute.ts | 55 +- packages/db/src/services/chart.service.ts | 55 +- 5 files changed, 537 insertions(+), 258 deletions(-) diff --git a/apps/start/src/components/report-chart/area/chart.tsx b/apps/start/src/components/report-chart/area/chart.tsx index 35f692f4..872ed19b 100644 --- a/apps/start/src/components/report-chart/area/chart.tsx +++ b/apps/start/src/components/report-chart/area/chart.tsx @@ -205,103 +205,109 @@ export function Chart({ data }: Props) {
- - - - {references.data?.map((ref) => ( - + - ))} - - - } /> - } /> - {series.map((serie) => { - const color = getChartColor(serie.index); - return ( - - - - - - - ); - })} - {series.map((serie) => { - const color = getChartColor(serie.index); - return ( - + {references.data?.map((ref) => ( + - ); - })} - {previous && - series.map((serie) => { + ))} + + + } /> + } /> + {series.map((serie) => { + const color = getChartColor(serie.index); + return ( + + + + + + + ); + })} + {series.map((serie) => { const color = getChartColor(serie.index); return ( ); })} - - -
- {isEditMode && ( - - )} + {previous && + series.map((serie) => { + const color = getChartColor(serie.index); + return ( + + ); + })} + + + + {isEditMode && ( + + )} ); diff --git a/apps/start/src/components/report-chart/common/report-table-utils.ts b/apps/start/src/components/report-chart/common/report-table-utils.ts index 4a398080..ee737cd0 100644 --- a/apps/start/src/components/report-chart/common/report-table-utils.ts +++ b/apps/start/src/components/report-chart/common/report-table-utils.ts @@ -5,6 +5,7 @@ export type TableRow = { id: string; serieName: string; breakdownValues: string[]; + count: number; sum: number; average: number; min: number; @@ -75,6 +76,7 @@ export function createFlatRows( id: serie.id, serieName: serie.names[0] ?? '', breakdownValues: serie.names.slice(1), + count: serie.metrics.count ?? 0, sum: serie.metrics.sum, average: serie.metrics.average, min: serie.metrics.min, @@ -144,25 +146,16 @@ export function createGroupedRows( // For each row in the group groupRows.forEach((row, index) => { const breakdownDisplay: (string | null)[] = []; + const firstRow = groupRows[0]!; if (index === 0) { // First row shows all breakdown values breakdownDisplay.push(...row.breakdownValues); } else { - // Subsequent rows: compare with first row in group - const firstRow = groupRows[0]!; - + // Subsequent rows: show all values, but mark duplicates for muted styling for (let i = 0; i < row.breakdownValues.length; i++) { - // Show empty if this breakdown matches the first row at this position - if (i < firstRow.breakdownValues.length) { - if (row.breakdownValues[i] === firstRow.breakdownValues[i]) { - breakdownDisplay.push(null); - } else { - breakdownDisplay.push(row.breakdownValues[i]!); - } - } else { - breakdownDisplay.push(row.breakdownValues[i]!); - } + // Always show the value, even if it matches the first row + breakdownDisplay.push(row.breakdownValues[i] ?? null); } } @@ -187,6 +180,7 @@ export function createSummaryRow( ): GroupedTableRow { // Aggregate metrics from all rows in the group const totalSum = groupRows.reduce((sum, row) => sum + row.sum, 0); + const totalCount = groupRows.reduce((sum, row) => sum + row.count, 0); const totalAverage = groupRows.reduce((sum, row) => sum + row.average, 0) / groupRows.length; const totalMin = Math.min(...groupRows.map((row) => row.min)); @@ -215,6 +209,7 @@ export function createSummaryRow( id: `summary-${groupKey}`, serieName: firstRow.serieName, breakdownValues: firstRow.breakdownValues, + count: totalCount, sum: totalSum, average: totalAverage, min: totalMin, diff --git a/apps/start/src/components/report-chart/common/report-table.tsx b/apps/start/src/components/report-chart/common/report-table.tsx index 59adc4c0..8378a02e 100644 --- a/apps/start/src/components/report-chart/common/report-table.tsx +++ b/apps/start/src/components/report-chart/common/report-table.tsx @@ -13,7 +13,7 @@ import { useSelector } from '@/redux'; import type { IChartData } from '@/trpc/client'; import { cn } from '@/utils/cn'; import { getChartColor } from '@/utils/theme'; -import type { ColumnDef } from '@tanstack/react-table'; +import type { ColumnDef, Header, Row } from '@tanstack/react-table'; import { type SortingState, flexRender, @@ -28,7 +28,7 @@ import { } from '@tanstack/react-virtual'; import throttle from 'lodash.throttle'; import { ChevronDown, ChevronRight } from 'lucide-react'; -import { useEffect, useMemo, useRef, useState } from 'react'; +import { memo, useEffect, useMemo, useRef, useState } from 'react'; import type * as React from 'react'; import { ReportTableToolbar } from './report-table-toolbar'; @@ -57,6 +57,111 @@ interface ReportTableProps { const DEFAULT_COLUMN_WIDTH = 150; const ROW_HEIGHT = 48; // h-12 +interface VirtualRowProps { + row: Row; + virtualRow: VirtualItem; + gridTemplateColumns: string; + pinningStylesMap: Map; + headers: Header[]; + isResizingRef: React.MutableRefObject; + resizingColumnId: string | null; + setResizingColumnId: (id: string | null) => void; +} + +const VirtualRow = function VirtualRow({ + row, + virtualRow, + gridTemplateColumns, + pinningStylesMap, + headers, + isResizingRef, + resizingColumnId, + setResizingColumnId, +}: VirtualRowProps) { + const cells = row.getVisibleCells(); + + return ( +
+ {headers.map((header) => { + const column = header.column; + const cell = cells.find((c) => c.column.id === column.id); + if (!cell) return null; + + const isBreakdown = column.columnDef.meta?.isBreakdown ?? false; + const pinningStyles = pinningStylesMap.get(column.id) ?? {}; + const canResize = column.getCanResize(); + const isPinned = column.columnDef.meta?.pinned === 'left'; + const isResizing = resizingColumnId === column.id; + + return ( +
+ {flexRender(cell.column.columnDef.cell, cell.getContext())} + {canResize && isPinned && ( +
{ + e.stopPropagation(); + isResizingRef.current = true; + setResizingColumnId(column.id); + header.getResizeHandler()(e); + }} + onMouseUp={() => { + setTimeout(() => { + isResizingRef.current = false; + setResizingColumnId(null); + }, 0); + }} + onTouchStart={(e) => { + e.stopPropagation(); + isResizingRef.current = true; + setResizingColumnId(column.id); + header.getResizeHandler()(e); + }} + onTouchEnd={() => { + setTimeout(() => { + isResizingRef.current = false; + setResizingColumnId(null); + }, 0); + }} + className={cn( + 'absolute right-0 top-0 h-full w-1 cursor-col-resize touch-none select-none bg-transparent hover:bg-primary/50 transition-colors', + isResizing && 'bg-primary', + )} + /> + )} +
+ ); + })} +
+ ); +}; + export function ReportTable({ data, visibleSeries, @@ -69,12 +174,14 @@ export function ReportTable({ const [sorting, setSorting] = useState([]); const [globalFilter, setGlobalFilter] = useState(''); const [columnSizing, setColumnSizing] = useState>({}); + const [resizingColumnId, setResizingColumnId] = useState(null); const isResizingRef = useRef(false); const parentRef = useRef(null); const [scrollMargin, setScrollMargin] = useState(0); const number = useNumber(); const interval = useSelector((state) => state.report.interval); const breakdowns = useSelector((state) => state.report.breakdowns); + const formatDate = useFormatDateInterval({ interval, short: true, @@ -153,7 +260,7 @@ export function ReportTable({ } // Search in metric values - const metrics = ['sum', 'average', 'min', 'max'] as const; + const metrics = ['count', 'sum', 'average', 'min', 'max'] as const; if ( metrics.some((metric) => String(row[metric]).toLowerCase().includes(searchLower), @@ -253,6 +360,10 @@ export function ReportTable({ // Calculate min/max values for color visualization const { metricRanges, dateRanges } = useMemo(() => { const metricRanges: Record = { + count: { + min: Number.POSITIVE_INFINITY, + max: Number.NEGATIVE_INFINITY, + }, sum: { min: Number.POSITIVE_INFINITY, max: Number.NEGATIVE_INFINITY }, average: { min: Number.POSITIVE_INFINITY, @@ -270,29 +381,60 @@ export function ReportTable({ }; }); - rows.forEach((row) => { - // Calculate metric ranges - Object.keys(metricRanges).forEach((key) => { - const value = row[key as keyof typeof row] as number; - if (typeof value === 'number') { - metricRanges[key]!.min = Math.min(metricRanges[key]!.min, value); - metricRanges[key]!.max = Math.max(metricRanges[key]!.max, value); - } + // Check if we only have one series (excluding summary rows) + const nonSummaryRows = rows.filter((row) => !row.isSummaryRow); + const isSingleSeries = nonSummaryRows.length === 1; + + if (isSingleSeries) { + // For single series, calculate ranges from date values + const singleRow = nonSummaryRows[0]!; + const allDateValues = dates.map( + (date) => singleRow.dateValues[date] ?? 0, + ); + const dateMin = Math.min(...allDateValues); + const dateMax = Math.max(...allDateValues); + + // For date columns, use the range across all dates + dates.forEach((date) => { + dateRanges[date] = { + min: dateMin, + max: dateMax, + }; }); - // Calculate date ranges - dates.forEach((date) => { - const value = row.dateValues[date] ?? 0; - if (!dateRanges[date]) { - dateRanges[date] = { - min: Number.POSITIVE_INFINITY, - max: Number.NEGATIVE_INFINITY, - }; - } - dateRanges[date]!.min = Math.min(dateRanges[date]!.min, value); - dateRanges[date]!.max = Math.max(dateRanges[date]!.max, value); + // For metric columns, use date values to create meaningful ranges + // This ensures we can still show color variation even with one series + metricRanges.count = { min: dateMin, max: dateMax }; + metricRanges.sum = { min: dateMin, max: dateMax }; + metricRanges.average = { min: dateMin, max: dateMax }; + metricRanges.min = { min: dateMin, max: dateMax }; + metricRanges.max = { min: dateMin, max: dateMax }; + } else { + // Multiple series: calculate ranges across rows + rows.forEach((row) => { + // Calculate metric ranges + Object.keys(metricRanges).forEach((key) => { + const value = row[key as keyof typeof row] as number; + if (typeof value === 'number') { + metricRanges[key]!.min = Math.min(metricRanges[key]!.min, value); + metricRanges[key]!.max = Math.max(metricRanges[key]!.max, value); + } + }); + + // Calculate date ranges + dates.forEach((date) => { + const value = row.dateValues[date] ?? 0; + if (!dateRanges[date]) { + dateRanges[date] = { + min: Number.POSITIVE_INFINITY, + max: Number.NEGATIVE_INFINITY, + }; + } + dateRanges[date]!.min = Math.min(dateRanges[date]!.min, value); + dateRanges[date]!.max = Math.max(dateRanges[date]!.max, value); + }); }); - }); + } return { metricRanges, dateRanges }; }, [rows, dates]); @@ -302,6 +444,7 @@ export function ReportTable({ value: number, min: number, max: number, + className?: string, ): { opacity: number; className: string } => { if (value === 0 || max === min) { return { opacity: 0, className: '' }; @@ -312,7 +455,7 @@ export function ReportTable({ return { opacity, - className: 'bg-highlight dark:bg-emerald-700', + className: cn('bg-highlight dark:bg-emerald-700', className), }; }; @@ -325,6 +468,11 @@ export function ReportTable({ return (visibleSeries as IChartData['series']).map((s) => s.id); }, [visibleSeries]); + // Create a hash of visibleSeriesIds to track checkbox state changes + const visibleSeriesIdsHash = useMemo(() => { + return visibleSeriesIds.sort().join(','); + }, [visibleSeriesIds]); + // Get serie index for color const getSerieIndex = (serieId: string): number => { return data.series.findIndex((s) => s.id === serieId); @@ -368,12 +516,42 @@ export function ReportTable({ pinned: 'left', }, cell: ({ row }) => { - const serieName = row.original.serieName; - const serieId = row.original.originalSerie.id; + const original = row.original; + const serieName = original.serieName; + const serieId = original.originalSerie.id; const isVisible = visibleSeriesIds.includes(serieId); const serieIndex = getSerieIndex(serieId); const color = getChartColor(serieIndex); + // Check if this serie name matches the first row in the group (for muted styling) + let isMuted = false; + let isFirstRowInGroup = false; + if ( + grouped && + 'groupKey' in original && + original.groupKey && + !original.isSummaryRow + ) { + // Find all rows in this group and get the first one + const groupRows = (rawRows as GroupedTableRow[]).filter( + (r) => r.groupKey === original.groupKey && !r.isSummaryRow, + ); + + if (groupRows.length > 0) { + const firstRowInGroup = groupRows[0]!; + + // Check if this is the first row in the group + if (firstRowInGroup.id === original.id) { + isFirstRowInGroup = true; + } else { + // Only mute if this is not the first row and the serie name matches + if (firstRowInGroup.serieName === serieName) { + isMuted = true; + } + } + } + } + return (
- +
); }, @@ -460,21 +645,50 @@ export function ReportTable({ cell: ({ row }) => { const original = row.original; let value: string | null; + let isMuted = false; + let isFirstRowInGroup = false; if ('breakdownDisplay' in original && grouped) { value = original.breakdownDisplay[index] ?? null; + + // Check if this is the first row in the group and if this breakdown should be bold + if (value && original.groupKey && !original.isSummaryRow) { + // Find all rows in this group and get the first one + const groupRows = (rawRows as GroupedTableRow[]).filter( + (r) => r.groupKey === original.groupKey && !r.isSummaryRow, + ); + + if (groupRows.length > 0) { + const firstRowInGroup = groupRows[0]!; + + // Check if this is the first row in the group + if (firstRowInGroup.id === original.id) { + isFirstRowInGroup = true; + } else { + // Only mute if this is not the first row and the value matches + const firstRowValue = firstRowInGroup.breakdownValues[index]; + if (firstRowValue === value) { + isMuted = true; + } + } + } + } } else { value = original.breakdownValues[index] ?? null; } const isSummary = original.isSummaryRow ?? false; + // Make bold if it's the first row in group and this is one of the first breakdown columns + // (all breakdowns except the last one) + const shouldBeBold = + isFirstRowInGroup && index < breakdownPropertyNames.length - 1; return ( {value || ''} @@ -486,6 +700,7 @@ export function ReportTable({ // Metric columns const metrics = [ + { key: 'count', label: 'Unique' }, { key: 'sum', label: 'Sum' }, { key: 'average', label: 'Average' }, { key: 'min', label: 'Min' }, @@ -504,7 +719,12 @@ export function ReportTable({ const isSummary = row.original.isSummaryRow ?? false; const range = metricRanges[metric.key]; const { opacity, className } = range - ? getCellBackground(value, range.min, range.max) + ? getCellBackground( + value, + range.min, + range.max, + 'bg-purple-400 dark:bg-purple-700', + ) : { opacity: 0, className: '' }; return ( @@ -582,6 +802,11 @@ export function ReportTable({ columnSizing, ]); + // Create a hash of column IDs to track when columns change + const columnsHash = useMemo(() => { + return columns.map((col) => col.id).join(','); + }, [columns]); + const table = useReactTable({ data: filteredRows, columns, @@ -628,6 +853,7 @@ export function ReportTable({ // Small delay to ensure resize handlers complete setTimeout(() => { isResizingRef.current = false; + setResizingColumnId(null); }, 100); } }; @@ -665,40 +891,69 @@ export function ReportTable({ .filter((col) => col.columnDef.meta?.pinned === 'right') .filter((col): col is NonNullable => col !== undefined); - // Helper to get pinning styles + // Pre-compute grid template columns string and headers + const { gridTemplateColumns, headers } = useMemo(() => { + const headerGroups = table.getHeaderGroups(); + const firstGroupHeaders = headerGroups[0]?.headers ?? []; + return { + gridTemplateColumns: + firstGroupHeaders.map((h) => `${h.getSize()}px`).join(' ') ?? '', + headers: firstGroupHeaders, + }; + }, [table, columnSizing, columnsHash]); + + // Pre-compute pinning styles for all columns + const pinningStylesMap = useMemo(() => { + const stylesMap = new Map(); + const headerGroups = table.getHeaderGroups(); + + headerGroups.forEach((group) => { + group.headers.forEach((header) => { + const column = header.column; + const isPinned = column.columnDef.meta?.pinned; + if (!isPinned) { + stylesMap.set(column.id, {}); + return; + } + + const pinnedColumns = + isPinned === 'left' ? leftPinnedColumns : rightPinnedColumns; + const columnIndex = pinnedColumns.findIndex((c) => c.id === column.id); + const isLastPinned = + columnIndex === pinnedColumns.length - 1 && isPinned === 'left'; + const isFirstRightPinned = columnIndex === 0 && isPinned === 'right'; + + let left = 0; + if (isPinned === 'left') { + for (let i = 0; i < columnIndex; i++) { + left += pinnedColumns[i]!.getSize(); + } + } + + stylesMap.set(column.id, { + position: 'sticky' as const, + left: isPinned === 'left' ? `${left}px` : undefined, + right: isPinned === 'right' ? '0px' : undefined, + zIndex: 10, + backgroundColor: 'var(--card)', + boxShadow: isLastPinned + ? '-4px 0 4px -4px var(--border) inset' + : isFirstRightPinned + ? '4px 0 4px -4px var(--border) inset' + : undefined, + }); + }); + }); + + return stylesMap; + }, [table, leftPinnedColumns, rightPinnedColumns, columnSizing, columnsHash]); + + // Helper to get pinning styles (for backward compatibility with header) const getPinningStyles = ( column: ReturnType | undefined, ) => { if (!column) return {}; - const isPinned = column.columnDef.meta?.pinned; - if (!isPinned) return {}; - - const pinnedColumns = - isPinned === 'left' ? leftPinnedColumns : rightPinnedColumns; - const columnIndex = pinnedColumns.findIndex((c) => c.id === column.id); - const isLastPinned = - columnIndex === pinnedColumns.length - 1 && isPinned === 'left'; - const isFirstRightPinned = columnIndex === 0 && isPinned === 'right'; - - let left = 0; - if (isPinned === 'left') { - for (let i = 0; i < columnIndex; i++) { - left += pinnedColumns[i]!.getSize(); - } - } - - return { - position: 'sticky' as const, - left: isPinned === 'left' ? `${left}px` : undefined, - right: isPinned === 'right' ? '0px' : undefined, - zIndex: 10, - backgroundColor: 'var(--card)', - boxShadow: isLastPinned - ? '-4px 0 4px -4px var(--border) inset' - : isFirstRightPinned - ? '4px 0 4px -4px var(--border) inset' - : undefined, - }; + return pinningStylesMap.get(column.id) ?? {}; }; if (rows.length === 0) { @@ -721,11 +976,7 @@ export function ReportTable({ className="sticky top-0 z-20 bg-card border-b" style={{ display: 'grid', - gridTemplateColumns: - table - .getHeaderGroups()[0] - ?.headers.map((h) => `${h.getSize()}px`) - .join(' ') ?? '', + gridTemplateColumns, minWidth: 'fit-content', }} > @@ -809,22 +1060,26 @@ export function ReportTable({ onMouseDown={(e) => { e.stopPropagation(); isResizingRef.current = true; + setResizingColumnId(column.id); header.getResizeHandler()(e); }} onMouseUp={() => { // Use setTimeout to allow the resize to complete before resetting setTimeout(() => { isResizingRef.current = false; + setResizingColumnId(null); }, 0); }} onTouchStart={(e) => { e.stopPropagation(); isResizingRef.current = true; + setResizingColumnId(column.id); header.getResizeHandler()(e); }} onTouchEnd={() => { setTimeout(() => { isResizingRef.current = false; + setResizingColumnId(null); }, 0); }} className={cn( @@ -850,95 +1105,20 @@ export function ReportTable({ if (!tableRow) return null; return ( -
`${h.getSize()}px`) - .join(' ') ?? '', - minWidth: 'fit-content', + - {table.getHeaderGroups()[0]?.headers.map((header) => { - const column = header.column; - const cell = tableRow - .getVisibleCells() - .find((c) => c.column.id === column.id); - if (!cell) return null; - - const isBreakdown = - column.columnDef.meta?.isBreakdown ?? false; - const pinningStyles = getPinningStyles(column); - const isMetricOrDate = - column.id.startsWith('metric-') || - column.id.startsWith('date-'); - - const canResize = column.getCanResize(); - const isPinned = column.columnDef.meta?.pinned === 'left'; - - return ( -
- {flexRender( - cell.column.columnDef.cell, - cell.getContext(), - )} - {canResize && isPinned && ( -
{ - e.stopPropagation(); - isResizingRef.current = true; - header.getResizeHandler()(e); - }} - onMouseUp={() => { - setTimeout(() => { - isResizingRef.current = false; - }, 0); - }} - onTouchStart={(e) => { - e.stopPropagation(); - isResizingRef.current = true; - header.getResizeHandler()(e); - }} - onTouchEnd={() => { - setTimeout(() => { - isResizingRef.current = false; - }, 0); - }} - className={cn( - 'absolute right-0 top-0 h-full w-1 cursor-col-resize touch-none select-none bg-transparent hover:bg-primary/50 transition-colors', - column.getIsResizing() && 'bg-primary', - )} - /> - )} -
- ); - })} -
+ gridTemplateColumns={gridTemplateColumns} + pinningStylesMap={pinningStylesMap} + headers={headers} + isResizingRef={isResizingRef} + resizingColumnId={resizingColumnId} + setResizingColumnId={setResizingColumnId} + /> ); })}
diff --git a/packages/db/src/engine/compute.ts b/packages/db/src/engine/compute.ts index ed50e323..db60c79f 100644 --- a/packages/db/src/engine/compute.ts +++ b/packages/db/src/engine/compute.ts @@ -72,6 +72,58 @@ export function compute( (a, b) => new Date(a).getTime() - new Date(b).getTime(), ); + // Calculate total_count for the formula using the same formula applied to input series' total_count values + // total_count is constant across all dates for a breakdown group, so compute it once + const totalCountScope: Record = {}; + definitions.slice(0, formulaIndex).forEach((depDef, depIndex) => { + const readableId = alphabetIds[depIndex]; + if (!readableId) { + return; + } + + // Find the series for this dependency in the current breakdown group + const depSeries = seriesByIndex.get(depIndex); + if (depSeries) { + // Get total_count from any data point (it's the same for all dates) + const totalCount = depSeries.data.find( + (d) => d.total_count != null, + )?.total_count; + totalCountScope[readableId] = totalCount ?? 0; + } else { + // Could be a formula from a previous breakdown group - find it in results + const formulaSerie = results.find( + (s) => + s.definitionIndex === depIndex && + 'type' in s.definition && + s.definition.type === 'formula' && + s.name.slice(1).join(':::') === breakdownSignature, + ); + if (formulaSerie) { + const totalCount = formulaSerie.data.find( + (d) => d.total_count != null, + )?.total_count; + totalCountScope[readableId] = totalCount ?? 0; + } else { + totalCountScope[readableId] = 0; + } + } + }); + + // Evaluate formula for total_count + let formulaTotalCount: number | undefined; + try { + const result = mathjs + .parse(formula.formula) + .compile() + .evaluate(totalCountScope) as number; + formulaTotalCount = + Number.isNaN(result) || !Number.isFinite(result) + ? undefined + : round(result, 2); + } catch (error) { + formulaTotalCount = undefined; + } + // Calculate formula for each date const formulaData = sortedDates.map((date) => { const scope: Record = {}; @@ -124,8 +176,7 @@ export function compute( Number.isNaN(count) || !Number.isFinite(count) ? 0 : round(count, 2), - total_count: breakdownSeries[0]?.data.find((d) => d.date === date) - ?.total_count, + total_count: formulaTotalCount, }; }); diff --git a/packages/db/src/services/chart.service.ts b/packages/db/src/services/chart.service.ts index 092986c3..989c3730 100644 --- a/packages/db/src/services/chart.service.ts +++ b/packages/db/src/services/chart.service.ts @@ -231,13 +231,60 @@ export function getChartSql({ return sql; } - const totalUniqueSubquery = `( - SELECT ${sb.select.count} + // Build total_count calculation that accounts for breakdowns + // When breakdowns exist, we need to calculate total_count per breakdown group + if (breakdowns.length > 0) { + // Create a subquery that calculates total_count per breakdown group (without date grouping) + // Then reference it in the main query via JOIN + const breakdownSelects = breakdowns + .map((breakdown, index) => { + const key = `label_${index + 1}`; + const breakdownExpr = getSelectPropertyKey(breakdown.name); + return `${breakdownExpr} as ${key}`; + }) + .join(', '); + + // GROUP BY needs to use the actual expressions, not aliases + const breakdownGroupByExprs = breakdowns + .map((breakdown) => getSelectPropertyKey(breakdown.name)) + .join(', '); + + // Build the total_count subquery grouped only by breakdowns (no date) + // Extract the count expression without the alias (remove "as count") + const countExpression = sb.select.count.replace(/\s+as\s+count$/i, ''); + const totalCountSubquery = `( + SELECT + ${breakdownSelects}, + ${countExpression} as total_count FROM ${sb.from} ${getJoins()} ${getWhere()} - )`; - sb.select.total_unique_count = `${totalUniqueSubquery} as total_count`; + GROUP BY ${breakdownGroupByExprs} + ) as total_counts`; + + // Join the total_counts subquery to get total_count per breakdown + // Match on the breakdown column values + const joinConditions = breakdowns + .map((_, index) => { + const outerKey = `label_${index + 1}`; + return `${outerKey} = total_counts.label_${index + 1}`; + }) + .join(' AND '); + + sb.joins.total_counts = `LEFT JOIN ${totalCountSubquery} ON ${joinConditions}`; + // Use any() aggregate since total_count is the same for all rows in a breakdown group + sb.select.total_unique_count = + 'any(total_counts.total_count) as total_count'; + } else { + // No breakdowns - use a simple subquery for total count + const totalUniqueSubquery = `( + SELECT ${sb.select.count} + FROM ${sb.from} + ${getJoins()} + ${getWhere()} + )`; + sb.select.total_unique_count = `${totalUniqueSubquery} as total_count`; + } const sql = `${getSelect()} ${getFrom()} ${getJoins()} ${getWhere()} ${getGroupBy()} ${getOrderBy()} ${getFill()}`; console.log('-- Report --');