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

Configure Feed

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

feat: recent documents quick-access on landing page (#105)

scott aa4596aa cc52af92

+292
+7
CHANGELOG.md
··· 10 10 ## [0.10.0] — 2026-03-24 11 11 12 12 ### Added 13 + - Sheets: rich error tooltip popups (upgrade from title attr) (#208) 14 + - Sheets: function help panel (#97) 15 + - Sheets: contextual error tooltips (#96) 13 16 - **Server-side trash**: Trash state moved from localStorage to SQLite `deleted_at` column — trash persists across browsers and devices, with automatic 30-day purge (#153) 14 17 - **Toast with undo on delete**: Deleting a document shows a 5-second toast with an accessible Undo button (#154) 15 18 - **Formula bar color coding**: Cell references in the formula bar now match the colored range highlight borders on the grid (#112) 16 19 17 20 ### Changed 21 + - Both: document duplication from landing page (#106) 22 + - Docs: embed code blocks with syntax highlighting (#110) 23 + - Sheets: SPARKLINE() inline mini-charts (#87) 24 + - Sheets: keyboard shortcut parity with Google Sheets (#105) 18 25 - One-time migration of localStorage trash entries to server on first load 19 26 - `GET /api/documents` now returns only active (non-trashed) documents 20 27 - New API endpoints: `GET /api/documents/trash`, `PUT /api/documents/:id/trash`, `PUT /api/documents/:id/restore`
+64
src/css/app.css
··· 430 430 } 431 431 432 432 /* Document list */ 433 + /* ======================================================== 434 + Recent Documents (#116) 435 + ======================================================== */ 436 + 437 + .recent-section { 438 + margin-bottom: var(--space-lg); 439 + } 440 + 441 + .recent-heading { 442 + font-family: var(--font-body); 443 + font-size: 0.75rem; 444 + font-weight: 600; 445 + letter-spacing: 0.08em; 446 + text-transform: uppercase; 447 + color: var(--color-text-faint); 448 + margin-bottom: var(--space-sm); 449 + } 450 + 451 + .recent-list { 452 + display: flex; 453 + gap: var(--space-sm); 454 + overflow-x: auto; 455 + padding-bottom: var(--space-xs); 456 + } 457 + 458 + .recent-card { 459 + display: flex; 460 + flex-direction: column; 461 + gap: 4px; 462 + min-width: 140px; 463 + max-width: 180px; 464 + padding: var(--space-sm) var(--space-md); 465 + background: var(--color-surface); 466 + border: 1px solid var(--color-border); 467 + border-radius: var(--radius-md); 468 + text-decoration: none; 469 + color: var(--color-text); 470 + transition: border-color var(--transition-fast), box-shadow var(--transition-fast); 471 + } 472 + 473 + .recent-card:hover { 474 + border-color: var(--color-border-strong); 475 + box-shadow: var(--shadow-sm); 476 + } 477 + 478 + .recent-card-icon { 479 + font-size: 1.25rem; 480 + } 481 + 482 + .recent-card-name { 483 + font-size: 0.8rem; 484 + font-weight: 500; 485 + overflow: hidden; 486 + text-overflow: ellipsis; 487 + white-space: nowrap; 488 + } 489 + 490 + .recent-card-type { 491 + font-size: 0.65rem; 492 + text-transform: uppercase; 493 + letter-spacing: 0.05em; 494 + color: var(--color-text-faint); 495 + } 496 + 433 497 .doc-section { 434 498 margin-top: var(--space-2xl); 435 499 }
+1
src/index.html
··· 50 50 </header> 51 51 52 52 <section class="doc-section" id="main-content"> 53 + <div id="recent-section" class="recent-section"></div> 53 54 <div class="doc-toolbar"> 54 55 <div class="doc-breadcrumbs" id="breadcrumbs"></div> 55 56 <div class="doc-toolbar-actions">
+30
src/landing-utils.ts
··· 284 284 } 285 285 return { valid: true }; 286 286 } 287 + 288 + // ============================================================ 289 + // Recent Documents 290 + // ============================================================ 291 + 292 + /** 293 + * Track a recently opened document by prepending its ID. 294 + * Deduplicates and caps the list at maxSize. 295 + */ 296 + export function trackRecentDoc(recentIds: string[], docId: string, maxSize = 10): string[] { 297 + return [docId, ...recentIds.filter(id => id !== docId)].slice(0, maxSize); 298 + } 299 + 300 + /** 301 + * Resolve recent IDs into actual DocumentMeta objects. 302 + * Filters out docs that no longer exist or lack decryption keys. 303 + * Preserves the order of recentIds (most recent first). 304 + */ 305 + export function getRecentDocs( 306 + recentIds: string[], 307 + allDocs: DocumentMeta[], 308 + keys: Record<string, string>, 309 + displayCount = 5, 310 + ): DocumentMeta[] { 311 + const docMap = new Map(allDocs.map(d => [d.id, d])); 312 + return recentIds 313 + .map(id => docMap.get(id)) 314 + .filter((d): d is DocumentMeta => d !== undefined && keys[d.id] !== undefined) 315 + .slice(0, displayCount); 316 + }
+57
src/landing.ts
··· 15 15 clearFolderAssignments, 16 16 generateRandomUsername, 17 17 validateUsername, 18 + trackRecentDoc, 19 + getRecentDocs, 18 20 DEFAULT_SORT, 19 21 } from './landing-utils.js'; 20 22 import { ··· 65 67 let folders: Folder[] = JSON.parse(localStorage.getItem('tools-folders') || '[]'); 66 68 let folderAssignments: FolderAssignments = JSON.parse(localStorage.getItem('tools-folder-assignments') || '{}'); 67 69 let currentFolderId: string | null = null; // null = root / All Documents 70 + let recentIds: string[] = JSON.parse(localStorage.getItem('tools-recent') || '[]'); 68 71 let searchQuery = ''; 69 72 let trashExpanded = false; 70 73 ··· 175 178 localStorage.setItem('tools-folder-assignments', JSON.stringify(folderAssignments)); 176 179 } 177 180 181 + recentIds = trackRecentDoc(recentIds, id); 182 + localStorage.setItem('tools-recent', JSON.stringify(recentIds)); 183 + 178 184 const path = type === 'doc' ? '/docs' : '/sheets'; 179 185 window.location.href = `${path}/${id}#${keyStr}`; 180 186 } ··· 325 331 renderDocuments(); 326 332 } 327 333 334 + function renderRecentSection(keys: Record<string, string>) { 335 + const recentEl = document.getElementById('recent-section'); 336 + if (!recentEl) return; 337 + 338 + const recent = getRecentDocs(recentIds, allDocs, keys); 339 + if (recent.length === 0) { 340 + recentEl.innerHTML = ''; 341 + return; 342 + } 343 + 344 + let html = '<h3 class="recent-heading">Recent</h3><div class="recent-list">'; 345 + for (const doc of recent) { 346 + const path = doc.type === 'doc' ? '/docs' : '/sheets'; 347 + const icon = doc.type === 'doc' ? '&#9998;' : '&#9638;'; 348 + const name = doc._decryptedName || 'Encrypted Document'; 349 + const href = doc._keyStr ? `${path}/${doc.id}#${doc._keyStr}` : '#'; 350 + html += `<a class="recent-card" href="${href}" data-doc-id="${doc.id}"> 351 + <span class="recent-card-icon">${icon}</span> 352 + <span class="recent-card-name">${escapeHtml(name)}</span> 353 + <span class="recent-card-type">${doc.type}</span> 354 + </a>`; 355 + } 356 + html += '</div>'; 357 + recentEl.innerHTML = html; 358 + 359 + // Track clicks on recent cards too 360 + recentEl.querySelectorAll('a.recent-card[data-doc-id]').forEach(link => { 361 + link.addEventListener('click', () => { 362 + const docId = (link as HTMLElement).dataset.docId; 363 + if (docId) { 364 + recentIds = trackRecentDoc(recentIds, docId); 365 + localStorage.setItem('tools-recent', JSON.stringify(recentIds)); 366 + } 367 + }); 368 + }); 369 + } 370 + 328 371 function renderDocuments() { 329 372 const keys = JSON.parse(localStorage.getItem('tools-keys') || '{}'); 330 373 const starSet = starredIdsSet(stars); ··· 489 532 } 490 533 }); 491 534 }); 535 + 536 + // Track recent docs on click 537 + docListEl.querySelectorAll('a.doc-item[data-doc-id]').forEach(link => { 538 + link.addEventListener('click', () => { 539 + const docId = (link as HTMLElement).dataset.docId; 540 + if (docId) { 541 + recentIds = trackRecentDoc(recentIds, docId); 542 + localStorage.setItem('tools-recent', JSON.stringify(recentIds)); 543 + } 544 + }); 545 + }); 492 546 } 547 + 548 + // Render recent documents section 549 + renderRecentSection(keys); 493 550 494 551 // Render trash section 495 552 renderTrash(trashedDocs, keys);
+133
tests/landing-recent.test.ts
··· 1 + import { describe, it, expect } from 'vitest'; 2 + import { trackRecentDoc, getRecentDocs } from '../src/landing-utils.js'; 3 + import type { DocumentMeta } from '../src/landing-types.js'; 4 + 5 + function makeMeta(id: string, type: 'doc' | 'sheet' = 'doc', name?: string): DocumentMeta { 6 + return { 7 + id, 8 + type, 9 + name_encrypted: null, 10 + deleted_at: null, 11 + created_at: '2026-01-01T00:00:00', 12 + updated_at: '2026-01-01T00:00:00', 13 + _decryptedName: name ?? `Doc ${id}`, 14 + _keyStr: `key-${id}`, 15 + }; 16 + } 17 + 18 + describe('trackRecentDoc', () => { 19 + it('adds a doc ID to an empty list', () => { 20 + const result = trackRecentDoc([], 'doc-1'); 21 + expect(result).toEqual(['doc-1']); 22 + }); 23 + 24 + it('prepends the doc ID (most recent first)', () => { 25 + const result = trackRecentDoc(['doc-1'], 'doc-2'); 26 + expect(result).toEqual(['doc-2', 'doc-1']); 27 + }); 28 + 29 + it('moves an existing doc ID to the front (deduplication)', () => { 30 + const result = trackRecentDoc(['doc-2', 'doc-1', 'doc-3'], 'doc-1'); 31 + expect(result).toEqual(['doc-1', 'doc-2', 'doc-3']); 32 + }); 33 + 34 + it('limits the list to maxSize entries', () => { 35 + const ids = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j']; 36 + const result = trackRecentDoc(ids, 'k', 10); 37 + expect(result).toHaveLength(10); 38 + expect(result[0]).toBe('k'); 39 + expect(result[9]).toBe('i'); // 'j' is pushed off 40 + }); 41 + 42 + it('uses default maxSize of 10', () => { 43 + const ids = Array.from({ length: 15 }, (_, i) => `id-${i}`); 44 + const result = trackRecentDoc(ids, 'new-id'); 45 + expect(result).toHaveLength(10); 46 + expect(result[0]).toBe('new-id'); 47 + }); 48 + 49 + it('does not mutate the original array', () => { 50 + const original = ['doc-1', 'doc-2']; 51 + const result = trackRecentDoc(original, 'doc-3'); 52 + expect(original).toEqual(['doc-1', 'doc-2']); 53 + expect(result).not.toBe(original); 54 + }); 55 + 56 + it('handles re-adding the already most-recent item', () => { 57 + const result = trackRecentDoc(['doc-1', 'doc-2'], 'doc-1'); 58 + expect(result).toEqual(['doc-1', 'doc-2']); 59 + }); 60 + }); 61 + 62 + describe('getRecentDocs', () => { 63 + const allDocs: DocumentMeta[] = [ 64 + makeMeta('doc-1', 'doc', 'Meeting Notes'), 65 + makeMeta('doc-2', 'sheet', 'Budget 2026'), 66 + makeMeta('doc-3', 'doc', 'Project Plan'), 67 + makeMeta('doc-4', 'doc', 'Secret Doc'), 68 + ]; 69 + 70 + const keys: Record<string, string> = { 71 + 'doc-1': 'key-1', 72 + 'doc-2': 'key-2', 73 + 'doc-3': 'key-3', 74 + // doc-4 has no key — should be excluded 75 + }; 76 + 77 + it('returns recent docs that exist and have keys', () => { 78 + const result = getRecentDocs(['doc-1', 'doc-2', 'doc-3'], allDocs, keys, 5); 79 + expect(result).toHaveLength(3); 80 + expect(result[0].id).toBe('doc-1'); 81 + expect(result[1].id).toBe('doc-2'); 82 + expect(result[2].id).toBe('doc-3'); 83 + }); 84 + 85 + it('filters out docs without keys', () => { 86 + const result = getRecentDocs(['doc-4', 'doc-1'], allDocs, keys, 5); 87 + expect(result).toHaveLength(1); 88 + expect(result[0].id).toBe('doc-1'); 89 + }); 90 + 91 + it('filters out doc IDs not in allDocs', () => { 92 + const result = getRecentDocs(['deleted-id', 'doc-1'], allDocs, keys, 5); 93 + expect(result).toHaveLength(1); 94 + expect(result[0].id).toBe('doc-1'); 95 + }); 96 + 97 + it('respects the displayCount limit', () => { 98 + const result = getRecentDocs(['doc-1', 'doc-2', 'doc-3'], allDocs, keys, 2); 99 + expect(result).toHaveLength(2); 100 + expect(result[0].id).toBe('doc-1'); 101 + expect(result[1].id).toBe('doc-2'); 102 + }); 103 + 104 + it('returns empty array for empty recentIds', () => { 105 + const result = getRecentDocs([], allDocs, keys, 5); 106 + expect(result).toEqual([]); 107 + }); 108 + 109 + it('returns empty array for empty allDocs', () => { 110 + const result = getRecentDocs(['doc-1'], [], keys, 5); 111 + expect(result).toEqual([]); 112 + }); 113 + 114 + it('returns empty array for empty keys', () => { 115 + const result = getRecentDocs(['doc-1'], allDocs, {}, 5); 116 + expect(result).toEqual([]); 117 + }); 118 + 119 + it('preserves the order of recentIds', () => { 120 + const result = getRecentDocs(['doc-3', 'doc-1', 'doc-2'], allDocs, keys, 5); 121 + expect(result.map(d => d.id)).toEqual(['doc-3', 'doc-1', 'doc-2']); 122 + }); 123 + 124 + it('uses default displayCount of 5', () => { 125 + const manyDocs = Array.from({ length: 8 }, (_, i) => makeMeta(`d-${i}`)); 126 + const manyKeys: Record<string, string> = {}; 127 + for (const d of manyDocs) manyKeys[d.id] = `key-${d.id}`; 128 + const ids = manyDocs.map(d => d.id); 129 + 130 + const result = getRecentDocs(ids, manyDocs, manyKeys); 131 + expect(result).toHaveLength(5); 132 + }); 133 + });