fix report table
This commit is contained in:
@@ -249,7 +249,11 @@ export function Chart({ data }: Props) {
|
|||||||
y2="1"
|
y2="1"
|
||||||
>
|
>
|
||||||
<stop offset="0%" stopColor={color} stopOpacity={0.8} />
|
<stop offset="0%" stopColor={color} stopOpacity={0.8} />
|
||||||
<stop offset={'100%'} stopColor={color} stopOpacity={0.1} />
|
<stop
|
||||||
|
offset={'100%'}
|
||||||
|
stopColor={color}
|
||||||
|
stopOpacity={0.1}
|
||||||
|
/>
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
</defs>
|
</defs>
|
||||||
);
|
);
|
||||||
@@ -269,6 +273,8 @@ export function Chart({ data }: Props) {
|
|||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
fill={`url(#color${color})`}
|
fill={`url(#color${color})`}
|
||||||
|
stroke={color}
|
||||||
|
strokeWidth={2}
|
||||||
isAnimationActive={false}
|
isAnimationActive={false}
|
||||||
fillOpacity={0.7}
|
fillOpacity={0.7}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ export type TableRow = {
|
|||||||
id: string;
|
id: string;
|
||||||
serieName: string;
|
serieName: string;
|
||||||
breakdownValues: string[];
|
breakdownValues: string[];
|
||||||
|
count: number;
|
||||||
sum: number;
|
sum: number;
|
||||||
average: number;
|
average: number;
|
||||||
min: number;
|
min: number;
|
||||||
@@ -75,6 +76,7 @@ export function createFlatRows(
|
|||||||
id: serie.id,
|
id: serie.id,
|
||||||
serieName: serie.names[0] ?? '',
|
serieName: serie.names[0] ?? '',
|
||||||
breakdownValues: serie.names.slice(1),
|
breakdownValues: serie.names.slice(1),
|
||||||
|
count: serie.metrics.count ?? 0,
|
||||||
sum: serie.metrics.sum,
|
sum: serie.metrics.sum,
|
||||||
average: serie.metrics.average,
|
average: serie.metrics.average,
|
||||||
min: serie.metrics.min,
|
min: serie.metrics.min,
|
||||||
@@ -144,25 +146,16 @@ export function createGroupedRows(
|
|||||||
// For each row in the group
|
// For each row in the group
|
||||||
groupRows.forEach((row, index) => {
|
groupRows.forEach((row, index) => {
|
||||||
const breakdownDisplay: (string | null)[] = [];
|
const breakdownDisplay: (string | null)[] = [];
|
||||||
|
const firstRow = groupRows[0]!;
|
||||||
|
|
||||||
if (index === 0) {
|
if (index === 0) {
|
||||||
// First row shows all breakdown values
|
// First row shows all breakdown values
|
||||||
breakdownDisplay.push(...row.breakdownValues);
|
breakdownDisplay.push(...row.breakdownValues);
|
||||||
} else {
|
} else {
|
||||||
// Subsequent rows: compare with first row in group
|
// Subsequent rows: show all values, but mark duplicates for muted styling
|
||||||
const firstRow = groupRows[0]!;
|
|
||||||
|
|
||||||
for (let i = 0; i < row.breakdownValues.length; i++) {
|
for (let i = 0; i < row.breakdownValues.length; i++) {
|
||||||
// Show empty if this breakdown matches the first row at this position
|
// Always show the value, even if it matches the first row
|
||||||
if (i < firstRow.breakdownValues.length) {
|
breakdownDisplay.push(row.breakdownValues[i] ?? null);
|
||||||
if (row.breakdownValues[i] === firstRow.breakdownValues[i]) {
|
|
||||||
breakdownDisplay.push(null);
|
|
||||||
} else {
|
|
||||||
breakdownDisplay.push(row.breakdownValues[i]!);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
breakdownDisplay.push(row.breakdownValues[i]!);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -187,6 +180,7 @@ export function createSummaryRow(
|
|||||||
): GroupedTableRow {
|
): GroupedTableRow {
|
||||||
// Aggregate metrics from all rows in the group
|
// Aggregate metrics from all rows in the group
|
||||||
const totalSum = groupRows.reduce((sum, row) => sum + row.sum, 0);
|
const totalSum = groupRows.reduce((sum, row) => sum + row.sum, 0);
|
||||||
|
const totalCount = groupRows.reduce((sum, row) => sum + row.count, 0);
|
||||||
const totalAverage =
|
const totalAverage =
|
||||||
groupRows.reduce((sum, row) => sum + row.average, 0) / groupRows.length;
|
groupRows.reduce((sum, row) => sum + row.average, 0) / groupRows.length;
|
||||||
const totalMin = Math.min(...groupRows.map((row) => row.min));
|
const totalMin = Math.min(...groupRows.map((row) => row.min));
|
||||||
@@ -215,6 +209,7 @@ export function createSummaryRow(
|
|||||||
id: `summary-${groupKey}`,
|
id: `summary-${groupKey}`,
|
||||||
serieName: firstRow.serieName,
|
serieName: firstRow.serieName,
|
||||||
breakdownValues: firstRow.breakdownValues,
|
breakdownValues: firstRow.breakdownValues,
|
||||||
|
count: totalCount,
|
||||||
sum: totalSum,
|
sum: totalSum,
|
||||||
average: totalAverage,
|
average: totalAverage,
|
||||||
min: totalMin,
|
min: totalMin,
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import { useSelector } from '@/redux';
|
|||||||
import type { IChartData } from '@/trpc/client';
|
import type { IChartData } from '@/trpc/client';
|
||||||
import { cn } from '@/utils/cn';
|
import { cn } from '@/utils/cn';
|
||||||
import { getChartColor } from '@/utils/theme';
|
import { getChartColor } from '@/utils/theme';
|
||||||
import type { ColumnDef } from '@tanstack/react-table';
|
import type { ColumnDef, Header, Row } from '@tanstack/react-table';
|
||||||
import {
|
import {
|
||||||
type SortingState,
|
type SortingState,
|
||||||
flexRender,
|
flexRender,
|
||||||
@@ -28,7 +28,7 @@ import {
|
|||||||
} from '@tanstack/react-virtual';
|
} from '@tanstack/react-virtual';
|
||||||
import throttle from 'lodash.throttle';
|
import throttle from 'lodash.throttle';
|
||||||
import { ChevronDown, ChevronRight } from 'lucide-react';
|
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 type * as React from 'react';
|
||||||
|
|
||||||
import { ReportTableToolbar } from './report-table-toolbar';
|
import { ReportTableToolbar } from './report-table-toolbar';
|
||||||
@@ -57,6 +57,111 @@ interface ReportTableProps {
|
|||||||
const DEFAULT_COLUMN_WIDTH = 150;
|
const DEFAULT_COLUMN_WIDTH = 150;
|
||||||
const ROW_HEIGHT = 48; // h-12
|
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({
|
export function ReportTable({
|
||||||
data,
|
data,
|
||||||
visibleSeries,
|
visibleSeries,
|
||||||
@@ -69,12 +174,14 @@ export function ReportTable({
|
|||||||
const [sorting, setSorting] = useState<SortingState>([]);
|
const [sorting, setSorting] = useState<SortingState>([]);
|
||||||
const [globalFilter, setGlobalFilter] = useState('');
|
const [globalFilter, setGlobalFilter] = useState('');
|
||||||
const [columnSizing, setColumnSizing] = useState<Record<string, number>>({});
|
const [columnSizing, setColumnSizing] = useState<Record<string, number>>({});
|
||||||
|
const [resizingColumnId, setResizingColumnId] = useState<string | null>(null);
|
||||||
const isResizingRef = useRef(false);
|
const isResizingRef = useRef(false);
|
||||||
const parentRef = useRef<HTMLDivElement>(null);
|
const parentRef = useRef<HTMLDivElement>(null);
|
||||||
const [scrollMargin, setScrollMargin] = useState(0);
|
const [scrollMargin, setScrollMargin] = useState(0);
|
||||||
const number = useNumber();
|
const number = useNumber();
|
||||||
const interval = useSelector((state) => state.report.interval);
|
const interval = useSelector((state) => state.report.interval);
|
||||||
const breakdowns = useSelector((state) => state.report.breakdowns);
|
const breakdowns = useSelector((state) => state.report.breakdowns);
|
||||||
|
|
||||||
const formatDate = useFormatDateInterval({
|
const formatDate = useFormatDateInterval({
|
||||||
interval,
|
interval,
|
||||||
short: true,
|
short: true,
|
||||||
@@ -153,7 +260,7 @@ export function ReportTable({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Search in metric values
|
// Search in metric values
|
||||||
const metrics = ['sum', 'average', 'min', 'max'] as const;
|
const metrics = ['count', 'sum', 'average', 'min', 'max'] as const;
|
||||||
if (
|
if (
|
||||||
metrics.some((metric) =>
|
metrics.some((metric) =>
|
||||||
String(row[metric]).toLowerCase().includes(searchLower),
|
String(row[metric]).toLowerCase().includes(searchLower),
|
||||||
@@ -253,6 +360,10 @@ export function ReportTable({
|
|||||||
// Calculate min/max values for color visualization
|
// Calculate min/max values for color visualization
|
||||||
const { metricRanges, dateRanges } = useMemo(() => {
|
const { metricRanges, dateRanges } = useMemo(() => {
|
||||||
const metricRanges: Record<string, { min: number; max: number }> = {
|
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 },
|
sum: { min: Number.POSITIVE_INFINITY, max: Number.NEGATIVE_INFINITY },
|
||||||
average: {
|
average: {
|
||||||
min: Number.POSITIVE_INFINITY,
|
min: Number.POSITIVE_INFINITY,
|
||||||
@@ -270,6 +381,36 @@ export function ReportTable({
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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) => {
|
rows.forEach((row) => {
|
||||||
// Calculate metric ranges
|
// Calculate metric ranges
|
||||||
Object.keys(metricRanges).forEach((key) => {
|
Object.keys(metricRanges).forEach((key) => {
|
||||||
@@ -293,6 +434,7 @@ export function ReportTable({
|
|||||||
dateRanges[date]!.max = Math.max(dateRanges[date]!.max, value);
|
dateRanges[date]!.max = Math.max(dateRanges[date]!.max, value);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return { metricRanges, dateRanges };
|
return { metricRanges, dateRanges };
|
||||||
}, [rows, dates]);
|
}, [rows, dates]);
|
||||||
@@ -302,6 +444,7 @@ export function ReportTable({
|
|||||||
value: number,
|
value: number,
|
||||||
min: number,
|
min: number,
|
||||||
max: number,
|
max: number,
|
||||||
|
className?: string,
|
||||||
): { opacity: number; className: string } => {
|
): { opacity: number; className: string } => {
|
||||||
if (value === 0 || max === min) {
|
if (value === 0 || max === min) {
|
||||||
return { opacity: 0, className: '' };
|
return { opacity: 0, className: '' };
|
||||||
@@ -312,7 +455,7 @@ export function ReportTable({
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
opacity,
|
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);
|
return (visibleSeries as IChartData['series']).map((s) => s.id);
|
||||||
}, [visibleSeries]);
|
}, [visibleSeries]);
|
||||||
|
|
||||||
|
// Create a hash of visibleSeriesIds to track checkbox state changes
|
||||||
|
const visibleSeriesIdsHash = useMemo(() => {
|
||||||
|
return visibleSeriesIds.sort().join(',');
|
||||||
|
}, [visibleSeriesIds]);
|
||||||
|
|
||||||
// Get serie index for color
|
// Get serie index for color
|
||||||
const getSerieIndex = (serieId: string): number => {
|
const getSerieIndex = (serieId: string): number => {
|
||||||
return data.series.findIndex((s) => s.id === serieId);
|
return data.series.findIndex((s) => s.id === serieId);
|
||||||
@@ -368,12 +516,42 @@ export function ReportTable({
|
|||||||
pinned: 'left',
|
pinned: 'left',
|
||||||
},
|
},
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const serieName = row.original.serieName;
|
const original = row.original;
|
||||||
const serieId = row.original.originalSerie.id;
|
const serieName = original.serieName;
|
||||||
|
const serieId = original.originalSerie.id;
|
||||||
const isVisible = visibleSeriesIds.includes(serieId);
|
const isVisible = visibleSeriesIds.includes(serieId);
|
||||||
const serieIndex = getSerieIndex(serieId);
|
const serieIndex = getSerieIndex(serieId);
|
||||||
const color = getChartColor(serieIndex);
|
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 (
|
return (
|
||||||
<div className="flex items-center gap-2 px-4 h-12">
|
<div className="flex items-center gap-2 px-4 h-12">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
@@ -385,7 +563,14 @@ export function ReportTable({
|
|||||||
}}
|
}}
|
||||||
className="h-4 w-4 shrink-0"
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -460,21 +645,50 @@ export function ReportTable({
|
|||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const original = row.original;
|
const original = row.original;
|
||||||
let value: string | null;
|
let value: string | null;
|
||||||
|
let isMuted = false;
|
||||||
|
let isFirstRowInGroup = false;
|
||||||
|
|
||||||
if ('breakdownDisplay' in original && grouped) {
|
if ('breakdownDisplay' in original && grouped) {
|
||||||
value = original.breakdownDisplay[index] ?? null;
|
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 {
|
} else {
|
||||||
value = original.breakdownValues[index] ?? null;
|
value = original.breakdownValues[index] ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const isSummary = original.isSummaryRow ?? false;
|
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 (
|
return (
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
'truncate block leading-[48px] px-4',
|
'truncate block leading-[48px] px-4',
|
||||||
!value && 'text-muted-foreground',
|
(!value || isMuted) && 'text-muted-foreground/50',
|
||||||
isSummary && 'font-semibold',
|
(isSummary || shouldBeBold) && 'font-semibold',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{value || ''}
|
{value || ''}
|
||||||
@@ -486,6 +700,7 @@ export function ReportTable({
|
|||||||
|
|
||||||
// Metric columns
|
// Metric columns
|
||||||
const metrics = [
|
const metrics = [
|
||||||
|
{ key: 'count', label: 'Unique' },
|
||||||
{ key: 'sum', label: 'Sum' },
|
{ key: 'sum', label: 'Sum' },
|
||||||
{ key: 'average', label: 'Average' },
|
{ key: 'average', label: 'Average' },
|
||||||
{ key: 'min', label: 'Min' },
|
{ key: 'min', label: 'Min' },
|
||||||
@@ -504,7 +719,12 @@ export function ReportTable({
|
|||||||
const isSummary = row.original.isSummaryRow ?? false;
|
const isSummary = row.original.isSummaryRow ?? false;
|
||||||
const range = metricRanges[metric.key];
|
const range = metricRanges[metric.key];
|
||||||
const { opacity, className } = range
|
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: '' };
|
: { opacity: 0, className: '' };
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -582,6 +802,11 @@ export function ReportTable({
|
|||||||
columnSizing,
|
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({
|
const table = useReactTable({
|
||||||
data: filteredRows,
|
data: filteredRows,
|
||||||
columns,
|
columns,
|
||||||
@@ -628,6 +853,7 @@ export function ReportTable({
|
|||||||
// Small delay to ensure resize handlers complete
|
// Small delay to ensure resize handlers complete
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
isResizingRef.current = false;
|
isResizingRef.current = false;
|
||||||
|
setResizingColumnId(null);
|
||||||
}, 100);
|
}, 100);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -665,13 +891,30 @@ export function ReportTable({
|
|||||||
.filter((col) => col.columnDef.meta?.pinned === 'right')
|
.filter((col) => col.columnDef.meta?.pinned === 'right')
|
||||||
.filter((col): col is NonNullable<typeof col> => col !== undefined);
|
.filter((col): col is NonNullable<typeof col> => col !== undefined);
|
||||||
|
|
||||||
// Helper to get pinning styles
|
// Pre-compute grid template columns string and headers
|
||||||
const getPinningStyles = (
|
const { gridTemplateColumns, headers } = useMemo(() => {
|
||||||
column: ReturnType<typeof table.getColumn> | undefined,
|
const headerGroups = table.getHeaderGroups();
|
||||||
) => {
|
const firstGroupHeaders = headerGroups[0]?.headers ?? [];
|
||||||
if (!column) return {};
|
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;
|
const isPinned = column.columnDef.meta?.pinned;
|
||||||
if (!isPinned) return {};
|
if (!isPinned) {
|
||||||
|
stylesMap.set(column.id, {});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const pinnedColumns =
|
const pinnedColumns =
|
||||||
isPinned === 'left' ? leftPinnedColumns : rightPinnedColumns;
|
isPinned === 'left' ? leftPinnedColumns : rightPinnedColumns;
|
||||||
@@ -687,7 +930,7 @@ export function ReportTable({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
stylesMap.set(column.id, {
|
||||||
position: 'sticky' as const,
|
position: 'sticky' as const,
|
||||||
left: isPinned === 'left' ? `${left}px` : undefined,
|
left: isPinned === 'left' ? `${left}px` : undefined,
|
||||||
right: isPinned === 'right' ? '0px' : undefined,
|
right: isPinned === 'right' ? '0px' : undefined,
|
||||||
@@ -698,7 +941,19 @@ export function ReportTable({
|
|||||||
: isFirstRightPinned
|
: isFirstRightPinned
|
||||||
? '4px 0 4px -4px var(--border) inset'
|
? '4px 0 4px -4px var(--border) inset'
|
||||||
: undefined,
|
: 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 {};
|
||||||
|
return pinningStylesMap.get(column.id) ?? {};
|
||||||
};
|
};
|
||||||
|
|
||||||
if (rows.length === 0) {
|
if (rows.length === 0) {
|
||||||
@@ -721,11 +976,7 @@ export function ReportTable({
|
|||||||
className="sticky top-0 z-20 bg-card border-b"
|
className="sticky top-0 z-20 bg-card border-b"
|
||||||
style={{
|
style={{
|
||||||
display: 'grid',
|
display: 'grid',
|
||||||
gridTemplateColumns:
|
gridTemplateColumns,
|
||||||
table
|
|
||||||
.getHeaderGroups()[0]
|
|
||||||
?.headers.map((h) => `${h.getSize()}px`)
|
|
||||||
.join(' ') ?? '',
|
|
||||||
minWidth: 'fit-content',
|
minWidth: 'fit-content',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -809,22 +1060,26 @@ export function ReportTable({
|
|||||||
onMouseDown={(e) => {
|
onMouseDown={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
isResizingRef.current = true;
|
isResizingRef.current = true;
|
||||||
|
setResizingColumnId(column.id);
|
||||||
header.getResizeHandler()(e);
|
header.getResizeHandler()(e);
|
||||||
}}
|
}}
|
||||||
onMouseUp={() => {
|
onMouseUp={() => {
|
||||||
// Use setTimeout to allow the resize to complete before resetting
|
// Use setTimeout to allow the resize to complete before resetting
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
isResizingRef.current = false;
|
isResizingRef.current = false;
|
||||||
|
setResizingColumnId(null);
|
||||||
}, 0);
|
}, 0);
|
||||||
}}
|
}}
|
||||||
onTouchStart={(e) => {
|
onTouchStart={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
isResizingRef.current = true;
|
isResizingRef.current = true;
|
||||||
|
setResizingColumnId(column.id);
|
||||||
header.getResizeHandler()(e);
|
header.getResizeHandler()(e);
|
||||||
}}
|
}}
|
||||||
onTouchEnd={() => {
|
onTouchEnd={() => {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
isResizingRef.current = false;
|
isResizingRef.current = false;
|
||||||
|
setResizingColumnId(null);
|
||||||
}, 0);
|
}, 0);
|
||||||
}}
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -850,95 +1105,20 @@ export function ReportTable({
|
|||||||
if (!tableRow) return null;
|
if (!tableRow) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<VirtualRow
|
||||||
key={virtualRow.key}
|
key={`${virtualRow.key}-${gridTemplateColumns}`}
|
||||||
style={{
|
row={tableRow}
|
||||||
position: 'absolute',
|
virtualRow={{
|
||||||
top: 0,
|
...virtualRow,
|
||||||
left: 0,
|
start: virtualRow.start - virtualizer.options.scrollMargin,
|
||||||
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',
|
|
||||||
}}
|
}}
|
||||||
className="border-b hover:bg-muted/30 transition-colors"
|
gridTemplateColumns={gridTemplateColumns}
|
||||||
>
|
pinningStylesMap={pinningStylesMap}
|
||||||
{table.getHeaderGroups()[0]?.headers.map((header) => {
|
headers={headers}
|
||||||
const column = header.column;
|
isResizingRef={isResizingRef}
|
||||||
const cell = tableRow
|
resizingColumnId={resizingColumnId}
|
||||||
.getVisibleCells()
|
setResizingColumnId={setResizingColumnId}
|
||||||
.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>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -72,6 +72,58 @@ export function compute(
|
|||||||
(a, b) => new Date(a).getTime() - new Date(b).getTime(),
|
(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
|
// Calculate formula for each date
|
||||||
const formulaData = sortedDates.map((date) => {
|
const formulaData = sortedDates.map((date) => {
|
||||||
const scope: Record<string, number> = {};
|
const scope: Record<string, number> = {};
|
||||||
@@ -124,8 +176,7 @@ export function compute(
|
|||||||
Number.isNaN(count) || !Number.isFinite(count)
|
Number.isNaN(count) || !Number.isFinite(count)
|
||||||
? 0
|
? 0
|
||||||
: round(count, 2),
|
: round(count, 2),
|
||||||
total_count: breakdownSeries[0]?.data.find((d) => d.date === date)
|
total_count: formulaTotalCount,
|
||||||
?.total_count,
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -231,6 +231,52 @@ export function getChartSql({
|
|||||||
return sql;
|
return sql;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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()}
|
||||||
|
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 = `(
|
const totalUniqueSubquery = `(
|
||||||
SELECT ${sb.select.count}
|
SELECT ${sb.select.count}
|
||||||
FROM ${sb.from}
|
FROM ${sb.from}
|
||||||
@@ -238,6 +284,7 @@ export function getChartSql({
|
|||||||
${getWhere()}
|
${getWhere()}
|
||||||
)`;
|
)`;
|
||||||
sb.select.total_unique_count = `${totalUniqueSubquery} as total_count`;
|
sb.select.total_unique_count = `${totalUniqueSubquery} as total_count`;
|
||||||
|
}
|
||||||
|
|
||||||
const sql = `${getSelect()} ${getFrom()} ${getJoins()} ${getWhere()} ${getGroupBy()} ${getOrderBy()} ${getFill()}`;
|
const sql = `${getSelect()} ${getFrom()} ${getJoins()} ${getWhere()} ${getGroupBy()} ${getOrderBy()} ${getFill()}`;
|
||||||
console.log('-- Report --');
|
console.log('-- Report --');
|
||||||
|
|||||||
Reference in New Issue
Block a user