This commit is contained in:
Carl-Gerhard Lindesvärd
2025-11-25 09:18:48 +01:00
parent d99335e2f4
commit 3bbeb927cc
3 changed files with 501 additions and 301 deletions

View File

@@ -3,6 +3,7 @@ import type { IChartData } from '@/trpc/client';
export type TableRow = { export type TableRow = {
id: string; id: string;
serieId: string; // Serie ID for visibility/color lookup
serieName: string; serieName: string;
breakdownValues: string[]; breakdownValues: string[];
count: number; count: number;
@@ -11,11 +12,10 @@ export type TableRow = {
min: number; min: number;
max: number; max: number;
dateValues: Record<string, number>; // date -> count dateValues: Record<string, number>; // date -> count
originalSerie: IChartData['series'][0]; // Group metadata
// Group metadata for collapse functionality groupKey?: string;
groupKey?: string; // Unique key for the group this row belongs to parentGroupKey?: string;
parentGroupKey?: string; // Key of parent group (for nested groups) isSummaryRow?: boolean;
isSummaryRow?: boolean; // True if this is a summary row for a collapsed group
}; };
export type GroupedTableRow = TableRow & { export type GroupedTableRow = TableRow & {
@@ -166,7 +166,14 @@ export function findGroup<T>(
/** /**
* Convert hierarchical groups to TanStack Table's expandable row format * Convert hierarchical groups to TanStack Table's expandable row format
* This creates rows with subRows that TanStack Table can expand/collapse natively *
* Transforms nested GroupedItem structure into flat ExpandableTableRow array
* that TanStack Table can use with its native expanding feature.
*
* Key behaviors:
* - Serie level (level -1) and breakdown levels 0 to breakdownCount-2 create group headers
* - Last breakdown level (breakdownCount-1) does NOT create group headers (always individual rows)
* - Individual rows are explicitly marked as NOT group headers or summary rows
*/ */
export function groupsToExpandableRows( export function groupsToExpandableRows(
groups: Array<GroupedItem<TableRow>>, groups: Array<GroupedItem<TableRow>>,
@@ -181,37 +188,35 @@ export function groupsToExpandableRows(
const currentPath = [...parentPath, group.group]; const currentPath = [...parentPath, group.group];
const subRows: ExpandableTableRow[] = []; const subRows: ExpandableTableRow[] = [];
// Separate nested groups from actual items // Separate nested groups from individual data items
const nestedGroups: GroupedItem<TableRow>[] = []; const nestedGroups: GroupedItem<TableRow>[] = [];
const actualItems: TableRow[] = []; const individualItems: TableRow[] = [];
for (const item of group.items) { for (const item of group.items) {
if (item && typeof item === 'object' && 'items' in item) { if (item && typeof item === 'object' && 'items' in item) {
nestedGroups.push(item); nestedGroups.push(item);
} else if (item) { } else if (item) {
actualItems.push(item); individualItems.push(item);
} }
} }
// Process nested groups (they become subRows) // Process nested groups recursively (they become expandable group headers)
for (const nestedGroup of nestedGroups) { for (const nestedGroup of nestedGroups) {
subRows.push(...processGroup(nestedGroup, currentPath)); subRows.push(...processGroup(nestedGroup, currentPath));
} }
// Process actual items // Process individual data items (leaf nodes)
actualItems.forEach((item, index) => { individualItems.forEach((item, index) => {
// Build breakdownDisplay: first row shows all values, subsequent rows show parent path + item values
const breakdownDisplay: (string | null)[] = []; const breakdownDisplay: (string | null)[] = [];
const breakdownValues = item.breakdownValues; const breakdownValues = item.breakdownValues;
// Build breakdownDisplay based on hierarchy for (let i = 0; i < breakdownCount; i++) {
if (index === 0) { if (index === 0) {
// First row shows all breakdown values // First row: show all breakdown values
for (let i = 0; i < breakdownCount; i++) {
breakdownDisplay.push(breakdownValues[i] ?? null); breakdownDisplay.push(breakdownValues[i] ?? null);
} } else {
} else { // Subsequent rows: show parent path values, then item values
// Subsequent rows: show values from parent path, then item values
for (let i = 0; i < breakdownCount; i++) {
if (i < currentPath.length) { if (i < currentPath.length) {
breakdownDisplay.push(currentPath[i] ?? null); breakdownDisplay.push(currentPath[i] ?? null);
} else if (i < breakdownValues.length) { } else if (i < breakdownValues.length) {
@@ -227,18 +232,20 @@ export function groupsToExpandableRows(
breakdownDisplay, breakdownDisplay,
groupKey: group.groupKey, groupKey: group.groupKey,
parentGroupKey: group.parentGroupKey, parentGroupKey: group.parentGroupKey,
// Explicitly mark as NOT a group header or summary row
isGroupHeader: false, isGroupHeader: false,
isSummaryRow: false, isSummaryRow: false,
}); });
}); });
// If this group has subRows and is not the last breakdown level, create a group header row // If this group has subRows and is not the last breakdown level, create a group header row
// Don't create group headers for the last breakdown level (level === breakdownCount) // Don't create group headers for the last breakdown level (level === breakdownCount - 1)
// because it would just duplicate the rows // because the last breakdown should always be individual rows
// -1 is serie level (should be grouped)
// 0 to breakdownCount-2 are breakdown levels (should be grouped)
// breakdownCount-1 is the last breakdown level (should NOT be grouped, always individual)
const shouldCreateGroupHeader = const shouldCreateGroupHeader =
subRows.length > 0 && subRows.length > 0 &&
(group.level < breakdownCount || group.level === -1); // -1 is serie level (group.level === -1 || group.level < breakdownCount - 1);
if (shouldCreateGroupHeader) { if (shouldCreateGroupHeader) {
// Create a summary row for the group // Create a summary row for the group
@@ -416,6 +423,7 @@ export function createFlatRows(
return { return {
id: serie.id, id: serie.id,
serieId: 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, count: serie.metrics.count ?? 0,
@@ -424,7 +432,6 @@ export function createFlatRows(
min: serie.metrics.min, min: serie.metrics.min,
max: serie.metrics.max, max: serie.metrics.max,
dateValues, dateValues,
originalSerie: serie,
}; };
}); });
} }
@@ -451,10 +458,11 @@ export function createGroupedRowsHierarchical(
} }
// Create hierarchical groups using groupByNames // Create hierarchical groups using groupByNames
// Group by serie name first, then by breakdown values // Note: groupByNames expects items with a `names` array, so we create a temporary array
// This is a minor inefficiency but keeps groupByNames generic and reusable
const itemsWithNames = flatRows.map((row) => ({ const itemsWithNames = flatRows.map((row) => ({
...row, ...row,
names: [row.serieName, ...row.breakdownValues], // Serie name + breakdown values names: [row.serieName, ...row.breakdownValues],
})); }));
return groupByNames(itemsWithNames); return groupByNames(itemsWithNames);
@@ -552,6 +560,8 @@ export function createSummaryRow(
groupKey: string, groupKey: string,
breakdownCount: number, breakdownCount: number,
): GroupedTableRow { ): GroupedTableRow {
const firstRow = groupRows[0]!;
// 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 totalCount = groupRows.reduce((sum, row) => sum + row.count, 0);
@@ -560,27 +570,23 @@ export function createSummaryRow(
const totalMin = Math.min(...groupRows.map((row) => row.min)); const totalMin = Math.min(...groupRows.map((row) => row.min));
const totalMax = Math.max(...groupRows.map((row) => row.max)); const totalMax = Math.max(...groupRows.map((row) => row.max));
// Aggregate date values // Aggregate date values across all rows
const dateValues: Record<string, number> = {}; const dateValues: Record<string, number> = {};
const allDates = new Set<string>();
groupRows.forEach((row) => { groupRows.forEach((row) => {
Object.keys(row.dateValues).forEach((date) => { Object.keys(row.dateValues).forEach((date) => {
allDates.add(date);
dateValues[date] = (dateValues[date] ?? 0) + row.dateValues[date]; dateValues[date] = (dateValues[date] ?? 0) + row.dateValues[date];
}); });
}); });
// Get breakdown values from first row // Build breakdownDisplay: show first breakdown value, rest are null
const firstRow = groupRows[0]!; const breakdownDisplay: (string | null)[] = [
const breakdownDisplay: (string | null)[] = []; firstRow.breakdownValues[0] ?? null,
breakdownDisplay.push(firstRow.breakdownValues[0] ?? null); ...Array(breakdownCount - 1).fill(null),
// Fill remaining breakdowns with null (empty) ];
for (let i = 1; i < breakdownCount; i++) {
breakdownDisplay.push(null);
}
return { return {
id: `summary-${groupKey}`, id: `summary-${groupKey}`,
serieId: firstRow.serieId,
serieName: firstRow.serieName, serieName: firstRow.serieName,
breakdownValues: firstRow.breakdownValues, breakdownValues: firstRow.breakdownValues,
count: totalCount, count: totalCount,
@@ -589,7 +595,6 @@ export function createSummaryRow(
min: totalMin, min: totalMin,
max: totalMax, max: totalMax,
dateValues, dateValues,
originalSerie: firstRow.originalSerie,
groupKey, groupKey,
isSummaryRow: true, isSummaryRow: true,
breakdownDisplay, breakdownDisplay,

View File

@@ -18,6 +18,7 @@ import {
} from '@tanstack/react-table'; } from '@tanstack/react-table';
import { import {
type VirtualItem, type VirtualItem,
useVirtualizer,
useWindowVirtualizer, useWindowVirtualizer,
} from '@tanstack/react-virtual'; } from '@tanstack/react-virtual';
import throttle from 'lodash.throttle'; import throttle from 'lodash.throttle';
@@ -25,6 +26,7 @@ import { ChevronDown, ChevronRight } from 'lucide-react';
import type * as React from 'react'; import type * as React from 'react';
import { useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { Tooltiper } from '@/components/ui/tooltip';
import { ReportTableToolbar } from './report-table-toolbar'; import { ReportTableToolbar } from './report-table-toolbar';
import { import {
type ExpandableTableRow, type ExpandableTableRow,
@@ -58,26 +60,101 @@ const ROW_HEIGHT = 48; // h-12
interface VirtualRowProps { interface VirtualRowProps {
row: Row<TableRow | GroupedTableRow>; row: Row<TableRow | GroupedTableRow>;
virtualRow: VirtualItem; virtualRow: VirtualItem;
gridTemplateColumns: string;
pinningStylesMap: Map<string, React.CSSProperties>; pinningStylesMap: Map<string, React.CSSProperties>;
headers: Header<TableRow | GroupedTableRow, unknown>[]; headers: Header<TableRow | GroupedTableRow, unknown>[];
isResizingRef: React.MutableRefObject<boolean>; isResizingRef: React.MutableRefObject<boolean>;
resizingColumnId: string | null; resizingColumnId: string | null;
setResizingColumnId: (id: string | null) => void; setResizingColumnId: (id: string | null) => void;
// Horizontal virtualization props
leftPinnedColumns: Header<TableRow | GroupedTableRow, unknown>['column'][];
scrollableColumns: Header<TableRow | GroupedTableRow, unknown>['column'][];
rightPinnedColumns: Header<TableRow | GroupedTableRow, unknown>['column'][];
virtualColumns: VirtualItem[];
leftPinnedWidth: number;
scrollableColumnsTotalWidth: number;
rightPinnedWidth: number;
} }
const VirtualRow = function VirtualRow({ const VirtualRow = function VirtualRow({
row, row,
virtualRow, virtualRow,
gridTemplateColumns,
pinningStylesMap, pinningStylesMap,
headers, headers,
isResizingRef, isResizingRef,
resizingColumnId, resizingColumnId,
setResizingColumnId, setResizingColumnId,
leftPinnedColumns,
scrollableColumns,
rightPinnedColumns,
virtualColumns,
leftPinnedWidth,
scrollableColumnsTotalWidth,
rightPinnedWidth,
}: VirtualRowProps) { }: VirtualRowProps) {
const cells = row.getVisibleCells(); const cells = row.getVisibleCells();
const renderCell = (
column: Header<TableRow | GroupedTableRow, unknown>['column'],
header: Header<TableRow | GroupedTableRow, unknown> | undefined,
) => {
const cell = cells.find((c) => c.column.id === column.id);
if (!cell || !header) 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('relative overflow-hidden border-r')}
>
{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>
);
};
return ( return (
<div <div
key={virtualRow.key} key={virtualRow.key}
@@ -85,76 +162,56 @@ const VirtualRow = function VirtualRow({
position: 'absolute', position: 'absolute',
top: 0, top: 0,
left: 0, left: 0,
width: '100%', width: leftPinnedWidth + scrollableColumnsTotalWidth + rightPinnedWidth,
height: `${virtualRow.size}px`, height: `${virtualRow.size}px`,
transform: `translateY(${virtualRow.start}px)`, transform: `translateY(${virtualRow.start}px)`,
display: 'grid', display: 'flex',
gridTemplateColumns,
minWidth: 'fit-content', minWidth: 'fit-content',
}} }}
className="border-b hover:bg-muted/30 transition-colors" className="border-b hover:bg-muted/30 transition-colors"
> >
{headers.map((header) => { {/* Left Pinned Columns */}
const column = header.column; {leftPinnedColumns.map((column) => {
const cell = cells.find((c) => c.column.id === column.id); const header = headers.find((h) => h.column.id === column.id);
if (!cell) return null; return renderCell(column, header);
})}
const isBreakdown = column.columnDef.meta?.isBreakdown ?? false; {/* Scrollable Columns (Virtualized) */}
const pinningStyles = pinningStylesMap.get(column.id) ?? {}; <div
const canResize = column.getCanResize(); style={{
const isPinned = column.columnDef.meta?.pinned === 'left'; position: 'relative',
const isResizing = resizingColumnId === column.id; width: scrollableColumnsTotalWidth,
height: `${virtualRow.size}px`,
}}
>
{virtualColumns.map((virtualCol) => {
const column = scrollableColumns[virtualCol.index];
if (!column) return null;
const header = headers.find((h) => h.column.id === column.id);
const cell = cells.find((c) => c.column.id === column.id);
if (!cell || !header) return null;
return ( return (
<div <div
key={cell.id} key={cell.id}
style={{ style={{
width: `${header.getSize()}px`, position: 'absolute',
minWidth: column.columnDef.minSize, left: `${virtualCol.start}px`,
maxWidth: column.columnDef.maxSize, width: `${virtualCol.size}px`,
...pinningStyles, height: `${virtualRow.size}px`,
}} }}
className={cn( className={cn('relative overflow-hidden')}
'border-r border-border relative overflow-hidden', >
isBreakdown && 'border-r-2', {flexRender(cell.column.columnDef.cell, cell.getContext())}
)} </div>
> );
{flexRender(cell.column.columnDef.cell, cell.getContext())} })}
{canResize && isPinned && ( </div>
<div
data-resize-handle {/* Right Pinned Columns */}
onMouseDown={(e) => { {rightPinnedColumns.map((column) => {
e.stopPropagation(); const header = headers.find((h) => h.column.id === column.id);
isResizingRef.current = true; return renderCell(column, header);
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> </div>
); );
@@ -165,7 +222,7 @@ export function ReportTable({
visibleSeries, visibleSeries,
setVisibleSeries, setVisibleSeries,
}: ReportTableProps) { }: ReportTableProps) {
const [grouped, setGrouped] = useState(true); const [grouped, setGrouped] = useState(false);
const [expanded, setExpanded] = useState<ExpandedState>({}); const [expanded, setExpanded] = useState<ExpandedState>({});
const [sorting, setSorting] = useState<SortingState>([]); const [sorting, setSorting] = useState<SortingState>([]);
const [globalFilter, setGlobalFilter] = useState(''); const [globalFilter, setGlobalFilter] = useState('');
@@ -501,7 +558,6 @@ export function ReportTable({
// Multiple series: calculate ranges across individual rows only // Multiple series: calculate ranges across individual rows only
if (individualRows.length === 0) { if (individualRows.length === 0) {
// No individual rows found - this shouldn't happen, but handle gracefully // No individual rows found - this shouldn't happen, but handle gracefully
console.warn('No individual rows found for range calculation');
} else { } else {
individualRows.forEach((row) => { individualRows.forEach((row) => {
// Calculate metric ranges // Calculate metric ranges
@@ -534,31 +590,36 @@ export function ReportTable({
return { metricRanges, dateRanges }; return { metricRanges, dateRanges };
}, [rows, dates]); }, [rows, dates]);
// Helper to get background color and opacity for a value // Helper to get background color style and opacity for a value
const getCellBackground = ( // Returns both style and opacity (for text color calculation) to avoid parsing
const getCellBackgroundStyle = (
value: number, value: number,
min: number, min: number,
max: number, max: number,
className?: string, colorClass: 'purple' | 'emerald' = 'emerald',
): { opacity: number; className: string } => { ): { style: React.CSSProperties; opacity: number } => {
if (value === 0) { if (value === 0) {
return { opacity: 0, className: '' }; return { style: {}, opacity: 0 };
} }
// If min equals max (e.g. single row or all values same), show moderate opacity // If min equals max (e.g. single row or all values same), show moderate opacity
let opacity: number;
if (max === min) { if (max === min) {
return { opacity = 0.5;
opacity: 0.5, } else {
className: cn('bg-highlight dark:bg-emerald-700', className), const percentage = (value - min) / (max - min);
}; opacity = Math.max(0.05, Math.min(1, percentage));
} }
const percentage = (value - min) / (max - min); // Use rgba colors directly instead of opacity + background class
const opacity = Math.max(0.05, Math.min(1, percentage)); const backgroundColor =
colorClass === 'purple'
? `rgba(168, 85, 247, ${opacity})` // purple-500
: `rgba(16, 185, 129, ${opacity})`; // emerald-500
return { return {
style: { backgroundColor },
opacity, opacity,
className: cn('bg-highlight dark:bg-emerald-700', className),
}; };
}; };
@@ -616,7 +677,7 @@ export function ReportTable({
cell: ({ row }) => { cell: ({ row }) => {
const original = row.original; const original = row.original;
const serieName = original.serieName; const serieName = original.serieName;
const serieId = original.originalSerie.id; const serieId = original.serieId;
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);
@@ -645,10 +706,7 @@ export function ReportTable({
if (firstRowInGroup.id === original.id) { if (firstRowInGroup.id === original.id) {
isFirstRowInGroup = true; isFirstRowInGroup = true;
} else { } else {
// Only mute if this is not the first row and the serie name matches isMuted = true;
if (firstRowInGroup.serieName === serieName) {
isMuted = true;
}
} }
} }
} }
@@ -656,7 +714,6 @@ export function ReportTable({
const originalRow = row.original as ExpandableTableRow | TableRow; const originalRow = row.original as ExpandableTableRow | TableRow;
const isGroupHeader = const isGroupHeader =
'isGroupHeader' in originalRow && originalRow.isGroupHeader === true; 'isGroupHeader' in originalRow && originalRow.isGroupHeader === true;
const canExpand = grouped ? (row.getCanExpand?.() ?? false) : false;
const isExpanded = grouped ? (row.getIsExpanded?.() ?? false) : false; const isExpanded = grouped ? (row.getIsExpanded?.() ?? false) : false;
const isSerieGroupHeader = const isSerieGroupHeader =
isGroupHeader && isGroupHeader &&
@@ -664,10 +721,28 @@ export function ReportTable({
originalRow.groupLevel === -1; originalRow.groupLevel === -1;
const hasSubRows = const hasSubRows =
'subRows' in originalRow && (originalRow.subRows?.length ?? 0) > 0; 'subRows' in originalRow && (originalRow.subRows?.length ?? 0) > 0;
const isExpandable = grouped && isSerieGroupHeader && hasSubRows;
return ( return (
<div className="flex items-center gap-2 px-4 h-12"> <div className="flex items-center gap-2 px-4 h-12">
{grouped && isSerieGroupHeader && hasSubRows && ( <Checkbox
checked={isVisible}
onCheckedChange={() => toggleSerieVisibility(serieId)}
style={{
borderColor: color,
backgroundColor: isVisible ? color : 'transparent',
}}
className="h-4 w-4 shrink-0"
/>
<SerieName
name={serieName}
className={cn(
'truncate',
!isExpandable && grouped && 'text-muted-foreground/40',
isExpandable && 'font-semibold',
)}
/>
{isExpandable && (
<button <button
type="button" type="button"
onClick={(e) => { onClick={(e) => {
@@ -691,23 +766,6 @@ export function ReportTable({
)} )}
</button> </button>
)} )}
<Checkbox
checked={isVisible}
onCheckedChange={() => toggleSerieVisibility(serieId)}
style={{
borderColor: color,
backgroundColor: isVisible ? color : 'transparent',
}}
className="h-4 w-4 shrink-0"
/>
<SerieName
name={serieName}
className={cn(
'truncate',
isMuted && 'text-muted-foreground/50',
(isFirstRowInGroup || isGroupHeader) && 'font-semibold',
)}
/>
</div> </div>
); );
}, },
@@ -806,11 +864,6 @@ export function ReportTable({
role="button" role="button"
tabIndex={0} tabIndex={0}
> >
{allExpanded ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
<span>{propertyName}</span> <span>{propertyName}</span>
</div> </div>
); );
@@ -818,7 +871,6 @@ export function ReportTable({
meta: { meta: {
pinned: 'left', pinned: 'left',
isBreakdown: true, isBreakdown: true,
breakdownIndex: index,
}, },
cell: ({ row }) => { cell: ({ row }) => {
const original = row.original as ExpandableTableRow | TableRow; const original = row.original as ExpandableTableRow | TableRow;
@@ -827,82 +879,30 @@ export function ReportTable({
const canExpand = row.getCanExpand?.() ?? false; const canExpand = row.getCanExpand?.() ?? false;
const isExpanded = row.getIsExpanded?.() ?? false; const isExpanded = row.getIsExpanded?.() ?? false;
let value: string | null; const value: string | number | null =
let isMuted = false; original.breakdownValues[index] ?? null;
let isFirstRowInGroup = false; const isLastBreakdown = index === breakdownPropertyNames.length - 1;
const isMuted = (!isLastBreakdown && !canExpand && grouped) || !value;
if ( // For group headers, only show value at the group level, hide deeper breakdowns
'breakdownDisplay' in original && if (isGroupHeader && 'groupLevel' in original) {
grouped && const groupLevel = original.groupLevel ?? 0;
original.breakdownDisplay !== undefined if (index !== groupLevel) {
) { return <div className="flex items-center gap-2 px-4 h-12" />;
value = original.breakdownDisplay[index] ?? null;
// For group headers, show the group value at the appropriate level
if (isGroupHeader && 'groupLevel' in original) {
const groupLevel = original.groupLevel ?? 0;
if (index === groupLevel) {
value = original.groupValue ?? null;
} else if (index < groupLevel) {
// Show parent group values from the path
// This would need to be calculated from the hierarchy
value = null; // Will be handled by breakdownDisplay
} else {
// For breakdowns deeper than the group level, don't show anything
// (e.g., if group is at COUNTRY level, don't show CITY)
value = null;
}
} }
// Check if this is the first row in the group and if this breakdown should be bold
if (
value &&
'groupKey' in original &&
original.groupKey &&
!original.isSummaryRow
) {
// Find all rows in this group from the current rows array
const groupRows = rows.filter(
(r): r is GroupedTableRow | ExpandableTableRow =>
'groupKey' in r &&
r.groupKey === original.groupKey &&
!('isSummaryRow' in r && 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 =
'breakdownValues' in firstRowInGroup
? firstRowInGroup.breakdownValues[index]
: null;
if (firstRowValue === value) {
isMuted = true;
}
}
}
}
} else {
value =
'breakdownValues' in original
? (original.breakdownValues[index] ?? null)
: null;
} }
const isSummary =
'isSummaryRow' in original && original.isSummaryRow === true;
// 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 (
<div className="flex items-center gap-2 px-4 h-12"> <div className="flex items-center gap-2 px-4 h-12">
<span
className={cn(
'truncate block leading-[48px]',
isMuted && 'text-muted-foreground/50',
isGroupHeader && 'font-semibold',
)}
>
{value || '(Not set)'}
</span>
{canExpand && {canExpand &&
index === index ===
('groupLevel' in original ? (original.groupLevel ?? 0) : 0) && ('groupLevel' in original ? (original.groupLevel ?? 0) : 0) &&
@@ -923,16 +923,6 @@ export function ReportTable({
)} )}
</button> </button>
)} )}
<span
className={cn(
'truncate block leading-[48px]',
(!value || isMuted) && 'text-muted-foreground/50',
(isSummary || shouldBeBold || isGroupHeader) &&
'font-semibold',
)}
>
{value || ''}
</span>
</div> </div>
); );
}, },
@@ -966,21 +956,6 @@ export function ReportTable({
const isIndividualRow = !isSummary && !isGroupHeader; const isIndividualRow = !isSummary && !isGroupHeader;
const range = metricRanges[metric.key]; const range = metricRanges[metric.key];
// Debug: Check first few rows
if (metric.key === 'count' && row.index < 5) {
console.log(`[FIX CHECK] Row ${row.index}:`, {
isSummaryRowValue:
'isSummaryRow' in original ? original.isSummaryRow : 'NOT SET',
isGroupHeaderValue:
'isGroupHeader' in original
? original.isGroupHeader
: 'NOT SET',
isSummary,
isGroupHeader,
isIndividualRow,
});
}
// Only apply colors to individual rows, not summary or group header rows // Only apply colors to individual rows, not summary or group header rows
// Also check that range is valid (not still at initial values) // Also check that range is valid (not still at initial values)
const hasValidRange = const hasValidRange =
@@ -988,32 +963,21 @@ export function ReportTable({
range.min !== Number.POSITIVE_INFINITY && range.min !== Number.POSITIVE_INFINITY &&
range.max !== Number.NEGATIVE_INFINITY; range.max !== Number.NEGATIVE_INFINITY;
const { opacity, className } = const { style: backgroundStyle, opacity: bgOpacity } =
isIndividualRow && hasValidRange isIndividualRow && hasValidRange
? getCellBackground( ? getCellBackgroundStyle(value, range.min, range.max, 'purple')
value, : { style: {}, opacity: 0 };
range.min,
range.max,
'bg-purple-400 dark:bg-purple-700',
)
: { opacity: 0, className: '' };
return ( return (
<div className="relative h-12 w-full"> <div
<div className={cn(
className={cn(className, 'absolute inset-0 w-full h-full')} 'h-12 w-full text-right font-mono text-sm px-4 flex items-center justify-end',
style={{ opacity }} '[text-shadow:_0_0_3px_rgb(0_0_0_/_20%)] shadow-[inset_-1px_-1px_0_var(--border)]',
/> (isSummary || isGroupHeader) && 'font-semibold',
<div )}
className={cn( style={backgroundStyle}
'relative text-right font-mono text-sm px-4 h-full flex items-center justify-end', >
(isSummary || isGroupHeader) && 'font-semibold', {number.format(value)}
opacity > 0.7 &&
'text-white [text-shadow:_0_0_3px_rgb(0_0_0_/_20%)]',
)}
>
{number.format(value)}
</div>
</div> </div>
); );
}, },
@@ -1042,27 +1006,23 @@ export function ReportTable({
range && range &&
range.min !== Number.POSITIVE_INFINITY && range.min !== Number.POSITIVE_INFINITY &&
range.max !== Number.NEGATIVE_INFINITY; range.max !== Number.NEGATIVE_INFINITY;
const { opacity, className } = const { style: backgroundStyle, opacity: bgOpacity } =
isIndividualRow && hasValidRange isIndividualRow && hasValidRange
? getCellBackground(value, range.min, range.max) ? getCellBackgroundStyle(value, range.min, range.max, 'emerald')
: { opacity: 0, className: '' }; : { style: {}, opacity: 0 };
const needsLightText = bgOpacity > 0.7;
return ( return (
<div className="relative h-12 w-full"> <div
<div className={cn(
className={cn(className, 'absolute inset-0 w-full h-full')} 'h-12 w-full text-right font-mono text-sm px-4 flex items-center justify-end',
style={{ opacity }} '[text-shadow:_0_0_3px_rgb(0_0_0_/_20%)] shadow-[inset_-1px_-1px_0_var(--border)]',
/> (isSummary || isGroupHeader) && 'font-semibold',
<div )}
className={cn( style={backgroundStyle}
'relative text-right font-mono text-sm px-4 h-full flex items-center justify-end', >
(isSummary || isGroupHeader) && 'font-semibold', {number.format(value)}
opacity > 0.7 &&
'text-white [text-shadow:_0_0_3px_rgb(0_0_0_/_20%)]',
)}
>
{number.format(value)}
</div>
</div> </div>
); );
}, },
@@ -1211,15 +1171,57 @@ export function ReportTable({
.getAllLeafColumns() .getAllLeafColumns()
.filter((col) => table.getState().columnVisibility[col.id] !== false); .filter((col) => table.getState().columnVisibility[col.id] !== false);
// Get pinned columns // Separate columns into pinned and scrollable
const leftPinnedColumns = table const leftPinnedColumns = headerColumns.filter(
.getAllColumns() (col) => col.columnDef.meta?.pinned === 'left',
.filter((col) => col.columnDef.meta?.pinned === 'left') );
.filter((col): col is NonNullable<typeof col> => col !== undefined); const rightPinnedColumns = headerColumns.filter(
const rightPinnedColumns = table (col) => col.columnDef.meta?.pinned === 'right',
.getAllColumns() );
.filter((col) => col.columnDef.meta?.pinned === 'right') const scrollableColumns = headerColumns.filter(
.filter((col): col is NonNullable<typeof col> => col !== undefined); (col) => !col.columnDef.meta?.pinned,
);
// Calculate widths for virtualization
const leftPinnedWidth = useMemo(
() => leftPinnedColumns.reduce((sum, col) => sum + col.getSize(), 0),
[leftPinnedColumns, columnSizing],
);
const rightPinnedWidth = useMemo(
() => rightPinnedColumns.reduce((sum, col) => sum + col.getSize(), 0),
[rightPinnedColumns, columnSizing],
);
const scrollableColumnsTotalWidth = useMemo(
() => scrollableColumns.reduce((sum, col) => sum + col.getSize(), 0),
[scrollableColumns, columnSizing],
);
// Horizontal virtualization for scrollable columns
// Only virtualize if we have enough columns to benefit from it
const shouldVirtualizeHorizontal = scrollableColumns.length > 10;
const horizontalVirtualizer = useVirtualizer({
count: scrollableColumns.length,
getScrollElement: () => parentRef.current,
estimateSize: (index) =>
scrollableColumns[index]?.getSize() ?? DEFAULT_COLUMN_WIDTH,
horizontal: true,
overscan: shouldVirtualizeHorizontal ? 5 : scrollableColumns.length,
});
// Get virtual columns - if not virtualizing, return all columns
const virtualColumns = shouldVirtualizeHorizontal
? horizontalVirtualizer.getVirtualItems()
: scrollableColumns.map((col, index) => ({
index,
start: scrollableColumns
.slice(0, index)
.reduce((sum, c) => sum + c.getSize(), 0),
size: col.getSize(),
key: col.id,
end: 0,
lane: 0,
}));
// Pre-compute grid template columns string and headers // Pre-compute grid template columns string and headers
const { gridTemplateColumns, headers } = useMemo(() => { const { gridTemplateColumns, headers } = useMemo(() => {
@@ -1299,19 +1301,37 @@ export function ReportTable({
onSearchChange={setGlobalFilter} onSearchChange={setGlobalFilter}
onUnselectAll={() => setVisibleSeries([])} onUnselectAll={() => setVisibleSeries([])}
/> />
<div ref={parentRef} className="overflow-x-auto"> <div
<div className="relative" style={{ minWidth: 'fit-content' }}> ref={parentRef}
className="overflow-x-auto"
style={{
width: '100%',
}}
>
<div
className="relative"
style={{
width:
leftPinnedWidth + scrollableColumnsTotalWidth + rightPinnedWidth,
minWidth: 'fit-content',
}}
>
{/* Header */} {/* Header */}
<div <div
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: 'flex',
gridTemplateColumns, width:
leftPinnedWidth +
scrollableColumnsTotalWidth +
rightPinnedWidth,
minWidth: 'fit-content', minWidth: 'fit-content',
}} }}
> >
{table.getHeaderGroups()[0]?.headers.map((header) => { {/* Left Pinned Columns */}
const column = header.column; {leftPinnedColumns.map((column) => {
const header = headers.find((h) => h.column.id === column.id);
if (!header) return null;
const headerContent = column.columnDef.header; const headerContent = column.columnDef.header;
const isBreakdown = column.columnDef.meta?.isBreakdown ?? false; const isBreakdown = column.columnDef.meta?.isBreakdown ?? false;
const pinningStyles = getPinningStyles(column); const pinningStyles = getPinningStyles(column);
@@ -1421,6 +1441,172 @@ export function ReportTable({
</div> </div>
); );
})} })}
{/* Scrollable Columns (Virtualized) */}
<div
style={{
position: 'relative',
width: scrollableColumnsTotalWidth,
height: '40px',
}}
>
{virtualColumns.map((virtualCol) => {
const column = scrollableColumns[virtualCol.index];
if (!column) return null;
const header = headers.find((h) => h.column.id === column.id);
if (!header) return null;
const headerContent = header.column.columnDef.header;
const isBreakdown =
header.column.columnDef.meta?.isBreakdown ?? false;
const isMetricOrDate =
header.column.id.startsWith('metric-') ||
header.column.id.startsWith('date-');
const canSort = header.column.getCanSort();
const isSorted = header.column.getIsSorted();
return (
<div
key={header.id}
style={{
position: 'absolute',
left: `${virtualCol.start}px`,
width: `${virtualCol.size}px`,
height: '40px',
}}
className={cn(
'px-4 flex items-center text-[10px] uppercase font-semibold bg-muted/30 border-r border-border whitespace-nowrap',
isMetricOrDate && 'text-right',
canSort && 'cursor-pointer hover:bg-muted/50 select-none',
)}
onClick={
canSort
? (e) => {
if (
isResizingRef.current ||
header.column.getIsResizing() ||
(e.target as HTMLElement).closest(
'[data-resize-handle]',
)
) {
return;
}
header.column.toggleSorting();
}
: undefined
}
onKeyDown={
canSort
? (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
header.column.toggleSorting();
}
}
: undefined
}
role={canSort ? 'button' : undefined}
tabIndex={canSort ? 0 : undefined}
>
<div className="flex items-center gap-1.5 flex-1">
{header.isPlaceholder
? null
: typeof headerContent === 'function'
? flexRender(headerContent, header.getContext())
: headerContent}
{canSort && (
<span className="text-muted-foreground">
{isSorted === 'asc'
? '↑'
: isSorted === 'desc'
? '↓'
: '⇅'}
</span>
)}
</div>
</div>
);
})}
</div>
{/* Right Pinned Columns */}
{rightPinnedColumns.map((column) => {
const header = headers.find((h) => h.column.id === column.id);
if (!header) return null;
const headerContent = header.column.columnDef.header;
const isBreakdown =
header.column.columnDef.meta?.isBreakdown ?? false;
const pinningStyles = getPinningStyles(header.column);
const isMetricOrDate =
header.column.id.startsWith('metric-') ||
header.column.id.startsWith('date-');
const canSort = header.column.getCanSort();
const isSorted = header.column.getIsSorted();
const canResize = header.column.getCanResize();
return (
<div
key={header.id}
style={{
width: `${header.getSize()}px`,
minWidth: header.column.columnDef.minSize,
maxWidth: header.column.columnDef.maxSize,
...pinningStyles,
}}
className={cn(
'h-10 px-4 flex items-center text-[10px] uppercase font-semibold bg-muted/30 border-r border-border whitespace-nowrap relative',
isMetricOrDate && 'text-right',
canSort && 'cursor-pointer hover:bg-muted/50 select-none',
)}
onClick={
canSort
? (e) => {
if (
isResizingRef.current ||
header.column.getIsResizing() ||
(e.target as HTMLElement).closest(
'[data-resize-handle]',
)
) {
return;
}
header.column.toggleSorting();
}
: undefined
}
onKeyDown={
canSort
? (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
header.column.toggleSorting();
}
}
: undefined
}
role={canSort ? 'button' : undefined}
tabIndex={canSort ? 0 : undefined}
>
<div className="flex items-center gap-1.5 flex-1">
{header.isPlaceholder
? null
: typeof headerContent === 'function'
? flexRender(headerContent, header.getContext())
: headerContent}
{canSort && (
<span className="text-muted-foreground">
{isSorted === 'asc'
? '↑'
: isSorted === 'desc'
? '↓'
: '⇅'}
</span>
)}
</div>
</div>
);
})}
</div> </div>
{/* Virtualized Body */} {/* Virtualized Body */}
@@ -1442,12 +1628,18 @@ export function ReportTable({
...virtualRow, ...virtualRow,
start: virtualRow.start - virtualizer.options.scrollMargin, start: virtualRow.start - virtualizer.options.scrollMargin,
}} }}
gridTemplateColumns={gridTemplateColumns}
pinningStylesMap={pinningStylesMap} pinningStylesMap={pinningStylesMap}
headers={headers} headers={headers}
isResizingRef={isResizingRef} isResizingRef={isResizingRef}
resizingColumnId={resizingColumnId} resizingColumnId={resizingColumnId}
setResizingColumnId={setResizingColumnId} setResizingColumnId={setResizingColumnId}
leftPinnedColumns={leftPinnedColumns}
scrollableColumns={scrollableColumns}
rightPinnedColumns={rightPinnedColumns}
virtualColumns={virtualColumns}
leftPinnedWidth={leftPinnedWidth}
scrollableColumnsTotalWidth={scrollableColumnsTotalWidth}
rightPinnedWidth={rightPinnedWidth}
/> />
); );
})} })}

View File

@@ -23,9 +23,12 @@ const chartColors = [
]; ];
export function getChartColor(index: number): string { export function getChartColor(index: number): string {
return chartColors[index % chartColors.length]!.main; return chartColors[index % chartColors.length]?.main || chartColors[0].main;
} }
export function getChartTranslucentColor(index: number): string { export function getChartTranslucentColor(index: number): string {
return chartColors[index % chartColors.length]!.translucent; return (
chartColors[index % chartColors.length]?.translucent ||
chartColors[0].translucent
);
} }