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

Configure Feed

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

fix(sheets): freeze pane glitches, date persistence, save/reload reliability

Freeze panes:
- Add explicit background on frozen row headers (prevents content bleed-through)
- Combine box-shadow on corner cells with both freeze borders

Dates:
- Convert Date objects to timestamps at import and in setCellData
(Date objects lose their prototype during Yjs serialization)
- Show formatted dates in cell editor and formula bar
- Parse date strings back to timestamps on commit for date-format cells
- Guard in formatCell for invalid date timestamps

Save/reload:
- Fix import race: await handleImportFile so __importInProgress flag
stays set until the async import actually finishes
- Skip redundant snapshot load on WebSocket reconnect (doc already has state)

Closes #187, closes #188, closes #189

+79 -22
+1 -1
package.json
··· 1 1 { 2 2 "name": "tools", 3 - "version": "0.8.1", 3 + "version": "0.8.2", 4 4 "private": true, 5 5 "type": "module", 6 6 "scripts": {
+16
src/css/app.css
··· 1920 1920 top from inline style). */ 1921 1921 .sheet-grid th.row-header.frozen-row { 1922 1922 z-index: 4; 1923 + background: var(--color-surface); 1923 1924 } 1924 1925 1925 1926 /* Freeze boundary: thicker line with shadow for clear visual separation */ ··· 1935 1936 box-shadow: 2px 0 4px oklch(0.48 0.1 195 / 0.15); 1936 1937 } 1937 1938 1939 + /* Corner cells with both borders need combined box-shadow (CSS overwrites, not combines) */ 1940 + .sheet-grid td.freeze-border-bottom.freeze-border-right, 1941 + .sheet-grid th.freeze-border-bottom.freeze-border-right { 1942 + box-shadow: 2px 0 4px oklch(0.48 0.1 195 / 0.15), 0 2px 4px oklch(0.48 0.1 195 / 0.15); 1943 + } 1944 + 1938 1945 [data-theme="dark"] .sheet-grid td.freeze-border-bottom, 1939 1946 [data-theme="dark"] .sheet-grid th.freeze-border-bottom { 1940 1947 border-bottom-color: oklch(0.6 0.1 195 / 0.7); ··· 1947 1954 box-shadow: 2px 0 6px oklch(0.05 0.005 195 / 0.4); 1948 1955 } 1949 1956 1957 + [data-theme="dark"] .sheet-grid td.freeze-border-bottom.freeze-border-right, 1958 + [data-theme="dark"] .sheet-grid th.freeze-border-bottom.freeze-border-right { 1959 + box-shadow: 2px 0 6px oklch(0.05 0.005 195 / 0.4), 0 2px 6px oklch(0.05 0.005 195 / 0.4); 1960 + } 1961 + 1950 1962 @media (prefers-color-scheme: dark) { 1951 1963 :root:not([data-theme="light"]) .sheet-grid td.freeze-border-bottom, 1952 1964 :root:not([data-theme="light"]) .sheet-grid th.freeze-border-bottom { ··· 1957 1969 :root:not([data-theme="light"]) .sheet-grid th.freeze-border-right { 1958 1970 border-right-color: oklch(0.6 0.1 195 / 0.7); 1959 1971 box-shadow: 2px 0 6px oklch(0.05 0.005 195 / 0.4); 1972 + } 1973 + :root:not([data-theme="light"]) .sheet-grid td.freeze-border-bottom.freeze-border-right, 1974 + :root:not([data-theme="light"]) .sheet-grid th.freeze-border-bottom.freeze-border-right { 1975 + box-shadow: 2px 0 6px oklch(0.05 0.005 195 / 0.4), 0 2px 6px oklch(0.05 0.005 195 / 0.4); 1960 1976 } 1961 1977 } 1962 1978
+4 -2
src/lib/provider.ts
··· 142 142 async connect(): Promise<void> { 143 143 if (this._destroyed) return; 144 144 145 - // Load persisted snapshot first 146 - await this._loadSnapshot(); 145 + // Load persisted snapshot on first connect only — reconnects already have doc state 146 + if (!this._hadSnapshot && !this.synced) { 147 + await this._loadSnapshot(); 148 + } 147 149 148 150 const url = `${this.wsUrl}?room=${encodeURIComponent(this.roomId)}`; 149 151 this.ws = new WebSocket(url);
+9 -2
src/sheets/formulas.ts
··· 1511 1511 if (value === '' || value === null || value === undefined) return ''; 1512 1512 if (typeof value === 'string' && value.startsWith('#')) return value; // Error 1513 1513 1514 + // Date objects may come from formula evaluation — convert to timestamp for display 1514 1515 if (value instanceof Date) { 1515 - return value.toLocaleDateString(); 1516 + if (!format || format === 'auto' || format === 'date') { 1517 + return value.toLocaleDateString(); 1518 + } 1519 + value = value.getTime(); 1516 1520 } 1517 1521 1518 1522 if (!format || format === 'auto') { ··· 1527 1531 case 'number': return num.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 }); 1528 1532 case 'currency': return '$' + num.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 }); 1529 1533 case 'percent': return (num * 100).toFixed(1) + '%'; 1530 - case 'date': return new Date(num).toLocaleDateString(); 1534 + case 'date': { 1535 + const d = new Date(num); 1536 + return isNaN(d.getTime()) ? String(value) : d.toLocaleDateString(); 1537 + } 1531 1538 case 'text': return String(value); 1532 1539 default: return String(value); 1533 1540 }
+36 -11
src/sheets/main.ts
··· 618 618 cell = new Y.Map(); 619 619 cells.set(id, cell); 620 620 } 621 - if (data.v !== undefined) cell.set('v', data.v); 621 + // Date objects don't survive Yjs serialization — store as timestamp 622 + let v = data.v; 623 + if (v instanceof Date) v = v.getTime(); 624 + if (v !== undefined) cell.set('v', v); 622 625 if (data.f !== undefined) cell.set('f', data.f); 623 626 if (data.s !== undefined) cell.set('s', JSON.stringify(data.s)); 624 627 } ··· 1085 1088 if (!td) return; 1086 1089 td.classList.add('editing'); 1087 1090 const cellData = getCellData(id); 1088 - const value = cellData?.f ? '=' + cellData.f : (cellData?.v ?? ''); 1091 + let value = cellData?.f ? '=' + cellData.f : (cellData?.v ?? ''); 1092 + // Show date-formatted cells as readable date strings in the editor 1093 + if (!cellData?.f && cellData?.s?.format === 'date' && typeof value === 'number') { 1094 + const d = new Date(value); 1095 + if (!isNaN(d.getTime())) { 1096 + value = d.toLocaleDateString(); 1097 + } 1098 + } 1089 1099 const input = document.createElement('input'); 1090 1100 input.className = 'cell-editor'; 1091 1101 input.value = value; ··· 1115 1125 if (raw.startsWith('=')) { 1116 1126 setCellData(id, { v: '', f: raw.slice(1) }); 1117 1127 } else { 1128 + const existingData = getCellData(id); 1118 1129 const numVal = Number(raw); 1119 - const value = raw === '' ? '' : (!isNaN(numVal) && raw !== '' ? numVal : raw); 1130 + let value: string | number = raw === '' ? '' : (!isNaN(numVal) && raw !== '' ? numVal : raw); 1131 + // If cell has date format and user typed a date string, parse it back to timestamp 1132 + if (typeof value === 'string' && value !== '' && existingData?.s?.format === 'date') { 1133 + const parsed = Date.parse(value); 1134 + if (!isNaN(parsed)) value = parsed; 1135 + } 1120 1136 setCellData(id, { v: value, f: '' }); 1121 1137 } 1122 1138 input.remove(); ··· 1673 1689 cellAddressInput.value = id; 1674 1690 } 1675 1691 const cellData = getCellData(id); 1676 - formulaInput.value = cellData?.f ? '=' + cellData.f : (cellData?.v ?? ''); 1692 + let barValue = cellData?.f ? '=' + cellData.f : (cellData?.v ?? ''); 1693 + // Show date-formatted cells as readable date strings in formula bar 1694 + if (!cellData?.f && cellData?.s?.format === 'date' && typeof barValue === 'number') { 1695 + const d = new Date(barValue); 1696 + if (!isNaN(d.getTime())) barValue = d.toLocaleDateString(); 1697 + } 1698 + formulaInput.value = barValue; 1677 1699 updateFormulaHighlight(formulaInput.value); 1678 1700 } 1679 1701 ··· 2645 2667 const pending = JSON.parse(pendingRaw); 2646 2668 // Set import-in-progress flag to prevent snapshot saves during async import 2647 2669 window.__importInProgress = true; 2648 - // Convert data URL back to a File object 2670 + // Convert data URL back to a File object and await the full import 2649 2671 fetch(pending.data) 2650 2672 .then(r => r.blob()) 2651 - .then(blob => { 2673 + .then(async blob => { 2652 2674 const file = new File([blob], pending.name, { type: blob.type }); 2653 - handleImportFile(file); 2675 + await handleImportFile(file); 2654 2676 }) 2655 2677 .finally(() => { 2656 2678 window.__importInProgress = false; ··· 2806 2828 } 2807 2829 } 2808 2830 2809 - function handleImportFile(file) { 2831 + async function handleImportFile(file) { 2810 2832 if (!file) return; 2811 2833 const ext = file.name.split('.').pop().toLowerCase(); 2812 2834 2813 2835 // Handle .xlsx files via ExcelJS 2814 2836 if (ext === 'xlsx' || ext === 'xls') { 2815 - importXlsx(file, { 2837 + await importXlsx(file, { 2816 2838 ydoc, 2817 2839 getActiveSheet, 2818 2840 ensureSheet, 2819 2841 setCellData, 2820 - setCellDataForSheet: (sheetIdx: number, id: string, data: { v?: string | number; f?: string; s?: Record<string, unknown> }) => { 2842 + setCellDataForSheet: (sheetIdx: number, id: string, data: { v?: unknown; f?: string; s?: Record<string, unknown> }) => { 2821 2843 const sheet = ensureSheet(sheetIdx); 2822 2844 const cells = sheet.get('cells'); 2823 2845 if (!cells) return; 2824 2846 let yCell = cells.get(id); 2825 2847 if (!yCell) { yCell = new Y.Map(); cells.set(id, yCell); } 2826 - if (data.v !== undefined) yCell.set('v', data.v); 2848 + // Date objects don't survive Yjs serialization — store as timestamp 2849 + let v = data.v; 2850 + if (v instanceof Date) v = v.getTime(); 2851 + if (v !== undefined) yCell.set('v', v); 2827 2852 if (data.f !== undefined) yCell.set('f', data.f); 2828 2853 if (data.s && Object.keys(data.s).length > 0) yCell.set('s', JSON.stringify(data.s)); 2829 2854 },
+4
src/sheets/xlsx-import.ts
··· 209 209 const formulaValue = cell.value as { formula?: string; result?: unknown }; 210 210 data.f = formulaValue.formula || ''; 211 211 data.v = formulaValue.result ?? ''; 212 + } else if (cell.value instanceof Date) { 213 + // Store dates as numeric timestamps — Date objects don't survive Yjs serialization 214 + data.v = cell.value.getTime(); 215 + if (!data.s.format) data.s.format = 'date'; 212 216 } else { 213 217 data.v = cell.value; 214 218 }
+3 -2
tests/xlsx-complex-import.test.ts
··· 477 477 expect(getCell('A1').s.align).toBe('center'); 478 478 }); 479 479 480 - it('has date values (ExcelJS returns Date objects)', () => { 480 + it('has date values stored as timestamps (Date objects converted for Yjs compatibility)', () => { 481 481 const dateVal = getCell('A2').v; 482 - expect(dateVal).toBeInstanceOf(Date); 482 + expect(typeof dateVal).toBe('number'); 483 + expect(new Date(dateVal as number).getFullYear()).toBeGreaterThanOrEqual(2000); 483 484 }); 484 485 485 486 it('has date format on date cells', () => {
+6 -4
tests/xlsx-complex-scenarios.test.ts
··· 237 237 expect(cell('D4')!.v).toBe(95000); 238 238 }); 239 239 240 - it('has date values', () => { 241 - expect(cell('C3')!.v).toBeInstanceOf(Date); 240 + it('has date values stored as timestamps', () => { 241 + expect(typeof cell('C3')!.v).toBe('number'); 242 + expect(new Date(cell('C3')!.v as number).getFullYear()).toBeGreaterThanOrEqual(2000); 242 243 }); 243 244 244 245 it('has correct cell count (22 rows x 5 cols data range)', () => { ··· 606 607 expect(cell('C2')!.v).toBe('High'); 607 608 }); 608 609 609 - it('has due dates as Date objects', () => { 610 - expect(cell('D2')!.v).toBeInstanceOf(Date); 610 + it('has due dates stored as timestamps', () => { 611 + expect(typeof cell('D2')!.v).toBe('number'); 612 + expect(new Date(cell('D2')!.v as number).getFullYear()).toBeGreaterThanOrEqual(2000); 611 613 }); 612 614 613 615 it('has percent complete values', () => {