fix report table

This commit is contained in:
Carl-Gerhard Lindesvärd
2025-11-24 13:06:46 +01:00
parent 57697a5a39
commit 7b18544085
5 changed files with 537 additions and 258 deletions

View File

@@ -205,103 +205,109 @@ export function Chart({ data }: Props) {
<div className={cn('h-full w-full', isEditMode && 'card p-4')}>
<ResponsiveContainer>
<ComposedChart data={rechartData}>
<Customized component={calcStrokeDasharray} />
<Line
dataKey="calcStrokeDasharray"
legendType="none"
animationDuration={0}
onAnimationEnd={handleAnimationEnd}
/>
<CartesianGrid
strokeDasharray="3 3"
horizontal={true}
vertical={false}
className="stroke-border"
/>
{references.data?.map((ref) => (
<ReferenceLine
key={ref.id}
x={ref.date.getTime()}
stroke={'oklch(from var(--foreground) l c h / 0.1)'}
strokeDasharray={'3 3'}
label={{
value: ref.title,
position: 'centerTop',
fill: '#334155',
fontSize: 12,
}}
fontSize={10}
<Customized component={calcStrokeDasharray} />
<Line
dataKey="calcStrokeDasharray"
legendType="none"
animationDuration={0}
onAnimationEnd={handleAnimationEnd}
/>
))}
<YAxis {...yAxisProps} />
<XAxis {...xAxisProps} />
<Legend content={<CustomLegend />} />
<Tooltip content={<ReportChartTooltip.Tooltip />} />
{series.map((serie) => {
const color = getChartColor(serie.index);
return (
<defs key={`defs-${serie.id}`}>
<linearGradient
id={`color${color}`}
x1="0"
y1="0"
x2="0"
y2="1"
>
<stop offset="0%" stopColor={color} stopOpacity={0.8} />
<stop offset={'100%'} stopColor={color} stopOpacity={0.1} />
</linearGradient>
</defs>
);
})}
{series.map((serie) => {
const color = getChartColor(serie.index);
return (
<Area
key={serie.id}
stackId="1"
type={lineType}
name={serie.id}
dataKey={`${serie.id}:count`}
strokeDasharray={
useDashedLastLine
? getStrokeDasharray(`${serie.id}:count`)
: undefined
}
fill={`url(#color${color})`}
isAnimationActive={false}
fillOpacity={0.7}
<CartesianGrid
strokeDasharray="3 3"
horizontal={true}
vertical={false}
className="stroke-border"
/>
{references.data?.map((ref) => (
<ReferenceLine
key={ref.id}
x={ref.date.getTime()}
stroke={'oklch(from var(--foreground) l c h / 0.1)'}
strokeDasharray={'3 3'}
label={{
value: ref.title,
position: 'centerTop',
fill: '#334155',
fontSize: 12,
}}
fontSize={10}
/>
);
})}
{previous &&
series.map((serie) => {
))}
<YAxis {...yAxisProps} />
<XAxis {...xAxisProps} />
<Legend content={<CustomLegend />} />
<Tooltip content={<ReportChartTooltip.Tooltip />} />
{series.map((serie) => {
const color = getChartColor(serie.index);
return (
<defs key={`defs-${serie.id}`}>
<linearGradient
id={`color${color}`}
x1="0"
y1="0"
x2="0"
y2="1"
>
<stop offset="0%" stopColor={color} stopOpacity={0.8} />
<stop
offset={'100%'}
stopColor={color}
stopOpacity={0.1}
/>
</linearGradient>
</defs>
);
})}
{series.map((serie) => {
const color = getChartColor(serie.index);
return (
<Area
key={`${serie.id}:prev`}
stackId="2"
key={serie.id}
stackId="1"
type={lineType}
name={`${serie.id}:prev`}
dataKey={`${serie.id}:prev:count`}
name={serie.id}
dataKey={`${serie.id}:count`}
strokeDasharray={
useDashedLastLine
? getStrokeDasharray(`${serie.id}:count`)
: undefined
}
fill={`url(#color${color})`}
stroke={color}
fill={color}
fillOpacity={0.3}
strokeOpacity={0.3}
strokeWidth={2}
isAnimationActive={false}
fillOpacity={0.7}
/>
);
})}
</ComposedChart>
</ResponsiveContainer>
</div>
{isEditMode && (
<ReportTable
data={data}
visibleSeries={series}
setVisibleSeries={setVisibleSeries}
/>
)}
{previous &&
series.map((serie) => {
const color = getChartColor(serie.index);
return (
<Area
key={`${serie.id}:prev`}
stackId="2"
type={lineType}
name={`${serie.id}:prev`}
dataKey={`${serie.id}:prev:count`}
stroke={color}
fill={color}
fillOpacity={0.3}
strokeOpacity={0.3}
isAnimationActive={false}
/>
);
})}
</ComposedChart>
</ResponsiveContainer>
</div>
{isEditMode && (
<ReportTable
data={data}
visibleSeries={series}
setVisibleSeries={setVisibleSeries}
/>
)}
</ChartClickMenu>
</ReportChartTooltip.TooltipProvider>
);

View File

@@ -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,

View File

@@ -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<TableRow | GroupedTableRow>;
virtualRow: VirtualItem;
gridTemplateColumns: string;
pinningStylesMap: Map<string, React.CSSProperties>;
headers: Header<TableRow | GroupedTableRow, unknown>[];
isResizingRef: React.MutableRefObject<boolean>;
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 (
<div
key={virtualRow.key}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: `${virtualRow.size}px`,
transform: `translateY(${virtualRow.start}px)`,
display: 'grid',
gridTemplateColumns,
minWidth: 'fit-content',
}}
className="border-b hover:bg-muted/30 transition-colors"
>
{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 (
<div
key={cell.id}
style={{
width: `${header.getSize()}px`,
minWidth: column.columnDef.minSize,
maxWidth: column.columnDef.maxSize,
...pinningStyles,
}}
className={cn(
'border-r border-border relative overflow-hidden',
isBreakdown && 'border-r-2',
)}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
{canResize && isPinned && (
<div
data-resize-handle
onMouseDown={(e) => {
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',
)}
/>
)}
</div>
);
})}
</div>
);
};
export function ReportTable({
data,
visibleSeries,
@@ -69,12 +174,14 @@ export function ReportTable({
const [sorting, setSorting] = useState<SortingState>([]);
const [globalFilter, setGlobalFilter] = useState('');
const [columnSizing, setColumnSizing] = useState<Record<string, number>>({});
const [resizingColumnId, setResizingColumnId] = useState<string | null>(null);
const isResizingRef = useRef(false);
const parentRef = useRef<HTMLDivElement>(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<string, { min: number; max: number }> = {
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 (
<div className="flex items-center gap-2 px-4 h-12">
<Checkbox
@@ -385,7 +563,14 @@ export function ReportTable({
}}
className="h-4 w-4 shrink-0"
/>
<SerieName name={serieName} className="truncate" />
<SerieName
name={serieName}
className={cn(
'truncate',
isMuted && 'text-muted-foreground/50',
isFirstRowInGroup && 'font-semibold',
)}
/>
</div>
);
},
@@ -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 (
<span
className={cn(
'truncate block leading-[48px] px-4',
!value && 'text-muted-foreground',
isSummary && 'font-semibold',
(!value || isMuted) && 'text-muted-foreground/50',
(isSummary || shouldBeBold) && 'font-semibold',
)}
>
{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<typeof col> => 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<string, React.CSSProperties>();
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<typeof table.getColumn> | 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 (
<div
key={virtualRow.key}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: `${virtualRow.size}px`,
transform: `translateY(${
virtualRow.start - virtualizer.options.scrollMargin
}px)`,
display: 'grid',
gridTemplateColumns:
table
.getHeaderGroups()[0]
?.headers.map((h) => `${h.getSize()}px`)
.join(' ') ?? '',
minWidth: 'fit-content',
<VirtualRow
key={`${virtualRow.key}-${gridTemplateColumns}`}
row={tableRow}
virtualRow={{
...virtualRow,
start: virtualRow.start - virtualizer.options.scrollMargin,
}}
className="border-b hover:bg-muted/30 transition-colors"
>
{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 (
<div
key={cell.id}
style={{
width: `${header.getSize()}px`,
minWidth: column.columnDef.minSize,
maxWidth: column.columnDef.maxSize,
...pinningStyles,
}}
className={cn(
'border-r border-border relative overflow-hidden',
isBreakdown && 'border-r-2',
)}
>
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
{canResize && isPinned && (
<div
data-resize-handle
onMouseDown={(e) => {
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',
)}
/>
)}
</div>
);
})}
</div>
gridTemplateColumns={gridTemplateColumns}
pinningStylesMap={pinningStylesMap}
headers={headers}
isResizingRef={isResizingRef}
resizingColumnId={resizingColumnId}
setResizingColumnId={setResizingColumnId}
/>
);
})}
</div>

View File

@@ -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<string, number> = {};
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<string, number> = {};
@@ -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,
};
});

View File

@@ -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 --');