+ {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 --');