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

Configure Feed

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

Merge pull request 'feat: rich cells, pivot tables, chart embeds (#46, #44, #71)' (#137) from feat/rich-cells-pivots-charts into main

scott cd924dba ad5d0a47

+1119
+220
src/sheets/chart-embed.ts
··· 1 + /** 2 + * Chart Embed — embed chart specs from Sheets into Docs. 3 + * 4 + * Pure logic module: chart config building, data extraction, embed references. 5 + * Rendering (canvas/SVG) handled by the UI layer. 6 + */ 7 + 8 + export type ChartType = 'bar' | 'line' | 'pie' | 'scatter' | 'area' | 'donut'; 9 + 10 + export interface ChartDataSeries { 11 + label: string; 12 + values: number[]; 13 + color?: string; 14 + } 15 + 16 + export interface ChartConfig { 17 + id: string; 18 + type: ChartType; 19 + title: string; 20 + xLabels: string[]; 21 + series: ChartDataSeries[]; 22 + /** Source sheet ID for live updates */ 23 + sourceSheetId: string; 24 + /** Cell range reference like "A1:D10" */ 25 + sourceRange: string; 26 + } 27 + 28 + export interface ChartEmbed { 29 + chartId: string; 30 + documentId: string; 31 + /** Position in document (character offset) */ 32 + position: number; 33 + width: number; 34 + height: number; 35 + } 36 + 37 + let _chartCounter = 0; 38 + 39 + /** 40 + * Create a chart config from sheet data. 41 + */ 42 + export function createChartConfig( 43 + type: ChartType, 44 + title: string, 45 + xLabels: string[], 46 + series: ChartDataSeries[], 47 + sourceSheetId: string, 48 + sourceRange: string, 49 + ): ChartConfig { 50 + return { 51 + id: `chart-${Date.now()}-${++_chartCounter}`, 52 + type, 53 + title, 54 + xLabels, 55 + series, 56 + sourceSheetId, 57 + sourceRange, 58 + }; 59 + } 60 + 61 + /** 62 + * Extract chart data from a range of sheet cells. 63 + * Assumes first column is labels, remaining columns are series. 64 + */ 65 + export function extractChartData( 66 + cellValues: Map<string, unknown>, 67 + colToLetter: (col: number) => string, 68 + startRow: number, 69 + endRow: number, 70 + startCol: number, 71 + endCol: number, 72 + headerRow?: number, 73 + ): { xLabels: string[]; series: ChartDataSeries[] } { 74 + const xLabels: string[] = []; 75 + const seriesData: number[][] = []; 76 + const seriesLabels: string[] = []; 77 + 78 + // Extract header labels for series 79 + for (let c = startCol + 1; c <= endCol; c++) { 80 + if (headerRow !== undefined) { 81 + const key = `${colToLetter(c)}${headerRow}`; 82 + seriesLabels.push(String(cellValues.get(key) ?? `Series ${c - startCol}`)); 83 + } else { 84 + seriesLabels.push(`Series ${c - startCol}`); 85 + } 86 + seriesData.push([]); 87 + } 88 + 89 + // Extract data 90 + for (let r = startRow; r <= endRow; r++) { 91 + const labelKey = `${colToLetter(startCol)}${r}`; 92 + xLabels.push(String(cellValues.get(labelKey) ?? '')); 93 + 94 + for (let c = startCol + 1; c <= endCol; c++) { 95 + const key = `${colToLetter(c)}${r}`; 96 + const val = cellValues.get(key); 97 + seriesData[c - startCol - 1].push(typeof val === 'number' ? val : Number(val) || 0); 98 + } 99 + } 100 + 101 + const series: ChartDataSeries[] = seriesLabels.map((label, i) => ({ 102 + label, 103 + values: seriesData[i], 104 + })); 105 + 106 + return { xLabels, series }; 107 + } 108 + 109 + /** 110 + * Create an embed reference for placing a chart in a document. 111 + */ 112 + export function createChartEmbed( 113 + chartId: string, 114 + documentId: string, 115 + position: number, 116 + width = 600, 117 + height = 400, 118 + ): ChartEmbed { 119 + return { chartId, documentId, position, width, height }; 120 + } 121 + 122 + /** 123 + * Update chart data (e.g., when source sheet changes). 124 + */ 125 + export function updateChartData( 126 + config: ChartConfig, 127 + xLabels: string[], 128 + series: ChartDataSeries[], 129 + ): ChartConfig { 130 + return { ...config, xLabels, series }; 131 + } 132 + 133 + /** 134 + * Change chart type. 135 + */ 136 + export function changeChartType(config: ChartConfig, type: ChartType): ChartConfig { 137 + return { ...config, type }; 138 + } 139 + 140 + /** 141 + * Resize an embed. 142 + */ 143 + export function resizeEmbed( 144 + embed: ChartEmbed, 145 + width: number, 146 + height: number, 147 + ): ChartEmbed { 148 + return { ...embed, width: Math.max(100, width), height: Math.max(100, height) }; 149 + } 150 + 151 + /** 152 + * Compute bar chart layout (returns bar positions as percentages). 153 + */ 154 + export function computeBarLayout( 155 + series: ChartDataSeries[], 156 + ): Array<{ seriesIndex: number; barIndex: number; value: number; heightPct: number }> { 157 + const allValues = series.flatMap(s => s.values); 158 + const maxVal = Math.max(...allValues, 1); 159 + const result: Array<{ seriesIndex: number; barIndex: number; value: number; heightPct: number }> = []; 160 + 161 + for (let si = 0; si < series.length; si++) { 162 + for (let bi = 0; bi < series[si].values.length; bi++) { 163 + const value = series[si].values[bi]; 164 + result.push({ 165 + seriesIndex: si, 166 + barIndex: bi, 167 + value, 168 + heightPct: (value / maxVal) * 100, 169 + }); 170 + } 171 + } 172 + 173 + return result; 174 + } 175 + 176 + /** 177 + * Compute pie chart slices (angles in degrees). 178 + */ 179 + export function computePieSlices( 180 + values: number[], 181 + ): Array<{ index: number; value: number; startAngle: number; endAngle: number; percentage: number }> { 182 + const total = values.reduce((a, b) => a + b, 0); 183 + if (total === 0) return []; 184 + 185 + const slices: Array<{ index: number; value: number; startAngle: number; endAngle: number; percentage: number }> = []; 186 + let currentAngle = 0; 187 + 188 + for (let i = 0; i < values.length; i++) { 189 + const percentage = values[i] / total; 190 + const angle = percentage * 360; 191 + slices.push({ 192 + index: i, 193 + value: values[i], 194 + startAngle: currentAngle, 195 + endAngle: currentAngle + angle, 196 + percentage, 197 + }); 198 + currentAngle += angle; 199 + } 200 + 201 + return slices; 202 + } 203 + 204 + /** 205 + * Get the total data point count across all series. 206 + */ 207 + export function totalDataPoints(config: ChartConfig): number { 208 + return config.series.reduce((sum, s) => sum + s.values.length, 0); 209 + } 210 + 211 + /** 212 + * Validate a chart config has data. 213 + */ 214 + export function isChartValid(config: ChartConfig): boolean { 215 + return ( 216 + config.series.length > 0 && 217 + config.series.some(s => s.values.length > 0) && 218 + config.title.length > 0 219 + ); 220 + }
+213
src/sheets/pivot-table.ts
··· 1 + /** 2 + * Pivot Table — summarize sheet data by row/column groupings. 3 + * 4 + * Pure logic module: data aggregation, pivot computation, formatting. 5 + * DOM rendering handled in the sheets UI layer. 6 + */ 7 + 8 + export type AggregateFunction = 'sum' | 'count' | 'avg' | 'min' | 'max' | 'countDistinct'; 9 + 10 + export interface PivotConfig { 11 + /** Column indices to group rows by */ 12 + rowFields: number[]; 13 + /** Column indices to group columns by */ 14 + colFields: number[]; 15 + /** Column index and aggregation for values */ 16 + valueField: number; 17 + aggregation: AggregateFunction; 18 + } 19 + 20 + export interface PivotCell { 21 + value: number; 22 + count: number; 23 + } 24 + 25 + export interface PivotResult { 26 + /** Unique row group keys */ 27 + rowKeys: string[][]; 28 + /** Unique column group keys */ 29 + colKeys: string[][]; 30 + /** Pivot cells indexed as [rowKeyIndex][colKeyIndex] */ 31 + cells: (PivotCell | null)[][]; 32 + /** Row totals */ 33 + rowTotals: PivotCell[]; 34 + /** Column totals */ 35 + colTotals: PivotCell[]; 36 + /** Grand total */ 37 + grandTotal: PivotCell; 38 + } 39 + 40 + /** 41 + * Extract a grouping key from row data for given field indices. 42 + */ 43 + export function extractKey( 44 + row: Map<string, unknown>, 45 + fields: number[], 46 + colToLetter: (col: number) => string, 47 + rowIndex: number, 48 + ): string[] { 49 + return fields.map(f => { 50 + const cellId = `${colToLetter(f)}${rowIndex}`; 51 + return String(row.get(cellId) ?? ''); 52 + }); 53 + } 54 + 55 + /** 56 + * Serialize a key array to a string for Map indexing. 57 + */ 58 + export function keyToString(key: string[]): string { 59 + return key.join('\0'); 60 + } 61 + 62 + /** 63 + * Aggregate a set of numeric values. 64 + */ 65 + export function aggregate(values: number[], fn: AggregateFunction): number { 66 + if (values.length === 0) return 0; 67 + 68 + switch (fn) { 69 + case 'sum': 70 + return values.reduce((a, b) => a + b, 0); 71 + case 'count': 72 + return values.length; 73 + case 'avg': 74 + return values.reduce((a, b) => a + b, 0) / values.length; 75 + case 'min': 76 + return Math.min(...values); 77 + case 'max': 78 + return Math.max(...values); 79 + case 'countDistinct': 80 + return new Set(values).size; 81 + } 82 + } 83 + 84 + /** 85 + * Compute pivot table from sheet data. 86 + */ 87 + export function computePivot( 88 + rows: Map<string, unknown>[], 89 + config: PivotConfig, 90 + colToLetter: (col: number) => string, 91 + startRow = 1, 92 + ): PivotResult { 93 + // Collect unique keys and buckets 94 + const rowKeySet = new Map<string, string[]>(); 95 + const colKeySet = new Map<string, string[]>(); 96 + const buckets = new Map<string, number[]>(); 97 + 98 + for (let i = 0; i < rows.length; i++) { 99 + const row = rows[i]; 100 + const rowIndex = startRow + i; 101 + 102 + const rk = extractKey(row, config.rowFields, colToLetter, rowIndex); 103 + const ck = extractKey(row, config.colFields, colToLetter, rowIndex); 104 + const rkStr = keyToString(rk); 105 + const ckStr = keyToString(ck); 106 + 107 + if (!rowKeySet.has(rkStr)) rowKeySet.set(rkStr, rk); 108 + if (!colKeySet.has(ckStr)) colKeySet.set(ckStr, ck); 109 + 110 + const valueKey = `${colToLetter(config.valueField)}${rowIndex}`; 111 + const rawValue = row.get(valueKey); 112 + const numValue = typeof rawValue === 'number' ? rawValue : Number(rawValue) || 0; 113 + 114 + const bucketKey = `${rkStr}\x01${ckStr}`; 115 + const bucket = buckets.get(bucketKey) || []; 116 + bucket.push(numValue); 117 + buckets.set(bucketKey, bucket); 118 + } 119 + 120 + const rowKeys = [...rowKeySet.values()]; 121 + const colKeys = [...colKeySet.values()]; 122 + 123 + // Build cells 124 + const cells: (PivotCell | null)[][] = []; 125 + const rowTotals: PivotCell[] = []; 126 + const colTotals: PivotCell[] = colKeys.map(() => ({ value: 0, count: 0 })); 127 + let grandValues: number[] = []; 128 + 129 + for (let ri = 0; ri < rowKeys.length; ri++) { 130 + const row: (PivotCell | null)[] = []; 131 + const allRowValues: number[] = []; 132 + 133 + for (let ci = 0; ci < colKeys.length; ci++) { 134 + const bucketKey = `${keyToString(rowKeys[ri])}\x01${keyToString(colKeys[ci])}`; 135 + const values = buckets.get(bucketKey); 136 + 137 + if (values && values.length > 0) { 138 + row.push({ value: aggregate(values, config.aggregation), count: values.length }); 139 + allRowValues.push(...values); 140 + 141 + // Accumulate for col totals 142 + const colBucket = buckets.get(bucketKey)!; 143 + colTotals[ci] = { 144 + value: 0, // Will recompute 145 + count: colTotals[ci].count + colBucket.length, 146 + }; 147 + } else { 148 + row.push(null); 149 + } 150 + } 151 + 152 + cells.push(row); 153 + rowTotals.push({ 154 + value: aggregate(allRowValues, config.aggregation), 155 + count: allRowValues.length, 156 + }); 157 + grandValues.push(...allRowValues); 158 + } 159 + 160 + // Compute column totals properly 161 + for (let ci = 0; ci < colKeys.length; ci++) { 162 + const colValues: number[] = []; 163 + for (let ri = 0; ri < rowKeys.length; ri++) { 164 + const bucketKey = `${keyToString(rowKeys[ri])}\x01${keyToString(colKeys[ci])}`; 165 + const values = buckets.get(bucketKey); 166 + if (values) colValues.push(...values); 167 + } 168 + colTotals[ci] = { 169 + value: aggregate(colValues, config.aggregation), 170 + count: colValues.length, 171 + }; 172 + } 173 + 174 + return { 175 + rowKeys, 176 + colKeys, 177 + cells, 178 + rowTotals, 179 + colTotals, 180 + grandTotal: { 181 + value: aggregate(grandValues, config.aggregation), 182 + count: grandValues.length, 183 + }, 184 + }; 185 + } 186 + 187 + /** 188 + * Format an aggregate value for display. 189 + */ 190 + export function formatAggregateValue(value: number, fn: AggregateFunction): string { 191 + switch (fn) { 192 + case 'count': 193 + case 'countDistinct': 194 + return String(Math.round(value)); 195 + case 'avg': 196 + return value.toFixed(2); 197 + default: 198 + return Number.isInteger(value) ? String(value) : value.toFixed(2); 199 + } 200 + } 201 + 202 + /** 203 + * Get a flat list of all non-null pivot cell values. 204 + */ 205 + export function flatPivotValues(result: PivotResult): number[] { 206 + const values: number[] = []; 207 + for (const row of result.cells) { 208 + for (const cell of row) { 209 + if (cell) values.push(cell.value); 210 + } 211 + } 212 + return values; 213 + }
+203
src/sheets/rich-cells.ts
··· 1 + /** 2 + * Rich Cell Types — structured cell content beyond plain text. 3 + * 4 + * Pure logic module: cell type detection, rendering hints, value extraction. 5 + * DOM rendering handled in the sheets UI layer. 6 + */ 7 + 8 + export type RichCellType = 9 + | 'text' 10 + | 'number' 11 + | 'currency' 12 + | 'percentage' 13 + | 'date' 14 + | 'boolean' 15 + | 'url' 16 + | 'email' 17 + | 'rating' 18 + | 'progress' 19 + | 'tag'; 20 + 21 + export interface RichCellConfig { 22 + type: RichCellType; 23 + /** Currency symbol for currency type */ 24 + currencySymbol?: string; 25 + /** Max stars for rating type */ 26 + maxRating?: number; 27 + /** Allowed tag values for tag type */ 28 + tagOptions?: string[]; 29 + /** Date format string */ 30 + dateFormat?: string; 31 + /** Number of decimal places */ 32 + decimals?: number; 33 + } 34 + 35 + const URL_REGEX = /^https?:\/\/[^\s]+$/i; 36 + const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; 37 + 38 + /** 39 + * Auto-detect the rich cell type from a raw value. 40 + */ 41 + export function detectCellType(value: unknown): RichCellType { 42 + if (value === null || value === undefined || value === '') return 'text'; 43 + if (typeof value === 'boolean') return 'boolean'; 44 + if (typeof value === 'number') return 'number'; 45 + 46 + const str = String(value); 47 + 48 + if (str === 'true' || str === 'false') return 'boolean'; 49 + if (URL_REGEX.test(str)) return 'url'; 50 + if (EMAIL_REGEX.test(str)) return 'email'; 51 + 52 + // Percentage: "75%" or "75.5%" 53 + if (/^-?\d+(\.\d+)?%$/.test(str)) return 'percentage'; 54 + 55 + // Currency: "$1,234.56" or "€100" 56 + if (/^[$€£¥]-?\d[\d,]*(\.\d+)?$/.test(str)) return 'currency'; 57 + 58 + // Date-like: ISO or common formats 59 + if (/^\d{4}-\d{2}-\d{2}/.test(str)) { 60 + const d = new Date(str); 61 + if (!isNaN(d.getTime())) return 'date'; 62 + } 63 + 64 + // Number-like string 65 + if (/^-?\d[\d,]*(\.\d+)?$/.test(str) && !isNaN(Number(str.replace(/,/g, '')))) return 'number'; 66 + 67 + return 'text'; 68 + } 69 + 70 + /** 71 + * Parse a value according to its rich type. 72 + */ 73 + export function parseRichValue(value: unknown, type: RichCellType): unknown { 74 + if (value === null || value === undefined) return null; 75 + const str = String(value); 76 + 77 + switch (type) { 78 + case 'number': 79 + return Number(str.replace(/,/g, '')); 80 + case 'currency': 81 + return Number(str.replace(/[^-\d.]/g, '')); 82 + case 'percentage': 83 + return Number(str.replace('%', '')) / 100; 84 + case 'boolean': 85 + return str === 'true' || str === '1' || value === true; 86 + case 'date': 87 + return new Date(str); 88 + case 'rating': 89 + return Math.max(0, Math.min(Number(str) || 0, 5)); 90 + case 'progress': 91 + return Math.max(0, Math.min(Number(str) || 0, 100)); 92 + default: 93 + return str; 94 + } 95 + } 96 + 97 + /** 98 + * Format a value for display according to its rich type. 99 + */ 100 + export function formatRichValue( 101 + value: unknown, 102 + config: RichCellConfig, 103 + ): string { 104 + if (value === null || value === undefined || value === '') return ''; 105 + 106 + switch (config.type) { 107 + case 'currency': { 108 + const num = typeof value === 'number' ? value : Number(String(value).replace(/[^-\d.]/g, '')); 109 + const symbol = config.currencySymbol ?? '$'; 110 + const decimals = config.decimals ?? 2; 111 + return `${symbol}${num.toFixed(decimals)}`; 112 + } 113 + case 'percentage': { 114 + const num = typeof value === 'number' ? value : Number(String(value).replace('%', '')) / 100; 115 + const decimals = config.decimals ?? 0; 116 + return `${(num * 100).toFixed(decimals)}%`; 117 + } 118 + case 'boolean': 119 + return value === true || value === 'true' ? 'Yes' : 'No'; 120 + case 'rating': { 121 + const stars = Math.round(Number(value) || 0); 122 + const max = config.maxRating ?? 5; 123 + return '\u2605'.repeat(Math.min(stars, max)) + '\u2606'.repeat(Math.max(0, max - stars)); 124 + } 125 + case 'progress': { 126 + const pct = Number(value) || 0; 127 + return `${Math.round(pct)}%`; 128 + } 129 + case 'date': { 130 + const d = value instanceof Date ? value : new Date(String(value)); 131 + if (isNaN(d.getTime())) return String(value); 132 + return d.toLocaleDateString(); 133 + } 134 + case 'url': 135 + return String(value); 136 + case 'email': 137 + return String(value); 138 + case 'number': { 139 + const num = typeof value === 'number' ? value : Number(value); 140 + if (isNaN(num)) return String(value); 141 + const decimals = config.decimals ?? undefined; 142 + return decimals !== undefined ? num.toFixed(decimals) : String(num); 143 + } 144 + default: 145 + return String(value); 146 + } 147 + } 148 + 149 + /** 150 + * Validate a value against a rich cell config. 151 + */ 152 + export function validateRichValue(value: unknown, config: RichCellConfig): boolean { 153 + if (value === null || value === undefined || value === '') return true; 154 + const str = String(value); 155 + 156 + switch (config.type) { 157 + case 'number': 158 + case 'currency': 159 + case 'percentage': 160 + case 'progress': 161 + case 'rating': 162 + return !isNaN(Number(str.replace(/[^-\d.]/g, ''))); 163 + case 'boolean': 164 + return ['true', 'false', '1', '0', 'yes', 'no'].includes(str.toLowerCase()); 165 + case 'email': 166 + return EMAIL_REGEX.test(str); 167 + case 'url': 168 + return URL_REGEX.test(str); 169 + case 'date': { 170 + const d = new Date(str); 171 + return !isNaN(d.getTime()); 172 + } 173 + case 'tag': 174 + return !config.tagOptions || config.tagOptions.includes(str); 175 + default: 176 + return true; 177 + } 178 + } 179 + 180 + /** 181 + * Get CSS class name for a rich cell type (for styling). 182 + */ 183 + export function richCellClass(type: RichCellType): string { 184 + return `rich-cell-${type}`; 185 + } 186 + 187 + /** 188 + * Create a default config for a given type. 189 + */ 190 + export function defaultConfig(type: RichCellType): RichCellConfig { 191 + switch (type) { 192 + case 'currency': 193 + return { type, currencySymbol: '$', decimals: 2 }; 194 + case 'rating': 195 + return { type, maxRating: 5 }; 196 + case 'percentage': 197 + return { type, decimals: 0 }; 198 + case 'number': 199 + return { type, decimals: 2 }; 200 + default: 201 + return { type }; 202 + } 203 + }
+175
tests/chart-embed.test.ts
··· 1 + import { describe, it, expect } from 'vitest'; 2 + import { 3 + createChartConfig, 4 + extractChartData, 5 + createChartEmbed, 6 + updateChartData, 7 + changeChartType, 8 + resizeEmbed, 9 + computeBarLayout, 10 + computePieSlices, 11 + totalDataPoints, 12 + isChartValid, 13 + } from '../src/sheets/chart-embed.js'; 14 + 15 + const colToLetter = (c: number) => String.fromCharCode(65 + c); 16 + 17 + describe('createChartConfig', () => { 18 + it('creates config with unique ID', () => { 19 + const c1 = createChartConfig('bar', 'Sales', ['Q1', 'Q2'], [{ label: 'Rev', values: [100, 200] }], 's1', 'A1:B3'); 20 + const c2 = createChartConfig('bar', 'Sales', ['Q1'], [{ label: 'Rev', values: [100] }], 's1', 'A1:B2'); 21 + expect(c1.id).not.toBe(c2.id); 22 + expect(c1.type).toBe('bar'); 23 + expect(c1.title).toBe('Sales'); 24 + expect(c1.series).toHaveLength(1); 25 + }); 26 + }); 27 + 28 + describe('extractChartData', () => { 29 + it('extracts labels and series from cells', () => { 30 + const values = new Map<string, unknown>([ 31 + ['A0', 'Month'], ['B0', 'Revenue'], 32 + ['A1', 'Jan'], ['B1', 100], 33 + ['A2', 'Feb'], ['B2', 200], 34 + ['A3', 'Mar'], ['B3', 150], 35 + ]); 36 + const { xLabels, series } = extractChartData(values, colToLetter, 1, 3, 0, 1, 0); 37 + expect(xLabels).toEqual(['Jan', 'Feb', 'Mar']); 38 + expect(series).toHaveLength(1); 39 + expect(series[0].label).toBe('Revenue'); 40 + expect(series[0].values).toEqual([100, 200, 150]); 41 + }); 42 + 43 + it('defaults series labels without header row', () => { 44 + const values = new Map<string, unknown>([ 45 + ['A1', 'X'], ['B1', 10], 46 + ]); 47 + const { series } = extractChartData(values, colToLetter, 1, 1, 0, 1); 48 + expect(series[0].label).toBe('Series 1'); 49 + }); 50 + 51 + it('converts non-numeric values to 0', () => { 52 + const values = new Map<string, unknown>([ 53 + ['A1', 'X'], ['B1', 'abc'], 54 + ]); 55 + const { series } = extractChartData(values, colToLetter, 1, 1, 0, 1); 56 + expect(series[0].values).toEqual([0]); 57 + }); 58 + }); 59 + 60 + describe('createChartEmbed', () => { 61 + it('creates embed with defaults', () => { 62 + const embed = createChartEmbed('c1', 'd1', 100); 63 + expect(embed.width).toBe(600); 64 + expect(embed.height).toBe(400); 65 + expect(embed.position).toBe(100); 66 + }); 67 + }); 68 + 69 + describe('updateChartData', () => { 70 + it('replaces data in config', () => { 71 + const config = createChartConfig('bar', 'Test', ['A'], [{ label: 'S', values: [1] }], 's1', 'A1:B2'); 72 + const updated = updateChartData(config, ['B', 'C'], [{ label: 'S2', values: [2, 3] }]); 73 + expect(updated.xLabels).toEqual(['B', 'C']); 74 + expect(updated.series[0].label).toBe('S2'); 75 + expect(updated.id).toBe(config.id); 76 + }); 77 + }); 78 + 79 + describe('changeChartType', () => { 80 + it('changes type', () => { 81 + const config = createChartConfig('bar', 'Test', [], [], 's1', 'A1:A1'); 82 + expect(changeChartType(config, 'pie').type).toBe('pie'); 83 + }); 84 + }); 85 + 86 + describe('resizeEmbed', () => { 87 + it('resizes with minimum', () => { 88 + const embed = createChartEmbed('c1', 'd1', 0, 600, 400); 89 + const resized = resizeEmbed(embed, 50, 50); 90 + expect(resized.width).toBe(100); 91 + expect(resized.height).toBe(100); 92 + }); 93 + 94 + it('allows larger sizes', () => { 95 + const embed = createChartEmbed('c1', 'd1', 0); 96 + const resized = resizeEmbed(embed, 800, 600); 97 + expect(resized.width).toBe(800); 98 + expect(resized.height).toBe(600); 99 + }); 100 + }); 101 + 102 + describe('computeBarLayout', () => { 103 + it('computes bar heights as percentages', () => { 104 + const series = [{ label: 'A', values: [50, 100] }]; 105 + const bars = computeBarLayout(series); 106 + expect(bars).toHaveLength(2); 107 + expect(bars[0].heightPct).toBe(50); 108 + expect(bars[1].heightPct).toBe(100); 109 + }); 110 + 111 + it('handles multiple series', () => { 112 + const series = [ 113 + { label: 'A', values: [100] }, 114 + { label: 'B', values: [50] }, 115 + ]; 116 + const bars = computeBarLayout(series); 117 + expect(bars).toHaveLength(2); 118 + expect(bars[0].seriesIndex).toBe(0); 119 + expect(bars[1].seriesIndex).toBe(1); 120 + }); 121 + }); 122 + 123 + describe('computePieSlices', () => { 124 + it('computes slices totaling 360 degrees', () => { 125 + const slices = computePieSlices([25, 25, 50]); 126 + expect(slices).toHaveLength(3); 127 + expect(slices[0].startAngle).toBe(0); 128 + expect(slices[2].endAngle).toBeCloseTo(360); 129 + expect(slices[0].percentage).toBeCloseTo(0.25); 130 + expect(slices[2].percentage).toBeCloseTo(0.5); 131 + }); 132 + 133 + it('returns empty for all zeros', () => { 134 + expect(computePieSlices([0, 0, 0])).toEqual([]); 135 + }); 136 + 137 + it('handles single value', () => { 138 + const slices = computePieSlices([100]); 139 + expect(slices).toHaveLength(1); 140 + expect(slices[0].percentage).toBe(1); 141 + expect(slices[0].endAngle).toBe(360); 142 + }); 143 + }); 144 + 145 + describe('totalDataPoints', () => { 146 + it('counts across series', () => { 147 + const config = createChartConfig('bar', 'Test', ['A', 'B'], [ 148 + { label: 'S1', values: [1, 2] }, 149 + { label: 'S2', values: [3, 4, 5] }, 150 + ], 's1', 'A1:C3'); 151 + expect(totalDataPoints(config)).toBe(5); 152 + }); 153 + }); 154 + 155 + describe('isChartValid', () => { 156 + it('returns true for valid chart', () => { 157 + const config = createChartConfig('bar', 'Sales', ['Q1'], [{ label: 'Rev', values: [100] }], 's1', 'A1:B2'); 158 + expect(isChartValid(config)).toBe(true); 159 + }); 160 + 161 + it('returns false for empty title', () => { 162 + const config = createChartConfig('bar', '', ['Q1'], [{ label: 'Rev', values: [100] }], 's1', 'A1:B2'); 163 + expect(isChartValid(config)).toBe(false); 164 + }); 165 + 166 + it('returns false for no series', () => { 167 + const config = createChartConfig('bar', 'Sales', ['Q1'], [], 's1', 'A1:B2'); 168 + expect(isChartValid(config)).toBe(false); 169 + }); 170 + 171 + it('returns false for empty series values', () => { 172 + const config = createChartConfig('bar', 'Sales', [], [{ label: 'Rev', values: [] }], 's1', 'A1:B2'); 173 + expect(isChartValid(config)).toBe(false); 174 + }); 175 + });
+139
tests/pivot-table.test.ts
··· 1 + import { describe, it, expect } from 'vitest'; 2 + import { 3 + extractKey, 4 + keyToString, 5 + aggregate, 6 + computePivot, 7 + formatAggregateValue, 8 + flatPivotValues, 9 + type PivotConfig, 10 + } from '../src/sheets/pivot-table.js'; 11 + 12 + const colToLetter = (c: number) => String.fromCharCode(65 + c); 13 + 14 + describe('extractKey', () => { 15 + it('extracts key from row', () => { 16 + const row = new Map<string, unknown>([['A1', 'East'], ['B1', 'Q1']]); 17 + expect(extractKey(row, [0, 1], colToLetter, 1)).toEqual(['East', 'Q1']); 18 + }); 19 + 20 + it('handles missing values', () => { 21 + const row = new Map<string, unknown>(); 22 + expect(extractKey(row, [0], colToLetter, 1)).toEqual(['']); 23 + }); 24 + }); 25 + 26 + describe('keyToString', () => { 27 + it('joins with null separator', () => { 28 + expect(keyToString(['a', 'b'])).toBe('a\0b'); 29 + }); 30 + }); 31 + 32 + describe('aggregate', () => { 33 + it('sum', () => expect(aggregate([1, 2, 3], 'sum')).toBe(6)); 34 + it('count', () => expect(aggregate([1, 2, 3], 'count')).toBe(3)); 35 + it('avg', () => expect(aggregate([2, 4, 6], 'avg')).toBe(4)); 36 + it('min', () => expect(aggregate([3, 1, 2], 'min')).toBe(1)); 37 + it('max', () => expect(aggregate([3, 1, 2], 'max')).toBe(3)); 38 + it('countDistinct', () => expect(aggregate([1, 2, 2, 3], 'countDistinct')).toBe(3)); 39 + it('empty returns 0', () => expect(aggregate([], 'sum')).toBe(0)); 40 + }); 41 + 42 + describe('computePivot', () => { 43 + // Data: Region (A), Quarter (B), Sales (C) 44 + const rows: Map<string, unknown>[] = [ 45 + new Map([['A1', 'East'], ['B1', 'Q1'], ['C1', 100]]), 46 + new Map([['A2', 'East'], ['B2', 'Q2'], ['C2', 150]]), 47 + new Map([['A3', 'West'], ['B3', 'Q1'], ['C3', 200]]), 48 + new Map([['A4', 'West'], ['B4', 'Q2'], ['C4', 250]]), 49 + new Map([['A5', 'East'], ['B5', 'Q1'], ['C5', 50]]), 50 + ]; 51 + 52 + const config: PivotConfig = { 53 + rowFields: [0], // Region 54 + colFields: [1], // Quarter 55 + valueField: 2, // Sales 56 + aggregation: 'sum', 57 + }; 58 + 59 + it('computes row and column keys', () => { 60 + const result = computePivot(rows, config, colToLetter); 61 + expect(result.rowKeys).toHaveLength(2); // East, West 62 + expect(result.colKeys).toHaveLength(2); // Q1, Q2 63 + }); 64 + 65 + it('computes cell values', () => { 66 + const result = computePivot(rows, config, colToLetter); 67 + // Find East/Q1: 100 + 50 = 150 68 + const eastIdx = result.rowKeys.findIndex(k => k[0] === 'East'); 69 + const q1Idx = result.colKeys.findIndex(k => k[0] === 'Q1'); 70 + expect(result.cells[eastIdx][q1Idx]!.value).toBe(150); 71 + }); 72 + 73 + it('computes row totals', () => { 74 + const result = computePivot(rows, config, colToLetter); 75 + const eastIdx = result.rowKeys.findIndex(k => k[0] === 'East'); 76 + expect(result.rowTotals[eastIdx].value).toBe(300); // 100+150+50 77 + }); 78 + 79 + it('computes column totals', () => { 80 + const result = computePivot(rows, config, colToLetter); 81 + const q1Idx = result.colKeys.findIndex(k => k[0] === 'Q1'); 82 + expect(result.colTotals[q1Idx].value).toBe(350); // 100+200+50 83 + }); 84 + 85 + it('computes grand total', () => { 86 + const result = computePivot(rows, config, colToLetter); 87 + expect(result.grandTotal.value).toBe(750); 88 + expect(result.grandTotal.count).toBe(5); 89 + }); 90 + 91 + it('works with avg aggregation', () => { 92 + const avgConfig = { ...config, aggregation: 'avg' as const }; 93 + const result = computePivot(rows, avgConfig, colToLetter); 94 + const eastIdx = result.rowKeys.findIndex(k => k[0] === 'East'); 95 + const q1Idx = result.colKeys.findIndex(k => k[0] === 'Q1'); 96 + expect(result.cells[eastIdx][q1Idx]!.value).toBe(75); // (100+50)/2 97 + }); 98 + 99 + it('handles empty data', () => { 100 + const result = computePivot([], config, colToLetter); 101 + expect(result.rowKeys).toHaveLength(0); 102 + expect(result.colKeys).toHaveLength(0); 103 + expect(result.grandTotal.count).toBe(0); 104 + }); 105 + }); 106 + 107 + describe('formatAggregateValue', () => { 108 + it('formats count as integer', () => { 109 + expect(formatAggregateValue(5, 'count')).toBe('5'); 110 + }); 111 + 112 + it('formats avg with decimals', () => { 113 + expect(formatAggregateValue(3.14159, 'avg')).toBe('3.14'); 114 + }); 115 + 116 + it('formats integer sum without decimals', () => { 117 + expect(formatAggregateValue(100, 'sum')).toBe('100'); 118 + }); 119 + 120 + it('formats decimal sum with decimals', () => { 121 + expect(formatAggregateValue(100.5, 'sum')).toBe('100.50'); 122 + }); 123 + }); 124 + 125 + describe('flatPivotValues', () => { 126 + it('extracts all cell values', () => { 127 + const result = computePivot( 128 + [ 129 + new Map([['A1', 'X'], ['B1', 'Y'], ['C1', 10]]), 130 + new Map([['A2', 'X'], ['B2', 'Z'], ['C2', 20]]), 131 + ], 132 + { rowFields: [0], colFields: [1], valueField: 2, aggregation: 'sum' }, 133 + colToLetter, 134 + ); 135 + const values = flatPivotValues(result); 136 + expect(values).toContain(10); 137 + expect(values).toContain(20); 138 + }); 139 + });
+169
tests/rich-cells.test.ts
··· 1 + import { describe, it, expect } from 'vitest'; 2 + import { 3 + detectCellType, 4 + parseRichValue, 5 + formatRichValue, 6 + validateRichValue, 7 + richCellClass, 8 + defaultConfig, 9 + type RichCellConfig, 10 + } from '../src/sheets/rich-cells.js'; 11 + 12 + describe('detectCellType', () => { 13 + it('detects number', () => { 14 + expect(detectCellType(42)).toBe('number'); 15 + expect(detectCellType('1234')).toBe('number'); 16 + expect(detectCellType('1,234')).toBe('number'); 17 + }); 18 + 19 + it('detects boolean', () => { 20 + expect(detectCellType(true)).toBe('boolean'); 21 + expect(detectCellType('true')).toBe('boolean'); 22 + expect(detectCellType('false')).toBe('boolean'); 23 + }); 24 + 25 + it('detects url', () => { 26 + expect(detectCellType('https://example.com')).toBe('url'); 27 + expect(detectCellType('http://foo.bar/baz')).toBe('url'); 28 + }); 29 + 30 + it('detects email', () => { 31 + expect(detectCellType('user@example.com')).toBe('email'); 32 + }); 33 + 34 + it('detects percentage', () => { 35 + expect(detectCellType('75%')).toBe('percentage'); 36 + expect(detectCellType('33.5%')).toBe('percentage'); 37 + }); 38 + 39 + it('detects currency', () => { 40 + expect(detectCellType('$100')).toBe('currency'); 41 + expect(detectCellType('€1,234.56')).toBe('currency'); 42 + }); 43 + 44 + it('detects date', () => { 45 + expect(detectCellType('2026-03-15')).toBe('date'); 46 + expect(detectCellType('2026-03-15T12:00:00')).toBe('date'); 47 + }); 48 + 49 + it('returns text for empty/null', () => { 50 + expect(detectCellType('')).toBe('text'); 51 + expect(detectCellType(null)).toBe('text'); 52 + expect(detectCellType(undefined)).toBe('text'); 53 + }); 54 + 55 + it('returns text for plain strings', () => { 56 + expect(detectCellType('Hello world')).toBe('text'); 57 + }); 58 + }); 59 + 60 + describe('parseRichValue', () => { 61 + it('parses number with commas', () => { 62 + expect(parseRichValue('1,234', 'number')).toBe(1234); 63 + }); 64 + 65 + it('parses currency', () => { 66 + expect(parseRichValue('$1,234.56', 'currency')).toBe(1234.56); 67 + }); 68 + 69 + it('parses percentage to decimal', () => { 70 + expect(parseRichValue('75%', 'percentage')).toBe(0.75); 71 + }); 72 + 73 + it('parses boolean', () => { 74 + expect(parseRichValue('true', 'boolean')).toBe(true); 75 + expect(parseRichValue('false', 'boolean')).toBe(false); 76 + }); 77 + 78 + it('clamps rating 0-5', () => { 79 + expect(parseRichValue('3', 'rating')).toBe(3); 80 + expect(parseRichValue('10', 'rating')).toBe(5); 81 + }); 82 + 83 + it('clamps progress 0-100', () => { 84 + expect(parseRichValue('150', 'progress')).toBe(100); 85 + }); 86 + 87 + it('returns null for null input', () => { 88 + expect(parseRichValue(null, 'text')).toBeNull(); 89 + }); 90 + }); 91 + 92 + describe('formatRichValue', () => { 93 + it('formats currency', () => { 94 + expect(formatRichValue(1234.5, { type: 'currency', currencySymbol: '$', decimals: 2 })).toBe('$1234.50'); 95 + }); 96 + 97 + it('formats percentage', () => { 98 + expect(formatRichValue(0.75, { type: 'percentage', decimals: 0 })).toBe('75%'); 99 + }); 100 + 101 + it('formats boolean', () => { 102 + expect(formatRichValue(true, { type: 'boolean' })).toBe('Yes'); 103 + expect(formatRichValue(false, { type: 'boolean' })).toBe('No'); 104 + }); 105 + 106 + it('formats rating as stars', () => { 107 + const result = formatRichValue(3, { type: 'rating', maxRating: 5 }); 108 + expect(result).toBe('\u2605\u2605\u2605\u2606\u2606'); 109 + }); 110 + 111 + it('formats progress', () => { 112 + expect(formatRichValue(75, { type: 'progress' })).toBe('75%'); 113 + }); 114 + 115 + it('formats number with decimals', () => { 116 + expect(formatRichValue(3.14159, { type: 'number', decimals: 2 })).toBe('3.14'); 117 + }); 118 + 119 + it('returns empty for null', () => { 120 + expect(formatRichValue(null, { type: 'text' })).toBe(''); 121 + }); 122 + }); 123 + 124 + describe('validateRichValue', () => { 125 + it('validates email', () => { 126 + expect(validateRichValue('user@example.com', { type: 'email' })).toBe(true); 127 + expect(validateRichValue('notanemail', { type: 'email' })).toBe(false); 128 + }); 129 + 130 + it('validates url', () => { 131 + expect(validateRichValue('https://example.com', { type: 'url' })).toBe(true); 132 + expect(validateRichValue('notaurl', { type: 'url' })).toBe(false); 133 + }); 134 + 135 + it('validates boolean', () => { 136 + expect(validateRichValue('true', { type: 'boolean' })).toBe(true); 137 + expect(validateRichValue('maybe', { type: 'boolean' })).toBe(false); 138 + }); 139 + 140 + it('validates tag options', () => { 141 + expect(validateRichValue('urgent', { type: 'tag', tagOptions: ['urgent', 'normal'] })).toBe(true); 142 + expect(validateRichValue('invalid', { type: 'tag', tagOptions: ['urgent', 'normal'] })).toBe(false); 143 + }); 144 + 145 + it('allows empty values', () => { 146 + expect(validateRichValue('', { type: 'email' })).toBe(true); 147 + expect(validateRichValue(null, { type: 'number' })).toBe(true); 148 + }); 149 + }); 150 + 151 + describe('richCellClass', () => { 152 + it('returns class name', () => { 153 + expect(richCellClass('currency')).toBe('rich-cell-currency'); 154 + expect(richCellClass('boolean')).toBe('rich-cell-boolean'); 155 + }); 156 + }); 157 + 158 + describe('defaultConfig', () => { 159 + it('returns currency config', () => { 160 + const c = defaultConfig('currency'); 161 + expect(c.currencySymbol).toBe('$'); 162 + expect(c.decimals).toBe(2); 163 + }); 164 + 165 + it('returns rating config', () => { 166 + const c = defaultConfig('rating'); 167 + expect(c.maxRating).toBe(5); 168 + }); 169 + });