Full document, spreadsheet, slideshow, and diagram tooling
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(sheets): row grouping with collapsible sections (#128)

Group rows by column values with subtotal aggregations.

- groupRows: groups rows by column value with sorted output
- Toggle, collapse-all, expand-all state management
- Hidden row tracking for collapsed groups
- Subtotal computation: sum, avg, count, min, max
- Group header/footer row detection for rendering
- 23 unit tests

+454
+236
src/sheets/row-grouping.ts
··· 1 + /** 2 + * Row Grouping — collapsible sections with subtotals. 3 + * 4 + * Pure logic module: group definitions, collapse/expand state, 5 + * subtotal computation. DOM rendering handled in main.ts. 6 + */ 7 + 8 + export interface RowGroup { 9 + id: string; 10 + /** Column index used for grouping (values in this column define groups) */ 11 + groupByCol: number; 12 + /** The value that identifies this group */ 13 + groupValue: string; 14 + /** Row indices belonging to this group */ 15 + rows: number[]; 16 + /** Whether the group is collapsed */ 17 + collapsed: boolean; 18 + } 19 + 20 + export interface GroupConfig { 21 + /** Column index to group by */ 22 + columnIndex: number; 23 + /** Column indices to compute subtotals for */ 24 + subtotalCols: number[]; 25 + /** Aggregation function for subtotals */ 26 + aggregation: AggregationType; 27 + } 28 + 29 + export type AggregationType = 'sum' | 'avg' | 'count' | 'min' | 'max'; 30 + 31 + export interface SubtotalResult { 32 + groupId: string; 33 + columnIndex: number; 34 + value: number; 35 + label: string; 36 + } 37 + 38 + /** 39 + * Group rows by the values in a specific column. 40 + */ 41 + export function groupRows( 42 + cellValues: Map<string, unknown>, 43 + columnIndex: number, 44 + rowCount: number, 45 + colToLetter: (col: number) => string, 46 + ): RowGroup[] { 47 + const groups = new Map<string, number[]>(); 48 + 49 + for (let r = 1; r <= rowCount; r++) { 50 + const cellId = `${colToLetter(columnIndex)}${r}`; 51 + const raw = cellValues.get(cellId); 52 + const value = raw !== null && raw !== undefined ? String(raw).trim() : ''; 53 + const key = value || '(empty)'; 54 + 55 + if (!groups.has(key)) { 56 + groups.set(key, []); 57 + } 58 + groups.get(key)!.push(r); 59 + } 60 + 61 + const result: RowGroup[] = []; 62 + let idCounter = 0; 63 + 64 + for (const [groupValue, rows] of groups) { 65 + result.push({ 66 + id: `grp-${++idCounter}`, 67 + groupByCol: columnIndex, 68 + groupValue, 69 + rows: rows.sort((a, b) => a - b), 70 + collapsed: false, 71 + }); 72 + } 73 + 74 + // Sort groups alphabetically by value, with (empty) last 75 + result.sort((a, b) => { 76 + if (a.groupValue === '(empty)') return 1; 77 + if (b.groupValue === '(empty)') return -1; 78 + return a.groupValue.localeCompare(b.groupValue); 79 + }); 80 + 81 + return result; 82 + } 83 + 84 + /** 85 + * Toggle collapsed state of a group. 86 + */ 87 + export function toggleGroup( 88 + groups: RowGroup[], 89 + groupId: string, 90 + ): RowGroup[] { 91 + return groups.map(g => 92 + g.id === groupId ? { ...g, collapsed: !g.collapsed } : g, 93 + ); 94 + } 95 + 96 + /** 97 + * Collapse all groups. 98 + */ 99 + export function collapseAll(groups: RowGroup[]): RowGroup[] { 100 + return groups.map(g => ({ ...g, collapsed: true })); 101 + } 102 + 103 + /** 104 + * Expand all groups. 105 + */ 106 + export function expandAll(groups: RowGroup[]): RowGroup[] { 107 + return groups.map(g => ({ ...g, collapsed: false })); 108 + } 109 + 110 + /** 111 + * Get the set of row indices that are hidden (in collapsed groups). 112 + */ 113 + export function getHiddenRows(groups: RowGroup[]): Set<number> { 114 + const hidden = new Set<number>(); 115 + for (const group of groups) { 116 + if (group.collapsed) { 117 + for (const row of group.rows) { 118 + hidden.add(row); 119 + } 120 + } 121 + } 122 + return hidden; 123 + } 124 + 125 + /** 126 + * Compute a subtotal for a group over a specific column. 127 + */ 128 + export function computeSubtotal( 129 + group: RowGroup, 130 + columnIndex: number, 131 + cellValues: Map<string, unknown>, 132 + colToLetter: (col: number) => string, 133 + aggregation: AggregationType = 'sum', 134 + ): SubtotalResult { 135 + const numbers: number[] = []; 136 + 137 + for (const row of group.rows) { 138 + const cellId = `${colToLetter(columnIndex)}${row}`; 139 + const raw = cellValues.get(cellId); 140 + if (raw === null || raw === undefined || raw === '') continue; 141 + const num = Number(raw); 142 + if (!isNaN(num)) { 143 + numbers.push(num); 144 + } 145 + } 146 + 147 + let value: number; 148 + let label: string; 149 + 150 + switch (aggregation) { 151 + case 'sum': 152 + value = numbers.reduce((a, b) => a + b, 0); 153 + label = `Sum: ${value}`; 154 + break; 155 + case 'avg': 156 + value = numbers.length > 0 157 + ? numbers.reduce((a, b) => a + b, 0) / numbers.length 158 + : 0; 159 + label = `Avg: ${Math.round(value * 100) / 100}`; 160 + break; 161 + case 'count': 162 + value = numbers.length; 163 + label = `Count: ${value}`; 164 + break; 165 + case 'min': 166 + value = numbers.length > 0 ? Math.min(...numbers) : 0; 167 + label = `Min: ${value}`; 168 + break; 169 + case 'max': 170 + value = numbers.length > 0 ? Math.max(...numbers) : 0; 171 + label = `Max: ${value}`; 172 + break; 173 + default: 174 + value = 0; 175 + label = ''; 176 + } 177 + 178 + return { 179 + groupId: group.id, 180 + columnIndex, 181 + value, 182 + label, 183 + }; 184 + } 185 + 186 + /** 187 + * Compute subtotals for all groups across specified columns. 188 + */ 189 + export function computeAllSubtotals( 190 + groups: RowGroup[], 191 + config: GroupConfig, 192 + cellValues: Map<string, unknown>, 193 + colToLetter: (col: number) => string, 194 + ): SubtotalResult[] { 195 + const results: SubtotalResult[] = []; 196 + 197 + for (const group of groups) { 198 + for (const colIdx of config.subtotalCols) { 199 + results.push( 200 + computeSubtotal(group, colIdx, cellValues, colToLetter, config.aggregation), 201 + ); 202 + } 203 + } 204 + 205 + return results; 206 + } 207 + 208 + /** 209 + * Find which group a row belongs to, if any. 210 + */ 211 + export function findGroupForRow( 212 + groups: RowGroup[], 213 + row: number, 214 + ): RowGroup | null { 215 + return groups.find(g => g.rows.includes(row)) || null; 216 + } 217 + 218 + /** 219 + * Check if a row is the first row in its group (for rendering group headers). 220 + */ 221 + export function isGroupHeaderRow( 222 + groups: RowGroup[], 223 + row: number, 224 + ): boolean { 225 + return groups.some(g => g.rows.length > 0 && g.rows[0] === row); 226 + } 227 + 228 + /** 229 + * Check if a row is the last row in its group (for rendering subtotal row). 230 + */ 231 + export function isGroupFooterRow( 232 + groups: RowGroup[], 233 + row: number, 234 + ): boolean { 235 + return groups.some(g => g.rows.length > 0 && g.rows[g.rows.length - 1] === row); 236 + }
+218
tests/row-grouping.test.ts
··· 1 + import { describe, it, expect } from 'vitest'; 2 + import { 3 + groupRows, 4 + toggleGroup, 5 + collapseAll, 6 + expandAll, 7 + getHiddenRows, 8 + computeSubtotal, 9 + computeAllSubtotals, 10 + findGroupForRow, 11 + isGroupHeaderRow, 12 + isGroupFooterRow, 13 + type RowGroup, 14 + type GroupConfig, 15 + } from '../src/sheets/row-grouping.js'; 16 + 17 + /** Simple A=0, B=1, ... column letter helper */ 18 + function colToLetter(col: number): string { 19 + return String.fromCharCode(65 + col); 20 + } 21 + 22 + /** Build a cell map from an object { "A1": value, ... } */ 23 + function makeCells(obj: Record<string, unknown>): Map<string, unknown> { 24 + return new Map(Object.entries(obj)); 25 + } 26 + 27 + describe('Row Grouping', () => { 28 + describe('groupRows', () => { 29 + it('groups rows by column values', () => { 30 + const cells = makeCells({ 31 + A1: 'Alpha', A2: 'Beta', A3: 'Alpha', A4: 'Beta', A5: 'Alpha', 32 + }); 33 + const groups = groupRows(cells, 0, 5, colToLetter); 34 + expect(groups).toHaveLength(2); 35 + const alpha = groups.find(g => g.groupValue === 'Alpha')!; 36 + const beta = groups.find(g => g.groupValue === 'Beta')!; 37 + expect(alpha.rows).toEqual([1, 3, 5]); 38 + expect(beta.rows).toEqual([2, 4]); 39 + }); 40 + 41 + it('handles empty cells as (empty) group', () => { 42 + const cells = makeCells({ A1: 'X', A2: '', A3: 'X' }); 43 + const groups = groupRows(cells, 0, 3, colToLetter); 44 + const empty = groups.find(g => g.groupValue === '(empty)'); 45 + expect(empty).toBeDefined(); 46 + expect(empty!.rows).toEqual([2]); 47 + }); 48 + 49 + it('sorts groups alphabetically with (empty) last', () => { 50 + const cells = makeCells({ A1: 'Zeta', A2: '', A3: 'Alpha' }); 51 + const groups = groupRows(cells, 0, 3, colToLetter); 52 + expect(groups[0].groupValue).toBe('Alpha'); 53 + expect(groups[1].groupValue).toBe('Zeta'); 54 + expect(groups[2].groupValue).toBe('(empty)'); 55 + }); 56 + 57 + it('starts all groups expanded', () => { 58 + const cells = makeCells({ A1: 'X', A2: 'Y' }); 59 + const groups = groupRows(cells, 0, 2, colToLetter); 60 + expect(groups.every(g => !g.collapsed)).toBe(true); 61 + }); 62 + 63 + it('handles no rows', () => { 64 + const groups = groupRows(new Map(), 0, 0, colToLetter); 65 + expect(groups).toEqual([]); 66 + }); 67 + }); 68 + 69 + describe('toggleGroup', () => { 70 + it('toggles collapse state', () => { 71 + const groups: RowGroup[] = [ 72 + { id: 'g1', groupByCol: 0, groupValue: 'A', rows: [1, 2], collapsed: false }, 73 + ]; 74 + const toggled = toggleGroup(groups, 'g1'); 75 + expect(toggled[0].collapsed).toBe(true); 76 + const again = toggleGroup(toggled, 'g1'); 77 + expect(again[0].collapsed).toBe(false); 78 + }); 79 + 80 + it('only toggles the target group', () => { 81 + const groups: RowGroup[] = [ 82 + { id: 'g1', groupByCol: 0, groupValue: 'A', rows: [1], collapsed: false }, 83 + { id: 'g2', groupByCol: 0, groupValue: 'B', rows: [2], collapsed: false }, 84 + ]; 85 + const toggled = toggleGroup(groups, 'g1'); 86 + expect(toggled[0].collapsed).toBe(true); 87 + expect(toggled[1].collapsed).toBe(false); 88 + }); 89 + }); 90 + 91 + describe('collapseAll / expandAll', () => { 92 + const groups: RowGroup[] = [ 93 + { id: 'g1', groupByCol: 0, groupValue: 'A', rows: [1], collapsed: false }, 94 + { id: 'g2', groupByCol: 0, groupValue: 'B', rows: [2], collapsed: false }, 95 + ]; 96 + 97 + it('collapses all groups', () => { 98 + const result = collapseAll(groups); 99 + expect(result.every(g => g.collapsed)).toBe(true); 100 + }); 101 + 102 + it('expands all groups', () => { 103 + const collapsed = collapseAll(groups); 104 + const result = expandAll(collapsed); 105 + expect(result.every(g => !g.collapsed)).toBe(true); 106 + }); 107 + }); 108 + 109 + describe('getHiddenRows', () => { 110 + it('returns rows from collapsed groups', () => { 111 + const groups: RowGroup[] = [ 112 + { id: 'g1', groupByCol: 0, groupValue: 'A', rows: [1, 3, 5], collapsed: true }, 113 + { id: 'g2', groupByCol: 0, groupValue: 'B', rows: [2, 4], collapsed: false }, 114 + ]; 115 + const hidden = getHiddenRows(groups); 116 + expect(hidden).toEqual(new Set([1, 3, 5])); 117 + }); 118 + 119 + it('returns empty set when all expanded', () => { 120 + const groups: RowGroup[] = [ 121 + { id: 'g1', groupByCol: 0, groupValue: 'A', rows: [1, 2], collapsed: false }, 122 + ]; 123 + expect(getHiddenRows(groups).size).toBe(0); 124 + }); 125 + }); 126 + 127 + describe('computeSubtotal', () => { 128 + const group: RowGroup = { 129 + id: 'g1', groupByCol: 0, groupValue: 'A', rows: [1, 2, 3], collapsed: false, 130 + }; 131 + const cells = makeCells({ B1: 10, B2: 20, B3: 30 }); 132 + 133 + it('computes sum', () => { 134 + const result = computeSubtotal(group, 1, cells, colToLetter, 'sum'); 135 + expect(result.value).toBe(60); 136 + expect(result.label).toBe('Sum: 60'); 137 + }); 138 + 139 + it('computes avg', () => { 140 + const result = computeSubtotal(group, 1, cells, colToLetter, 'avg'); 141 + expect(result.value).toBe(20); 142 + }); 143 + 144 + it('computes count', () => { 145 + const result = computeSubtotal(group, 1, cells, colToLetter, 'count'); 146 + expect(result.value).toBe(3); 147 + }); 148 + 149 + it('computes min', () => { 150 + const result = computeSubtotal(group, 1, cells, colToLetter, 'min'); 151 + expect(result.value).toBe(10); 152 + }); 153 + 154 + it('computes max', () => { 155 + const result = computeSubtotal(group, 1, cells, colToLetter, 'max'); 156 + expect(result.value).toBe(30); 157 + }); 158 + 159 + it('ignores non-numeric values', () => { 160 + const mixedCells = makeCells({ B1: 10, B2: 'text', B3: 30 }); 161 + const result = computeSubtotal(group, 1, mixedCells, colToLetter, 'sum'); 162 + expect(result.value).toBe(40); 163 + }); 164 + 165 + it('returns 0 for empty numeric data', () => { 166 + const emptyCells = makeCells({ B1: 'a', B2: 'b', B3: 'c' }); 167 + const result = computeSubtotal(group, 1, emptyCells, colToLetter, 'sum'); 168 + expect(result.value).toBe(0); 169 + }); 170 + }); 171 + 172 + describe('computeAllSubtotals', () => { 173 + it('computes subtotals for all groups and columns', () => { 174 + const groups: RowGroup[] = [ 175 + { id: 'g1', groupByCol: 0, groupValue: 'A', rows: [1, 2], collapsed: false }, 176 + { id: 'g2', groupByCol: 0, groupValue: 'B', rows: [3], collapsed: false }, 177 + ]; 178 + const cells = makeCells({ B1: 10, B2: 20, B3: 30, C1: 5, C2: 15, C3: 25 }); 179 + const config: GroupConfig = { columnIndex: 0, subtotalCols: [1, 2], aggregation: 'sum' }; 180 + const results = computeAllSubtotals(groups, config, cells, colToLetter); 181 + expect(results).toHaveLength(4); // 2 groups * 2 columns 182 + const g1b = results.find(r => r.groupId === 'g1' && r.columnIndex === 1); 183 + expect(g1b!.value).toBe(30); 184 + }); 185 + }); 186 + 187 + describe('findGroupForRow', () => { 188 + const groups: RowGroup[] = [ 189 + { id: 'g1', groupByCol: 0, groupValue: 'A', rows: [1, 3], collapsed: false }, 190 + { id: 'g2', groupByCol: 0, groupValue: 'B', rows: [2, 4], collapsed: false }, 191 + ]; 192 + 193 + it('finds the group containing a row', () => { 194 + expect(findGroupForRow(groups, 3)!.id).toBe('g1'); 195 + expect(findGroupForRow(groups, 4)!.id).toBe('g2'); 196 + }); 197 + 198 + it('returns null for ungrouped row', () => { 199 + expect(findGroupForRow(groups, 99)).toBeNull(); 200 + }); 201 + }); 202 + 203 + describe('isGroupHeaderRow / isGroupFooterRow', () => { 204 + const groups: RowGroup[] = [ 205 + { id: 'g1', groupByCol: 0, groupValue: 'A', rows: [1, 2, 3], collapsed: false }, 206 + ]; 207 + 208 + it('identifies header row (first in group)', () => { 209 + expect(isGroupHeaderRow(groups, 1)).toBe(true); 210 + expect(isGroupHeaderRow(groups, 2)).toBe(false); 211 + }); 212 + 213 + it('identifies footer row (last in group)', () => { 214 + expect(isGroupFooterRow(groups, 3)).toBe(true); 215 + expect(isGroupFooterRow(groups, 1)).toBe(false); 216 + }); 217 + }); 218 + });