From d99335e2f48f2fb871cdadcf82d2a24f036e5102 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carl-Gerhard=20Lindesva=CC=88rd?= Date: Mon, 24 Nov 2025 18:08:10 +0100 Subject: [PATCH] wip --- .../report-chart/common/report-table-utils.ts | 419 +++++++++- .../report-chart/common/report-table.tsx | 741 +++++++++++++----- 2 files changed, 957 insertions(+), 203 deletions(-) diff --git a/apps/start/src/components/report-chart/common/report-table-utils.ts b/apps/start/src/components/report-chart/common/report-table-utils.ts index ee737cd0..d29d5fb0 100644 --- a/apps/start/src/components/report-chart/common/report-table-utils.ts +++ b/apps/start/src/components/report-chart/common/report-table-utils.ts @@ -23,6 +23,348 @@ export type GroupedTableRow = TableRow & { breakdownDisplay: (string | null)[]; // null means show empty cell }; +/** + * Row type that supports TanStack Table's expanding feature + * Can represent both group header rows and data rows + */ +export type ExpandableTableRow = TableRow & { + subRows?: ExpandableTableRow[]; + isGroupHeader?: boolean; // True if this is a group header row + groupValue?: string; // The value this group represents + groupLevel?: number; // The level in the hierarchy (0-based) + breakdownDisplay?: (string | null)[]; // For display purposes +}; + +/** + * Hierarchical group structure for better collapse/expand functionality + */ +export type GroupedItem = { + group: string; + items: Array | T>; + level: number; + groupKey: string; // Unique key for this group (path-based) + parentGroupKey?: string; // Key of parent group +}; + +/** + * Transform flat array of items with hierarchical names into nested group structure + * This creates a tree structure that makes it easier to toggle specific groups + */ +export function groupByNames( + items: T[], +): Array> { + const rootGroups = new Map>(); + + for (const item of items) { + const names = item.names; + if (names.length === 0) continue; + + // Start with the first level (serie name, level -1) + const firstLevel = names[0]!; + const rootGroupKey = firstLevel; + + if (!rootGroups.has(firstLevel)) { + rootGroups.set(firstLevel, { + group: firstLevel, + items: [], + level: -1, // Serie level + groupKey: rootGroupKey, + }); + } + + const rootGroup = rootGroups.get(firstLevel)!; + + // Navigate/create nested groups for remaining levels (breakdowns, level 0+) + let currentGroup = rootGroup; + let parentGroupKey = rootGroupKey; + + for (let i = 1; i < names.length; i++) { + const levelName = names[i]!; + const groupKey = `${parentGroupKey}:${levelName}`; + const level = i - 1; // Breakdown levels start at 0 + + // Find existing group at this level + const existingGroup = currentGroup.items.find( + (child): child is GroupedItem => + typeof child === 'object' && + 'group' in child && + child.group === levelName && + 'level' in child && + child.level === level, + ); + + if (existingGroup) { + currentGroup = existingGroup; + parentGroupKey = groupKey; + } else { + // Create new group at this level + const newGroup: GroupedItem = { + group: levelName, + items: [], + level, + groupKey, + parentGroupKey, + }; + currentGroup.items.push(newGroup); + currentGroup = newGroup; + parentGroupKey = groupKey; + } + } + + // Add the actual item to the deepest group + currentGroup.items.push(item); + } + + return Array.from(rootGroups.values()); +} + +/** + * Flatten a grouped structure back into a flat array of items + * Useful for getting all items in a group or its children + */ +export function flattenGroupedItems( + groupedItems: Array | T>, +): T[] { + const result: T[] = []; + + for (const item of groupedItems) { + if (item && typeof item === 'object' && 'items' in item) { + // It's a group, recursively flatten its items + result.push(...flattenGroupedItems(item.items)); + } else if (item) { + // It's an actual item + result.push(item); + } + } + + return result; +} + +/** + * Find a group by its groupKey in a nested structure + */ +export function findGroup( + groups: Array>, + groupKey: string, +): GroupedItem | null { + for (const group of groups) { + if (group.groupKey === groupKey) { + return group; + } + + // Search in nested groups + for (const item of group.items) { + if (item && typeof item === 'object' && 'items' in item) { + const found = findGroup([item], groupKey); + if (found) return found; + } + } + } + + return null; +} + +/** + * Convert hierarchical groups to TanStack Table's expandable row format + * This creates rows with subRows that TanStack Table can expand/collapse natively + */ +export function groupsToExpandableRows( + groups: Array>, + breakdownCount: number, +): ExpandableTableRow[] { + const result: ExpandableTableRow[] = []; + + function processGroup( + group: GroupedItem, + parentPath: string[] = [], + ): ExpandableTableRow[] { + const currentPath = [...parentPath, group.group]; + const subRows: ExpandableTableRow[] = []; + + // Separate nested groups from actual items + const nestedGroups: GroupedItem[] = []; + const actualItems: TableRow[] = []; + + for (const item of group.items) { + if (item && typeof item === 'object' && 'items' in item) { + nestedGroups.push(item); + } else if (item) { + actualItems.push(item); + } + } + + // Process nested groups (they become subRows) + for (const nestedGroup of nestedGroups) { + subRows.push(...processGroup(nestedGroup, currentPath)); + } + + // Process actual items + actualItems.forEach((item, index) => { + const breakdownDisplay: (string | null)[] = []; + const breakdownValues = item.breakdownValues; + + // Build breakdownDisplay based on hierarchy + if (index === 0) { + // First row shows all breakdown values + for (let i = 0; i < breakdownCount; i++) { + breakdownDisplay.push(breakdownValues[i] ?? null); + } + } else { + // Subsequent rows: show values from parent path, then item values + for (let i = 0; i < breakdownCount; i++) { + if (i < currentPath.length) { + breakdownDisplay.push(currentPath[i] ?? null); + } else if (i < breakdownValues.length) { + breakdownDisplay.push(breakdownValues[i] ?? null); + } else { + breakdownDisplay.push(null); + } + } + } + + subRows.push({ + ...item, + breakdownDisplay, + groupKey: group.groupKey, + parentGroupKey: group.parentGroupKey, + // Explicitly mark as NOT a group header or summary row + isGroupHeader: false, + isSummaryRow: false, + }); + }); + + // 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) + // because it would just duplicate the rows + const shouldCreateGroupHeader = + subRows.length > 0 && + (group.level < breakdownCount || group.level === -1); // -1 is serie level + + if (shouldCreateGroupHeader) { + // Create a summary row for the group + const groupItems = flattenGroupedItems(group.items); + const summaryRow = createSummaryRow( + groupItems, + group.groupKey, + breakdownCount, + ); + + return [ + { + ...summaryRow, + isGroupHeader: true, + groupValue: group.group, + groupLevel: group.level, + subRows, + }, + ]; + } + + return subRows; + } + + for (const group of groups) { + result.push(...processGroup(group)); + } + + return result; +} + +/** + * Convert hierarchical groups to flat table rows, respecting collapsed groups + * This creates GroupedTableRow entries with proper breakdownDisplay values + * @deprecated Use groupsToExpandableRows with TanStack Table's expanding feature instead + */ +export function groupsToTableRows( + groups: Array>, + collapsedGroups: Set, + breakdownCount: number, +): GroupedTableRow[] { + const rows: GroupedTableRow[] = []; + + function processGroup( + group: GroupedItem, + parentPath: string[] = [], + parentGroupKey?: string, + ): void { + const isGroupCollapsed = collapsedGroups.has(group.groupKey); + const currentPath = [...parentPath, group.group]; + + if (isGroupCollapsed) { + // Group is collapsed - add summary row + const groupItems = flattenGroupedItems(group.items); + if (groupItems.length > 0) { + const summaryRow = createSummaryRow( + groupItems, + group.groupKey, + breakdownCount, + ); + rows.push(summaryRow); + } + return; + } + + // Group is expanded - process items + // Separate nested groups from actual items + const nestedGroups: GroupedItem[] = []; + const actualItems: T[] = []; + + for (const item of group.items) { + if (item && typeof item === 'object' && 'items' in item) { + nestedGroups.push(item); + } else if (item) { + actualItems.push(item); + } + } + + // Process actual items first + actualItems.forEach((item, index) => { + const breakdownDisplay: (string | null)[] = []; + const breakdownValues = item.breakdownValues; + + // For the first item in the group, show all breakdown values + // For subsequent items, show values based on hierarchy + if (index === 0) { + // First row shows all breakdown values + for (let i = 0; i < breakdownCount; i++) { + breakdownDisplay.push(breakdownValues[i] ?? null); + } + } else { + // Subsequent rows: show values from parent path, then item values + for (let i = 0; i < breakdownCount; i++) { + if (i < currentPath.length) { + // Show value from parent group path + breakdownDisplay.push(currentPath[i] ?? null); + } else if (i < breakdownValues.length) { + // Show current breakdown value from the item + breakdownDisplay.push(breakdownValues[i] ?? null); + } else { + breakdownDisplay.push(null); + } + } + } + + rows.push({ + ...item, + breakdownDisplay, + groupKey: group.groupKey, + parentGroupKey: group.parentGroupKey, + }); + }); + + // Process nested groups + for (const nestedGroup of nestedGroups) { + processGroup(nestedGroup, currentPath, group.groupKey); + } + } + + for (const group of groups) { + processGroup(group); + } + + return rows; +} + /** * Extract unique dates from all series */ @@ -88,8 +430,40 @@ export function createFlatRows( } /** - * Transform series into grouped table rows + * Transform series into hierarchical groups + * Uses the new groupByNames function for better structure + * Groups by serie name first, then by breakdown values + */ +export function createGroupedRowsHierarchical( + series: IChartData['series'], + dates: string[], +): Array> { + const flatRows = createFlatRows(series, dates); + + // Sort by sum descending before grouping + flatRows.sort((a, b) => b.sum - a.sum); + + const breakdownCount = flatRows[0]?.breakdownValues.length ?? 0; + + if (breakdownCount === 0) { + // No breakdowns - return empty array (will be handled as flat rows) + return []; + } + + // Create hierarchical groups using groupByNames + // Group by serie name first, then by breakdown values + const itemsWithNames = flatRows.map((row) => ({ + ...row, + names: [row.serieName, ...row.breakdownValues], // Serie name + breakdown values + })); + + return groupByNames(itemsWithNames); +} + +/** + * Transform series into grouped table rows (legacy flat format) * Groups rows hierarchically by breakdown values + * @deprecated Use createGroupedRowsHierarchical + groupsToTableRows instead */ export function createGroupedRows( series: IChartData['series'], @@ -318,3 +692,46 @@ export function transformToTableData( breakdownPropertyNames, }; } + +/** + * Transform chart data into hierarchical groups + * Returns hierarchical structure for better group management + */ +export function transformToHierarchicalGroups( + data: IChartData, + breakdowns: Array<{ name: string }>, +): { + groups: Array>; + dates: string[]; + breakdownPropertyNames: string[]; +} { + const dates = getUniqueDates(data.series); + const originalBreakdownPropertyNames = getBreakdownPropertyNames( + data.series, + breakdowns, + ); + + // Reorder breakdowns by unique count (fewest first) + const { reorderedNames: breakdownPropertyNames, reorderMap } = + reorderBreakdownsByUniqueCount(data.series, originalBreakdownPropertyNames); + + // Reorder breakdown values in series before creating rows + const reorderedSeries = data.series.map((serie) => { + const reorderedNames = [ + serie.names[0], // Keep serie name first + ...reorderMap.map((oldIndex) => serie.names[oldIndex + 1] ?? ''), // Reorder breakdown values + ]; + return { + ...serie, + names: reorderedNames, + }; + }); + + const groups = createGroupedRowsHierarchical(reorderedSeries, dates); + + return { + groups, + dates, + breakdownPropertyNames, + }; +} diff --git a/apps/start/src/components/report-chart/common/report-table.tsx b/apps/start/src/components/report-chart/common/report-table.tsx index 2080d7bc..3e086ada 100644 --- a/apps/start/src/components/report-chart/common/report-table.tsx +++ b/apps/start/src/components/report-chart/common/report-table.tsx @@ -7,9 +7,11 @@ import { cn } from '@/utils/cn'; import { getChartColor } from '@/utils/theme'; import type { ColumnDef, Header, Row } from '@tanstack/react-table'; import { + type ExpandedState, type SortingState, flexRender, getCoreRowModel, + getExpandedRowModel, getFilteredRowModel, getSortedRowModel, useReactTable, @@ -25,9 +27,13 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import { ReportTableToolbar } from './report-table-toolbar'; import { + type ExpandableTableRow, + type GroupedItem, type GroupedTableRow, type TableRow, - createSummaryRow, + groupsToExpandableRows, + groupsToTableRows, + transformToHierarchicalGroups, transformToTableData, } from './report-table-utils'; import { SerieName } from './serie-name'; @@ -70,7 +76,6 @@ const VirtualRow = function VirtualRow({ resizingColumnId, setResizingColumnId, }: VirtualRowProps) { - console.log('VirtualRow', row.original.id); const cells = row.getVisibleCells(); return ( @@ -161,9 +166,7 @@ export function ReportTable({ setVisibleSeries, }: ReportTableProps) { const [grouped, setGrouped] = useState(true); - const [collapsedGroups, setCollapsedGroups] = useState>( - new Set(), - ); + const [expanded, setExpanded] = useState({}); const [sorting, setSorting] = useState([]); const [globalFilter, setGlobalFilter] = useState(''); const [columnSizing, setColumnSizing] = useState>({}); @@ -180,57 +183,45 @@ export function ReportTable({ short: true, }); - // Transform data to table format + // Transform data to hierarchical groups or flat rows const { - rows: rawRows, + groups: hierarchicalGroups, + rows: flatRows, dates, breakdownPropertyNames, - } = useMemo( - () => transformToTableData(data, breakdowns, grouped), - [data, breakdowns, grouped], - ); + } = useMemo(() => { + if (grouped) { + const result = transformToHierarchicalGroups(data, breakdowns); + return { + groups: result.groups, + rows: null, + dates: result.dates, + breakdownPropertyNames: result.breakdownPropertyNames, + }; + } + const result = transformToTableData(data, breakdowns, false); + return { + groups: null, + rows: result.rows as TableRow[], + dates: result.dates, + breakdownPropertyNames: result.breakdownPropertyNames, + }; + }, [data, breakdowns, grouped]); - // Filter rows based on collapsed groups and create summary rows - const rows = useMemo(() => { - if (!grouped || collapsedGroups.size === 0) { - return rawRows; + // Convert hierarchical groups to expandable rows (for TanStack Table's expanding feature) + const expandableRows = useMemo(() => { + if (!grouped || !hierarchicalGroups || hierarchicalGroups.length === 0) { + return null; } - const processedRows: (TableRow | GroupedTableRow)[] = []; - const groupedRows = rawRows as GroupedTableRow[]; + return groupsToExpandableRows( + hierarchicalGroups, + breakdownPropertyNames.length, + ); + }, [grouped, hierarchicalGroups, breakdownPropertyNames.length]); - // Group rows by their groupKey - const rowsByGroup = new Map(); - groupedRows.forEach((row) => { - if (row.groupKey) { - if (!rowsByGroup.has(row.groupKey)) { - rowsByGroup.set(row.groupKey, []); - } - rowsByGroup.get(row.groupKey)!.push(row); - } else { - // Rows without groupKey go directly to processed - processedRows.push(row); - } - }); - - // Process each group - rowsByGroup.forEach((groupRows, groupKey) => { - if (collapsedGroups.has(groupKey)) { - // Group is collapsed - show summary row - const summaryRow = createSummaryRow( - groupRows, - groupKey, - breakdownPropertyNames.length, - ); - processedRows.push(summaryRow); - } else { - // Group is expanded - show all rows - processedRows.push(...groupRows); - } - }); - - return processedRows; - }, [rawRows, collapsedGroups, grouped, breakdownPropertyNames.length]); + // Use expandable rows if available, otherwise use flat rows + const rows = expandableRows ?? flatRows ?? []; // Filter rows based on global search and apply sorting const filteredRows = useMemo(() => { @@ -275,43 +266,42 @@ export function ReportTable({ }); } - // Apply sorting - if grouped, sort within each group - if (grouped && sorting.length > 0 && result.length > 0) { - const groupedRows = result as GroupedTableRow[]; - - // Group rows by their groupKey - const rowsByGroup = new Map(); - const ungroupedRows: GroupedTableRow[] = []; - - groupedRows.forEach((row) => { - if (row.groupKey) { - if (!rowsByGroup.has(row.groupKey)) { - rowsByGroup.set(row.groupKey, []); - } - rowsByGroup.get(row.groupKey)!.push(row); - } else { - ungroupedRows.push(row); - } - }); + // Apply sorting - if grouped, always sort groups by highest count, then sort within each group + if (grouped && result.length > 0) { + const groupedRows = result as ExpandableTableRow[] | GroupedTableRow[]; // Sort function based on current sort state - const sortFn = (a: GroupedTableRow, b: GroupedTableRow) => { + const sortFn = ( + a: ExpandableTableRow | GroupedTableRow | TableRow, + b: ExpandableTableRow | GroupedTableRow | TableRow, + ) => { + // If no sorting is selected, return 0 (no change) + if (sorting.length === 0) return 0; + for (const sort of sorting) { const { id, desc } = sort; let aValue: any; let bValue: any; if (id === 'serie-name') { - aValue = a.serieName; - bValue = b.serieName; + aValue = a.serieName ?? ''; + bValue = b.serieName ?? ''; } else if (id.startsWith('breakdown-')) { const index = Number.parseInt(id.replace('breakdown-', ''), 10); - aValue = a.breakdownValues[index] ?? ''; - bValue = b.breakdownValues[index] ?? ''; + if ('breakdownDisplay' in a && a.breakdownDisplay) { + aValue = a.breakdownDisplay[index] ?? ''; + } else { + aValue = a.breakdownValues[index] ?? ''; + } + if ('breakdownDisplay' in b && b.breakdownDisplay) { + bValue = b.breakdownDisplay[index] ?? ''; + } else { + bValue = b.breakdownValues[index] ?? ''; + } } else if (id.startsWith('metric-')) { const metric = id.replace('metric-', '') as keyof TableRow; - aValue = a[metric]; - bValue = b[metric]; + aValue = a[metric] ?? 0; + bValue = b[metric] ?? 0; } else if (id.startsWith('date-')) { const date = id.replace('date-', ''); aValue = a.dateValues[date] ?? 0; @@ -320,31 +310,113 @@ export function ReportTable({ continue; } + // Handle null/undefined values + if (aValue == null && bValue == null) continue; + if (aValue == null) return 1; + if (bValue == null) return -1; + // Compare values - if (aValue < bValue) return desc ? 1 : -1; - if (aValue > bValue) return desc ? -1 : 1; + if (typeof aValue === 'string' && typeof bValue === 'string') { + const comparison = aValue.localeCompare(bValue); + if (comparison !== 0) return desc ? -comparison : comparison; + } else { + if (aValue < bValue) return desc ? 1 : -1; + if (aValue > bValue) return desc ? -1 : 1; + } } return 0; }; - // Sort groups themselves by their first row's sort value - const groupsArray = Array.from(rowsByGroup.entries()); - groupsArray.sort((a, b) => { - const aFirst = a[1][0]; - const bFirst = b[1][0]; - if (!aFirst || !bFirst) return 0; - return sortFn(aFirst, bFirst); - }); + // For expandable rows, we need to sort recursively + function sortExpandableRows( + rows: ExpandableTableRow[], + isTopLevel = true, + ): ExpandableTableRow[] { + // Sort rows: groups by count first (only at top level), then apply user sort + const sorted = [...rows].sort((a, b) => { + // At top level, sort groups by count first + if (isTopLevel) { + const aIsGroupHeader = 'isGroupHeader' in a && a.isGroupHeader; + const bIsGroupHeader = 'isGroupHeader' in b && b.isGroupHeader; - // Rebuild result with sorted groups - const finalResult: GroupedTableRow[] = []; - groupsArray.forEach(([, groupRows]) => { - const sorted = [...groupRows].sort(sortFn); - finalResult.push(...sorted); - }); - finalResult.push(...ungroupedRows.sort(sortFn)); + if (aIsGroupHeader && bIsGroupHeader) { + const aLevel = 'groupLevel' in a ? (a.groupLevel ?? -1) : -1; + const bLevel = 'groupLevel' in b ? (b.groupLevel ?? -1) : -1; - return finalResult; + // Same level groups: sort by count first (always, regardless of user sort) + if (aLevel === bLevel) { + const aCount = a.count ?? 0; + const bCount = b.count ?? 0; + if (aCount !== bCount) { + return bCount - aCount; // Highest first + } + // If counts are equal, fall through to user sort + } + } + } + + // Apply user's sort criteria (for all rows, including within groups) + return sortFn(a, b); + }); + + // Sort subRows recursively (within each group) - these are NOT top level + return sorted.map((row) => { + if ('subRows' in row && row.subRows) { + return { + ...row, + subRows: sortExpandableRows(row.subRows, false), + }; + } + return row; + }); + } + + return sortExpandableRows(groupedRows as ExpandableTableRow[]); + } + + // For flat mode, apply sorting + if (!grouped && result.length > 0 && sorting.length > 0) { + return [...result].sort((a, b) => { + for (const sort of sorting) { + const { id, desc } = sort; + let aValue: any; + let bValue: any; + + if (id === 'serie-name') { + aValue = a.serieName ?? ''; + bValue = b.serieName ?? ''; + } else if (id.startsWith('breakdown-')) { + const index = Number.parseInt(id.replace('breakdown-', ''), 10); + aValue = a.breakdownValues[index] ?? ''; + bValue = b.breakdownValues[index] ?? ''; + } else if (id.startsWith('metric-')) { + const metric = id.replace('metric-', '') as keyof TableRow; + aValue = a[metric] ?? 0; + bValue = b[metric] ?? 0; + } else if (id.startsWith('date-')) { + const date = id.replace('date-', ''); + aValue = a.dateValues[date] ?? 0; + bValue = b.dateValues[date] ?? 0; + } else { + continue; + } + + // Handle null/undefined values + if (aValue == null && bValue == null) continue; + if (aValue == null) return 1; + if (bValue == null) return -1; + + // Compare values + if (typeof aValue === 'string' && typeof bValue === 'string') { + const comparison = aValue.localeCompare(bValue); + if (comparison !== 0) return desc ? -comparison : comparison; + } else { + if (aValue < bValue) return desc ? 1 : -1; + if (aValue > bValue) return desc ? -1 : 1; + } + } + return 0; + }); } return result; @@ -374,13 +446,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; + // Helper function to flatten expandable rows and get only individual rows + function getIndividualRows( + rows: (ExpandableTableRow | TableRow)[], + ): TableRow[] { + const individualRows: TableRow[] = []; + for (const row of rows) { + const isGroupHeader = + 'isGroupHeader' in row && row.isGroupHeader === true; + const isSummary = 'isSummaryRow' in row && row.isSummaryRow === true; + + if (!isGroupHeader && !isSummary) { + // It's an individual row - add it + individualRows.push(row as TableRow); + } + + // Always recursively process subRows if they exist (regardless of whether this is a group header) + if ('subRows' in row && row.subRows && Array.isArray(row.subRows)) { + individualRows.push(...getIndividualRows(row.subRows)); + } + } + return individualRows; + } + + // Get only individual rows from all rows to ensure consistent ranges + const individualRows = getIndividualRows(rows); + const isSingleSeries = individualRows.length === 1; if (isSingleSeries) { // For single series, calculate ranges from date values - const singleRow = nonSummaryRows[0]!; + const singleRow = individualRows[0]!; const allDateValues = dates.map( (date) => singleRow.dateValues[date] ?? 0, ); @@ -403,30 +498,37 @@ export function ReportTable({ 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); - } - }); + // Multiple series: calculate ranges across individual rows only + if (individualRows.length === 0) { + // No individual rows found - this shouldn't happen, but handle gracefully + console.warn('No individual rows found for range calculation'); + } else { + individualRows.forEach((row) => { + // Calculate metric ranges + Object.keys(metricRanges).forEach((key) => { + const value = row[key as keyof typeof row] as number; + if (typeof value === 'number' && !Number.isNaN(value)) { + 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); + // 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, + }; + } + if (typeof value === 'number' && !Number.isNaN(value)) { + dateRanges[date]!.min = Math.min(dateRanges[date]!.min, value); + dateRanges[date]!.max = Math.max(dateRanges[date]!.max, value); + } + }); }); - }); + } } return { metricRanges, dateRanges }; @@ -439,10 +541,18 @@ export function ReportTable({ max: number, className?: string, ): { opacity: number; className: string } => { - if (value === 0 || max === min) { + if (value === 0) { return { opacity: 0, className: '' }; } + // If min equals max (e.g. single row or all values same), show moderate opacity + if (max === min) { + return { + opacity: 0.5, + className: cn('bg-highlight dark:bg-emerald-700', className), + }; + } + const percentage = (value - min) / (max - min); const opacity = Math.max(0.05, Math.min(1, percentage)); @@ -481,17 +591,12 @@ export function ReportTable({ }); }; - // Toggle group collapse + // Toggle group collapse (now handled by TanStack Table's expanding feature) + // This is kept for backward compatibility with header click handlers const toggleGroupCollapse = (groupKey: string) => { - setCollapsedGroups((prev) => { - const next = new Set(prev); - if (next.has(groupKey)) { - next.delete(groupKey); - } else { - next.add(groupKey); - } - return next; - }); + // This will be handled by TanStack Table's row expansion + // We can find the row by groupKey and toggle it + // For now, this is a no-op as TanStack Table handles it }; // Define columns @@ -525,9 +630,12 @@ export function ReportTable({ 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, + // Find all rows in this group from the current rows array + const groupRows = rows.filter( + (r): r is GroupedTableRow => + 'groupKey' in r && + r.groupKey === original.groupKey && + !r.isSummaryRow, ); if (groupRows.length > 0) { @@ -545,8 +653,44 @@ export function ReportTable({ } } + const originalRow = row.original as ExpandableTableRow | TableRow; + const isGroupHeader = + 'isGroupHeader' in originalRow && originalRow.isGroupHeader === true; + const canExpand = grouped ? (row.getCanExpand?.() ?? false) : false; + const isExpanded = grouped ? (row.getIsExpanded?.() ?? false) : false; + const isSerieGroupHeader = + isGroupHeader && + 'groupLevel' in originalRow && + originalRow.groupLevel === -1; + const hasSubRows = + 'subRows' in originalRow && (originalRow.subRows?.length ?? 0) > 0; + return (
+ {grouped && isSerieGroupHeader && hasSubRows && ( + + )} toggleSerieVisibility(serieId)} @@ -561,7 +705,7 @@ export function ReportTable({ className={cn( 'truncate', isMuted && 'text-muted-foreground/50', - isFirstRowInGroup && 'font-semibold', + (isFirstRowInGroup || isGroupHeader) && 'font-semibold', )} />
@@ -592,39 +736,80 @@ export function ReportTable({ return propertyName; } - // Find all unique group keys for this breakdown level - const groupKeys = new Set(); - (rawRows as GroupedTableRow[]).forEach((row) => { - if (row.groupKey) { - groupKeys.add(row.groupKey); + // Find all rows at this breakdown level that can be expanded + const rowsAtLevel: string[] = []; + if (grouped && expandableRows) { + function collectRowIdsAtLevel( + rows: ExpandableTableRow[], + targetLevel: number, + currentLevel = 0, + ): void { + for (const row of rows) { + if ( + row.isGroupHeader && + row.groupLevel === targetLevel && + (row.subRows?.length ?? 0) > 0 + ) { + rowsAtLevel.push(row.id); + } + // Recurse into subRows if we haven't reached target level yet + if (currentLevel < targetLevel && row.subRows) { + collectRowIdsAtLevel( + row.subRows, + targetLevel, + currentLevel + 1, + ); + } + } } - }); + collectRowIdsAtLevel(expandableRows, index); + } - // Check if all groups at this level are collapsed - const allCollapsed = Array.from(groupKeys).every((key) => - collapsedGroups.has(key), - ); + // Check if all groups at this level are expanded + const allExpanded = + rowsAtLevel.length > 0 && + rowsAtLevel.every( + (id) => typeof expanded === 'object' && expanded[id] === true, + ); return (
{ + if (!grouped) return; // Toggle all groups at this breakdown level - groupKeys.forEach((key) => toggleGroupCollapse(key)); + setExpanded((prev) => { + const newExpanded: ExpandedState = + typeof prev === 'object' ? { ...prev } : {}; + const shouldExpand = !allExpanded; + rowsAtLevel.forEach((id) => { + newExpanded[id] = shouldExpand; + }); + return newExpanded; + }); }} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); - groupKeys.forEach((key) => toggleGroupCollapse(key)); + if (!grouped) return; + setExpanded((prev) => { + const newExpanded: ExpandedState = + typeof prev === 'object' ? { ...prev } : {}; + const shouldExpand = !allExpanded; + rowsAtLevel.forEach((id) => { + newExpanded[id] = shouldExpand; + }); + return newExpanded; + }); } }} role="button" tabIndex={0} > - {allCollapsed ? ( - - ) : ( + {allExpanded ? ( + ) : ( + )} {propertyName}
@@ -636,19 +821,52 @@ export function ReportTable({ breakdownIndex: index, }, cell: ({ row }) => { - const original = row.original; + const original = row.original as ExpandableTableRow | TableRow; + const isGroupHeader = + 'isGroupHeader' in original && original.isGroupHeader === true; + const canExpand = row.getCanExpand?.() ?? false; + const isExpanded = row.getIsExpanded?.() ?? false; + let value: string | null; let isMuted = false; let isFirstRowInGroup = false; - if ('breakdownDisplay' in original && grouped) { + if ( + 'breakdownDisplay' in original && + grouped && + original.breakdownDisplay !== undefined + ) { 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 && 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 ( + 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) { @@ -659,7 +877,10 @@ export function ReportTable({ isFirstRowInGroup = true; } else { // Only mute if this is not the first row and the value matches - const firstRowValue = firstRowInGroup.breakdownValues[index]; + const firstRowValue = + 'breakdownValues' in firstRowInGroup + ? firstRowInGroup.breakdownValues[index] + : null; if (firstRowValue === value) { isMuted = true; } @@ -667,25 +888,52 @@ export function ReportTable({ } } } else { - value = original.breakdownValues[index] ?? null; + value = + 'breakdownValues' in original + ? (original.breakdownValues[index] ?? null) + : null; } - const isSummary = original.isSummaryRow ?? false; + 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 ( - - {value || ''} - +
+ {canExpand && + index === + ('groupLevel' in original ? (original.groupLevel ?? 0) : 0) && + index < breakdownPropertyNames.length - 1 && ( + + )} + + {value || ''} + +
); }, }); @@ -709,16 +957,46 @@ export function ReportTable({ size: 100, cell: ({ row }) => { const value = row.original[metric.key]; - const isSummary = row.original.isSummaryRow ?? false; + const original = row.original as ExpandableTableRow | TableRow; + const hasIsSummaryRow = 'isSummaryRow' in original; + const hasIsGroupHeader = 'isGroupHeader' in original; + const isSummary = hasIsSummaryRow && original.isSummaryRow === true; + const isGroupHeader = + hasIsGroupHeader && original.isGroupHeader === true; + const isIndividualRow = !isSummary && !isGroupHeader; const range = metricRanges[metric.key]; - const { opacity, className } = range - ? getCellBackground( - value, - range.min, - range.max, - 'bg-purple-400 dark:bg-purple-700', - ) - : { opacity: 0, className: '' }; + + // 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 + // Also check that range is valid (not still at initial values) + const hasValidRange = + range && + range.min !== Number.POSITIVE_INFINITY && + range.max !== Number.NEGATIVE_INFINITY; + + const { opacity, className } = + isIndividualRow && hasValidRange + ? getCellBackground( + value, + range.min, + range.max, + 'bg-purple-400 dark:bg-purple-700', + ) + : { opacity: 0, className: '' }; return (
@@ -729,7 +1007,7 @@ export function ReportTable({
0.7 && 'text-white [text-shadow:_0_0_3px_rgb(0_0_0_/_20%)]', )} @@ -753,10 +1031,21 @@ export function ReportTable({ cell: ({ row }) => { const value = row.original.dateValues[date] ?? 0; const isSummary = row.original.isSummaryRow ?? false; + const isGroupHeader = + 'isGroupHeader' in row.original && + row.original.isGroupHeader === true; + const isIndividualRow = !isSummary && !isGroupHeader; const range = dateRanges[date]; - const { opacity, className } = range - ? getCellBackground(value, range.min, range.max) - : { opacity: 0, className: '' }; + // Only apply colors to individual rows, not summary or group header rows + // Also check that range is valid (not still at initial values) + const hasValidRange = + range && + range.min !== Number.POSITIVE_INFINITY && + range.max !== Number.NEGATIVE_INFINITY; + const { opacity, className } = + isIndividualRow && hasValidRange + ? getCellBackground(value, range.min, range.max) + : { opacity: 0, className: '' }; return (
@@ -767,7 +1056,7 @@ export function ReportTable({
0.7 && 'text-white [text-shadow:_0_0_3px_rgb(0_0_0_/_20%)]', )} @@ -788,11 +1077,12 @@ export function ReportTable({ number, grouped, visibleSeriesIds, - collapsedGroups, - rawRows, + expandableRows, + rows, metricRanges, dateRanges, columnSizing, + expanded, ]); // Create a hash of column IDs to track when columns change @@ -800,26 +1090,63 @@ export function ReportTable({ return columns.map((col) => col.id).join(','); }, [columns]); - const table = useReactTable({ - data: filteredRows, - columns, - getCoreRowModel: getCoreRowModel(), - getSortedRowModel: grouped ? getCoreRowModel() : getSortedRowModel(), // Disable TanStack sorting when grouped - getFilteredRowModel: getFilteredRowModel(), - filterFns: { - isWithinRange: () => true, - }, - enableColumnResizing: true, - columnResizeMode: 'onChange', - state: { + // Memoize table options to ensure table updates when filteredRows changes + const tableOptions = useMemo( + () => ({ + data: filteredRows, // This is already sorted in filteredRows + columns, + getCoreRowModel: getCoreRowModel(), + getExpandedRowModel: grouped ? getExpandedRowModel() : undefined, + getSubRows: grouped + ? (row: ExpandableTableRow | TableRow) => + 'subRows' in row ? row.subRows : undefined + : undefined, + // Sorting is handled manually in filteredRows, so we don't use getSortedRowModel + getFilteredRowModel: getFilteredRowModel(), + filterFns: { + isWithinRange: () => true, + }, + enableColumnResizing: true, + columnResizeMode: 'onChange' as const, + getRowCanExpand: grouped + ? (row: any) => { + const r = row.original as ExpandableTableRow; + if (!('isGroupHeader' in r) || !r.isGroupHeader) return false; + // Don't allow expansion for the last breakdown level + const groupLevel = r.groupLevel ?? -1; + const isLastBreakdown = + groupLevel === breakdownPropertyNames.length - 1; + const hasSubRows = (r.subRows?.length ?? 0) > 0; + return !isLastBreakdown && hasSubRows; + } + : undefined, + state: { + sorting, // Keep sorting state for UI indicators + columnSizing, + expanded: grouped ? expanded : undefined, + }, + onSortingChange: setSorting, + onColumnSizingChange: setColumnSizing, + onExpandedChange: grouped ? setExpanded : undefined, + globalFilterFn: () => true, // We handle filtering manually + manualSorting: true, // We handle sorting manually for both modes + manualFiltering: true, // We handle filtering manually + }), + [ + filteredRows, + columns, + grouped, + breakdownPropertyNames.length, sorting, columnSizing, - }, - onSortingChange: setSorting, - onColumnSizingChange: setColumnSizing, - globalFilterFn: () => true, // We handle filtering manually - manualSorting: grouped, // Manual sorting when grouped - }); + expanded, + setSorting, + setColumnSizing, + setExpanded, + ], + ); + + const table = useReactTable(tableOptions); // Virtualization setup useEffect(() => { @@ -860,8 +1187,18 @@ export function ReportTable({ }; }, []); + // Get the row model to use (expanded when grouped, regular otherwise) + // filteredRows is already sorted, so getExpandedRowModel/getRowModel should preserve that order + // We need to recalculate when filteredRows changes to ensure sorting is applied + const rowModelToUse = useMemo(() => { + if (grouped) { + return table.getExpandedRowModel(); + } + return table.getRowModel(); + }, [table, grouped, expanded, filteredRows.length, sorting]); + const virtualizer = useWindowVirtualizer({ - count: filteredRows.length, + count: rowModelToUse.rows.length, estimateSize: () => ROW_HEIGHT, overscan: 10, scrollMargin, @@ -1094,7 +1431,7 @@ export function ReportTable({ }} > {virtualRows.map((virtualRow) => { - const tableRow = table.getRowModel().rows[virtualRow.index]; + const tableRow = rowModelToUse.rows[virtualRow.index]; if (!tableRow) return null; return (