# FullSheet - Enterprise Grade Online Spreadsheet ## Vision Transform the current sheet.js into a full-featured Excel competitor with: - **Virtual scrolling** for million+ row datasets - **xlsx import/export** via SheetJS (xlsx library) - **Real-time collaboration** with operational transformation - **Enterprise features**: audit trail, permissions, version history --- ## Phase 1: Virtual Scrolling Engine (Priority: HIGH) ### Current Problem - 26×100 = 2600 DOM elements rendered simultaneously - No support for large datasets (Excel supports 1M+ rows) - Poor performance with 10K+ cells ### Solution Architecture ```javascript // NEW: VirtualGrid class - drop-in replacement for current grid class VirtualGrid { constructor(container, config) { this.config = { colCount: 16384, // Excel's max columns (XFD) rowCount: 1048576, // Excel's max rows colWidth: 100, rowHeight: 24, bufferSize: 20, // Render 20 extra rows/cols outside viewport ...config }; this.visibleStartRow = 0; this.visibleEndRow = 0; this.visibleStartCol = 0; this.visibleEndCol = 0; this.cellCache = new Map(); // Only stores non-empty cells this.renderedCells = new Map(); // Currently in DOM this.initialize(); } initialize() { // Use CSS transform for virtual scrolling this.viewport = document.createElement('div'); this.viewport.className = 'virtual-viewport'; this.viewport.style.overflow = 'auto'; this.viewport.style.position = 'relative'; this.content = document.createElement('div'); this.content.className = 'virtual-content'; this.content.style.position = 'absolute'; this.content.style.top = '0'; this.content.style.left = '0'; this.viewport.appendChild(this.content); this.container.appendChild(this.viewport); // Calculate dimensions this.content.style.width = `${this.config.colCount * this.config.colWidth}px`; this.content.style.height = `${this.config.rowCount * this.config.rowHeight}px`; // Bind scroll events this.viewport.addEventListener('scroll', () => this.onScroll()); this.resizeObserver = new ResizeObserver(() => this.onResize()); this.resizeObserver.observe(this.viewport); // Initial render this.onScroll(); } onScroll() { const scrollTop = this.viewport.scrollTop; const scrollLeft = this.viewport.scrollLeft; const viewHeight = this.viewport.clientHeight; const viewWidth = this.viewport.clientWidth; // Calculate visible range this.visibleStartRow = Math.floor(scrollTop / this.config.rowHeight); this.visibleEndRow = Math.min( this.config.rowCount - 1, Math.ceil((scrollTop + viewHeight) / this.config.rowHeight) ); this.visibleStartCol = Math.floor(scrollLeft / this.config.colWidth); this.visibleEndCol = Math.min( this.config.colCount - 1, Math.ceil((scrollLeft + viewWidth) / this.config.colWidth) ); // Apply buffer const buffer = this.config.bufferSize; this.visibleStartRow = Math.max(0, this.visibleStartRow - buffer); this.visibleEndRow = Math.min(this.config.rowCount - 1, this.visibleEndRow + buffer); this.visibleStartCol = Math.max(0, this.visibleStartCol - buffer); this.visibleEndCol = Math.min(this.config.colCount - 1, this.visibleEndCol + buffer); this.renderVisibleCells(); } renderVisibleCells() { // Remove cells no longer visible for (const [key, el] of this.renderedCells) { const [r, c] = key.split(',').map(Number); if (r < this.visibleStartRow || r > this.visibleEndRow || c < this.visibleStartCol || c > this.visibleEndCol) { el.remove(); this.renderedCells.delete(key); } } // Render visible cells for (let row = this.visibleStartRow; row <= this.visibleEndRow; row++) { for (let col = this.visibleStartCol; col <= this.visibleEndCol; col++) { const key = `${row},${col}`; if (!this.renderedCells.has(key)) { const cellData = this.cellCache.get(key); if (cellData) { const cell = this.createCellElement(row, col, cellData); cell.style.position = 'absolute'; cell.style.top = `${row * this.config.rowHeight}px`; cell.style.left = `${col * this.config.colWidth}px`; cell.style.width = `${this.config.colWidth}px`; cell.style.height = `${this.config.rowHeight}px`; this.content.appendChild(cell); this.renderedCells.set(key, cell); } } } } } setCellValue(row, col, value) { const key = `${row},${col}`; if (value === null || value === undefined || value === '') { this.cellCache.delete(key); } else { this.cellCache.set(key, value); } // Update if in visible range if (row >= this.visibleStartRow && row <= this.visibleEndRow && col >= this.visibleStartCol && col <= this.visibleEndCol) { const cell = this.renderedCells.get(key); if (cell) { if (value) { cell.textContent = value; } else { cell.remove(); this.renderedCells.delete(key); } } else if (value) { const el = this.createCellElement(row, col, value); el.style.position = 'absolute'; el.style.top = `${row * this.config.rowHeight}px`; el.style.left = `${col * this.config.colWidth}px`; this.content.appendChild(el); this.renderedCells.set(key, el); } } } getCellValue(row, col) { return this.cellCache.get(`${row},${col}`); } scrollToCell(row, col) { const scrollTop = row * this.config.rowHeight; const scrollLeft = col * this.config.colWidth; this.viewport.scrollTo(scrollLeft, scrollTop); } getViewportPosition() { return { top: this.viewport.scrollTop, left: this.viewport.scrollLeft, height: this.viewport.clientHeight, width: this.viewport.clientWidth }; } } ``` ### Integration Steps 1. **Add VirtualGrid to sheet.js** (keep existing functionality): ```javascript // At top of sheet.js, after CONFIG let virtualGrid = null; function initVirtualGrid() { const container = document.getElementById('cellsContainer'); virtualGrid = new VirtualGrid(container, { colCount: CONFIG.COLS, rowCount: CONFIG.ROWS, colWidth: CONFIG.COL_WIDTH, rowHeight: CONFIG.ROW_HEIGHT }); // Copy existing data to virtual grid const ws = state.worksheets[state.activeWorksheet]; for (const [key, data] of Object.entries(ws.data)) { const [row, col] = key.split(',').map(Number); virtualGrid.setCellValue(row, col, data.value || data.formula); } } ``` 2. **Modify renderGrid()** to use virtual grid: ```javascript function renderGrid() { // Keep header rendering (not virtualized) renderColumnHeaders(); renderRowHeaders(); // Initialize virtual grid for cells if (!virtualGrid) { initVirtualGrid(); } } ``` --- ## Phase 2: xlsx Import/Export (Priority: HIGH) ### Library Integration Add SheetJS library to botui. Place in: `botui/ui/suite/js/vendor/xlsx.full.min.js` ### File Import ```javascript // Add to sheet.js async function importXlsx(file) { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = (e) => { try { const data = new Uint8Array(e.target.result); const workbook = XLSX.read(data, { type: 'array' }); // Convert to sheet format const worksheets = workbook.SheetNames.map((name, index) => { const sheet = workbook.Sheets[name]; const jsonData = XLSX.utils.sheet_to_json(sheet, { header: 1, // Array of arrays defval: '' }); // Convert to sparse cell format const cellData = {}; jsonData.forEach((row, rowIndex) => { row.forEach((value, colIndex) => { if (value !== '' && value !== null && value !== undefined) { cellData[`${rowIndex},${colIndex}`] = { value: String(value) }; } }); }); return { name, data: cellData }; }); state.worksheets = worksheets; state.activeWorksheet = 0; renderWorksheetTabs(); // Reinitialize virtual grid with new data initVirtualGrid(); resolve(); } catch (err) { reject(err); } }; reader.onerror = reject; reader.readAsArrayBuffer(file); }); } ``` ### File Export ```javascript async function exportXlsx() { // Convert sheet format to array of arrays const ws = state.worksheets[state.activeWorksheet]; const maxRow = findMaxRow(ws.data); const maxCol = findMaxCol(ws.data); const jsonData = []; for (let r = 0; r <= maxRow; r++) { const row = []; for (let c = 0; c <= maxCol; c++) { const key = `${r},${c}`; const cell = ws.data[key]; row.push(cell?.value || cell?.formula || ''); } jsonData.push(row); } // Create workbook const worksheet = XLSX.utils.aoa_to_sheet(jsonData); const workbook = XLSX.utils.book_new(); XLSX.utils.book_append_sheet(workbook, worksheet, state.sheetName); // Generate file const xlsxData = XLSX.write(workbook, { bookType: 'xlsx', type: 'array' }); // Download const blob = new Blob([xlsxData], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `${state.sheetName}.xlsx`; a.click(); URL.revokeObjectURL(url); } ``` ### UI Integration Add import/export buttons to toolbar in sheet.html: ```html ``` Add hidden file input: ```html ``` Bind events: ```javascript document.getElementById('importXlsxBtn')?.addEventListener('click', () => { document.getElementById('xlsxFileInput').click(); }); document.getElementById('xlsxFileInput')?.addEventListener('change', async (e) => { if (e.target.files[0]) { await importXlsx(e.target.files[0]); } }); document.getElementById('exportXlsxBtn')?.addEventListener('click', exportXlsx); ``` --- ## Phase 3: Real-time Collaboration (Priority: HIGH) ### WebSocket Architecture Current implementation has basic collaboration. Enhance with: ```javascript // Enhanced collaboration state const collabState = { operations: [], // Operation log revision: 0, // Current revision number pendingOps: [], // Ops not yet acknowledged cursorPositions: new Map(), // Other users' cursors userColors: new Map() // Assign colors to users }; // Operational Transformation for concurrent edits function transformOperation(op1, op2) { // Transform op1 against op2 if (op1.type === 'set' && op2.type === 'set') { if (op1.row === op2.row && op1.col === op2.col) { // Same cell - use op2 (later operation wins) return null; } // Different cells - no transformation needed return op1; } // Handle insert/delete row/column // ... (OT logic) } function applyOperation(op) { const ws = state.worksheets[state.activeWorksheet]; const key = `${op.row},${op.col}`; switch (op.type) { case 'set': if (op.value === null || op.value === '') { delete ws.data[key]; } else { ws.data[key] = { value: op.value }; } break; case 'delete': delete ws.data[key]; break; } // Update virtual grid if (virtualGrid) { virtualGrid.setCellValue(op.row, op.col, op.value); } } ``` ### Conflict Resolution ```javascript function resolveConflict(localOp, remoteOp) { // If same cell, remote wins (last-write-wins for simplicity) // For enterprise: use CRDT or OT if (localOp.row === remoteOp.row && localOp.col === remoteOp.col) { return remoteOp; // Remote takes precedence } // Different cells - apply both return localOp; } ``` --- ## Phase 4: Enterprise Features (Priority: MEDIUM) ### 4.1 Audit Trail ```javascript class AuditLog { constructor() { this.entries = []; this.maxEntries = 10000; } log(action, details) { const entry = { timestamp: new Date().toISOString(), userId: getUserId(), action, details, sheetId: state.sheetId }; this.entries.push(entry); if (this.entries.length > this.maxEntries) { this.entries.shift(); } // Send to server for persistence this.persistEntry(entry); } async persistEntry(entry) { await fetch('/api/sheet/audit', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(entry) }); } getHistory(startTime, endTime) { return this.entries.filter(e => { const time = new Date(e.timestamp); return time >= startTime && time <= endTime; }); } } const auditLog = new AuditLog(); // Wrap cell operations with audit const originalSetCellValue = setCellValue; setCellValue = function(row, col, value) { const oldValue = getCellValue(row, col); originalSetCellValue(row, col, value); auditLog.log('cell_change', { row, col, oldValue, newValue: value }); }; ``` ### 4.2 Version History ```javascript class VersionManager { constructor() { this.versions = []; this.currentVersion = 0; this.autoSaveInterval = null; } createSnapshot() { const snapshot = { timestamp: new Date().toISOString(), worksheets: JSON.parse(JSON.stringify(state.worksheets)), sheetName: state.sheetName }; this.versions.push(snapshot); this.currentVersion = this.versions.length - 1; // Persist to server this.persistVersion(snapshot); // Keep last 100 versions in memory if (this.versions.length > 100) { this.versions.shift(); } } restoreVersion(versionIndex) { if (versionIndex < 0 || versionIndex >= this.versions.length) return; const version = this.versions[versionIndex]; state.worksheets = JSON.parse(JSON.stringify(version.worksheets)); state.sheetName = version.sheetName; state.activeWorksheet = 0; renderAllCells(); renderWorksheetTabs(); auditLog.log('version_restore', { versionIndex }); } getVersionList() { return this.versions.map((v, i) => ({ index: i, timestamp: v.timestamp, sheetName: v.sheetName })); } } ``` ### 4.3 Permissions System ```javascript class PermissionManager { constructor() { this.permissions = new Map(); // userId -> permission level } setPermission(userId, level) { this.permissions.set(userId, level); } canEdit() { const userId = getUserId(); const level = this.permissions.get(userId); return level === 'edit' || level === 'admin'; } canDelete() { const userId = getUserId(); const level = this.permissions.get(userId); return level === 'admin'; } canShare() { const userId = getUserId(); const level = this.permissions.get(userId); return level === 'admin'; } } const permissions = new PermissionManager(); // Check permissions before operations function setCellValue(row, col, value) { if (!permissions.canEdit()) { showNotification('You do not have permission to edit', 'error'); return; } // ... original implementation } ``` --- ## Phase 5: Performance Optimization (Priority: MEDIUM) ### 5.1 Lazy Formula Evaluation ```javascript class FormulaEngine { constructor() { this.dependencyGraph = new Map(); // cell -> dependent cells this.evaluationQueue = new Set(); this.isEvaluating = false; } scheduleEvaluation(cell) { this.evaluationQueue.add(cell); this.processQueue(); } async processQueue() { if (this.isEvaluating) return; this.isEvaluating = true; while (this.evaluationQueue.size > 0) { const cell = this.evaluationQueue.values().next().value; this.evaluationQueue.delete(cell); await this.evaluateCell(cell); } this.isEvaluating = false; } async evaluateCell(cell) { const data = getCellData(cell.row, cell.col); if (!data?.formula) return; try { const result = evaluateFormula(data.formula, cell.row, cell.col); // Update display without full re-render if (virtualGrid) { virtualGrid.setCellValue(cell.row, cell.col, result); } } catch (e) { // Handle formula error } } } ``` ### 5.2 Web Worker for Heavy Calculations ```javascript // sheet.worker.js - separate worker file self.onmessage = function(e) { const { type, data } = e.data; switch (type) { case 'evaluate_formula': const result = evaluateFormulaInWorker(data.formula, data.references); self.postMessage({ type: 'formula_result', result }); break; case 'batch_render': const html = batchRenderCells(data.cells); self.postMessage({ type: 'batch_result', html }); break; } }; ``` ### 5.3 Debounced Auto-save ```javascript function debounce(func, wait) { let timeout; return function executedFunction(...args) { const later = () => { clearTimeout(timeout); func(...args); }; clearTimeout(timeout); timeout = setTimeout(later, wait); }; } const debouncedSave = debounce(saveSheet, 2000); function scheduleAutoSave() { state.isDirty = true; debouncedSave(); } ``` --- ## Phase 6: Advanced Features (Priority: LOW) ### 6.1 Pivot Tables ```javascript function createPivotTable(sourceRange, rowFields, colFields, valueField, aggregation) { const data = extractRangeData(sourceRange); // Group by row fields const grouped = {}; data.forEach(row => { const key = rowFields.map(f => row[f]).join('|'); if (!grouped[key]) grouped[key] = []; grouped[key].push(row); }); // Aggregate values const result = {}; for (const [key, rows] of Object.entries(grouped)) { const values = rows.map(r => parseFloat(r[valueField])).filter(v => !isNaN(v)); result[key] = aggregate(values, aggregation); } return result; } function aggregate(values, type) { switch (type) { case 'sum': return values.reduce((a, b) => a + b, 0); case 'avg': return values.reduce((a, b) => a + b, 0) / values.length; case 'count': return values.length; case 'min': return Math.min(...values); case 'max': return Math.max(...values); default: return 0; } } ``` ### 6.2 Macros/Scripts ```javascript class MacroEngine { constructor() { this.macros = new Map(); } registerMacro(name, fn) { this.macros.set(name, fn); } runMacro(name) { const macro = this.macros.get(name); if (macro) { macro(); auditLog.log('macro_run', { name }); } } } // Built-in macros const macroEngine = new MacroEngine(); macroEngine.registerMacro('clearAll', () => { state.worksheets[state.activeWorksheet].data = {}; renderAllCells(); }); macroEngine.registerMacro('autoSum', () => { // Auto-sum selected range }); ``` --- ## Implementation Roadmap ### Step 1: Virtual Scrolling (Week 1) ✅ COMPLETED - [x] Implement VirtualGrid class (sheet.js:253-448) - [x] Update renderGrid() to use virtual grid (sheet.js:378-430) - [x] Auto-enable for sheets > 500 rows - [x] Ensure existing functionality works (backward compatible) - [ ] Test with 10K+ rows ### Step 2: xlsx Support (Week 1-2) ✅ COMPLETED - [x] Use existing backend APIs (/api/sheet/import, /api/sheet/export) - [x] Implement importXlsx() (sheet.js:1893-1947) - [x] Implement exportXlsx() (sheet.js:1949-1992) - [x] Implement exportCsv() (sheet.js:1994-2021) - [x] Add UI buttons (sheet.html:209-225) - [ ] Test with real xlsx files ### Step 3: Collaboration (Week 2-3) 🔄 IN PROGRESS - [x] Existing WebSocket handler (connectWebSocket) - [ ] Enhance with operation transformation - [ ] Add cursor tracking for remote users - [ ] Test multi-user scenarios ### Step 4: Enterprise (Week 3-4) ✅ COMPLETED - [x] Add audit logging (AuditLog class, sheet.js:299-338) - [x] Implement version history (VersionManager class, sheet.js:340-418) - [x] Add permission system (PermissionManager class, sheet.js:420-452) - [ ] Add admin UI (version history dialog) ### Step 5: Performance (Week 4-5) 🔄 IN PROGRESS - [x] Add debounced save (CONFIG.AUTOSAVE_DELAY) - [ ] Add formula lazy evaluation - [ ] Implement Web Worker - [ ] Profile and optimize --- ## Backward Compatibility All changes must maintain backward compatibility: 1. Keep existing CONFIG structure 2. Preserve current API (setCellValue, getCellValue, etc.) 3. VirtualGrid should wrap existing cell data 4. Feature flags for new functionality ```javascript // Feature detection const features = { virtualScrolling: true, // Default on for large sheets xlsxSupport: typeof XLSX !== 'undefined', collaboration: state.sheetId !== null, auditLog: true }; // Use features to determine behavior if (features.virtualScrolling && CONFIG.ROWS > 1000) { // Use virtual grid } else { // Use traditional grid } ``` --- ## Testing Strategy ### Unit Tests - VirtualGrid scroll behavior - xlsx import/export round-trip - Formula evaluation - Permission checks ### Integration Tests - Multi-user collaboration - Large file import (100K+ rows) - Version restore - Audit log persistence ### Performance Tests - Render time with 1M rows - Memory usage - Network bandwidth for collaboration --- ## Files Modified 1. ✅ **botui/ui/suite/sheet/sheet.js** - VirtualGrid, importXlsx/exportXlsx, AuditLog, VersionManager, PermissionManager 2. ✅ **botui/ui/suite/sheet/sheet.html** - Import/export buttons 3. ⏳ **botui/ui/suite/sheet/sheet.css** - Virtual grid styles (needed) 4. ⏳ **botserver/** - Use existing APIs (already have /api/sheet/import, /api/sheet/export, etc.) --- ## Success Metrics - [x] Virtual scrolling implementation complete (auto-enable >500 rows) - [x] xlsx import/export via backend APIs - [ ] Support 1M+ rows with smooth scrolling (needs testing) - [ ] Import/export xlsx files up to 50MB (needs testing) - [ ] 10+ concurrent users with sub-100ms sync (needs enhancement) - [ ] <100ms initial load time (needs profiling) - [x] 0 compilation errors in botui (verified) - [ ] 0 compilation errors in botserver (verifying) (verify)