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: offline support — cached doc list, offline indicator, IDB snapshot fallback' (#361) from feat/offline-support into main

scott bec91806 b0aa30cd

+559 -3
+10
CHANGELOG.md
··· 7 7 8 8 ## [Unreleased] 9 9 10 + ## [0.34.0] — 2026-04-10 11 + 12 + ### Added 13 + - Offline support: the landing page now caches the document list (active + trash) to `localStorage` after every successful fetch, and when the network is unavailable it re-renders from that cache with an "Offline — showing last-known document list" toast so you can still see your docs (and open cached ones) with no connection (#606) 14 + - Offline support: every editor (docs, sheets, slides, forms, diagrams, calendar) now mounts a small fixed top-right "Offline" badge that appears when `navigator.onLine` flips to false and disappears when you reconnect — provides immediate feedback that changes are local-only until sync resumes (#606) 15 + - Offline support: the `EncryptedProvider` already loads from the IndexedDB `tools-backups` store as a fallback when the server snapshot is unreachable, so opening a previously-visited document offline hydrates the Yjs doc from the local backup and you can keep editing — edits stay queued in IDB and sync automatically on reconnect (#606) 16 + 17 + ### Known limitation 18 + - The Electron desktop app is still a thin wrapper around `https://tools.lobster-hake.ts.net` — a cold-start launch with no network cannot reach the server and will show the "reconnecting" screen. Offline editing works in the PWA / web tab; embedded-server Electron is a separate, larger change and will land later. 19 + 10 20 ## [0.33.0] — 2026-04-10 11 21 12 22 ### Fixed
+1 -1
package.json
··· 1 1 { 2 2 "name": "tools", 3 - "version": "0.33.0", 3 + "version": "0.34.0", 4 4 "private": true, 5 5 "type": "module", 6 6 "main": "electron/main.js",
+2
src/calendar/main.ts
··· 11 11 import { importKey } from '../lib/crypto.js'; 12 12 import { EncryptedProvider } from '../lib/provider.js'; 13 13 import { setupTooltips } from '../lib/tooltips.js'; 14 + import { mountOfflineIndicator } from '../lib/offline-indicator.js'; 14 15 import { createCommandPalette } from '../command-palette.js'; 15 16 import { 16 17 type CalendarEvent, ··· 2131 2132 await initCrypto(); 2132 2133 state.cryptoKey = cryptoKey; 2133 2134 setupTooltips(); 2135 + mountOfflineIndicator(); 2134 2136 2135 2137 if (cryptoKey) { 2136 2138 const provider = new EncryptedProvider(ydoc, docId, cryptoKey);
+33
src/css/app.css
··· 3982 3982 opacity: 1; 3983 3983 } 3984 3984 3985 + /* --- Offline indicator badge --- */ 3986 + .offline-badge { 3987 + position: fixed; 3988 + top: calc(env(safe-area-inset-top, 0px) + 0.75rem); 3989 + right: calc(env(safe-area-inset-right, 0px) + 0.75rem); 3990 + display: none; 3991 + align-items: center; 3992 + gap: 0.4rem; 3993 + padding: 0.35rem 0.7rem; 3994 + background: var(--color-warning, #d9974a); 3995 + color: #fff; 3996 + font-family: var(--font-body); 3997 + font-size: 0.75rem; 3998 + font-weight: 600; 3999 + border-radius: var(--radius-sm, 4px); 4000 + box-shadow: var(--shadow-md); 4001 + z-index: var(--z-tooltip); 4002 + pointer-events: none; 4003 + user-select: none; 4004 + } 4005 + .offline-badge-dot { 4006 + display: inline-block; 4007 + width: 0.5rem; 4008 + height: 0.5rem; 4009 + border-radius: 50%; 4010 + background: #fff; 4011 + animation: offline-badge-pulse 2s ease-in-out infinite; 4012 + } 4013 + @keyframes offline-badge-pulse { 4014 + 0%, 100% { opacity: 1; } 4015 + 50% { opacity: 0.5; } 4016 + } 4017 + 3985 4018 /* --- Drag-and-drop import overlay --- */ 3986 4019 .drop-overlay { 3987 4020 position: fixed;
+2
src/diagrams/main.ts
··· 9 9 import { importKey } from '../lib/crypto.js'; 10 10 import { EncryptedProvider } from '../lib/provider.js'; 11 11 import { setupTooltips } from '../lib/tooltips.js'; 12 + import { mountOfflineIndicator } from '../lib/offline-indicator.js'; 12 13 import { 13 14 createWhiteboard, setShapeLabel, 14 15 } from './whiteboard.js'; ··· 338 339 ensureArrowheadMarker(); 339 340 await initCrypto(); 340 341 setupTooltips(); 342 + mountOfflineIndicator(); 341 343 342 344 pushHistory(); 343 345
+2
src/docs/main.ts
··· 55 55 import { createMarkdownToggle, TOGGLE_MODE } from './markdown-toggle.js'; 56 56 import { createVersionPanel } from '../version-panel.js'; 57 57 import { OfflineManager } from '../lib/offline.js'; 58 + import { mountOfflineIndicator } from '../lib/offline-indicator.js'; 58 59 import { ZenModeState, ZEN_STORAGE_KEY, ZEN_CLASS } from './zen-mode.js'; 59 60 import { filterCommands, PLACEHOLDER_EMPTY, PLACEHOLDER_BLOCK } from './slash-menu.js'; 60 61 import { createSlashCommands, getCommandExecutor } from './extensions/slash-commands.js'; ··· 747 748 748 749 // --- Styled tooltips --- 749 750 setupTooltips(); 751 + mountOfflineIndicator();
+2
src/forms/main.ts
··· 9 9 import { importKey } from '../lib/crypto.js'; 10 10 import { EncryptedProvider } from '../lib/provider.js'; 11 11 import { setupTooltips } from '../lib/tooltips.js'; 12 + import { mountOfflineIndicator } from '../lib/offline-indicator.js'; 12 13 import { createForm, setTargetSheet, type FormSchema } from './form-builder.js'; 13 14 import { createConditionalState, type ConditionalLogicState } from './conditional-logic.js'; 14 15 import { createChatSidebar, createChatState, loadConfig, initChatWiring } from '../lib/ai-chat.js'; ··· 205 206 async function init() { 206 207 await initCrypto(); 207 208 setupTooltips(); 209 + mountOfflineIndicator(); 208 210 209 211 if (cryptoKey) { 210 212 const provider = new EncryptedProvider(ydoc, docId, cryptoKey);
+43 -2
src/landing.ts
··· 18 18 import { createDocument, createFromTemplate, openDailyNote, openCalendar } from './landing-create.js'; 19 19 import type { CreateDeps } from './landing-create.js'; 20 20 import { setupDragAndDrop } from './landing-import.js'; 21 + import { 22 + cacheActiveDocs, 23 + cacheTrashDocs, 24 + loadCachedActiveDocs, 25 + loadCachedTrashDocs, 26 + } from './lib/offline-cache.js'; 27 + import { mountOfflineIndicator } from './lib/offline-indicator.js'; 21 28 22 29 // --- DOM refs --- 23 30 const docListEl = document.getElementById('doc-list') as HTMLElement; ··· 292 299 } 293 300 294 301 // --- Load & render --- 302 + async function renderFromCache(): Promise<boolean> { 303 + const cachedActive = loadCachedActiveDocs(); 304 + const cachedTrash = loadCachedTrashDocs(); 305 + if (!cachedActive && !cachedTrash) return false; 306 + const docs = cachedActive || []; 307 + const trashed = cachedTrash || []; 308 + await Promise.all([decryptDocNames(docs), decryptDocNames(trashed)]); 309 + allDocs = docs; 310 + trashedDocs = trashed; 311 + renderDocuments(); 312 + return true; 313 + } 314 + 295 315 async function loadDocuments() { 296 316 await migrateLocalStorageTrash(); 297 317 ··· 303 323 304 324 if (!activeRes.ok || !trashRes.ok) { 305 325 console.error('Failed to load documents:', activeRes.status, trashRes.status); 306 - showToast('Failed to load documents \u2014 try refreshing', 5000, true); 326 + const hadCache = await renderFromCache(); 327 + showToast( 328 + hadCache 329 + ? 'Showing offline cache \u2014 changes will sync when you reconnect' 330 + : 'Failed to load documents \u2014 try refreshing', 331 + 5000, 332 + true, 333 + ); 307 334 return; 308 335 } 309 336 310 337 const docs = await activeRes.json(); 311 338 const trashed = await trashRes.json(); 312 339 340 + // Persist raw (still-encrypted) docs for offline fallback 341 + cacheActiveDocs(docs); 342 + cacheTrashDocs(trashed); 343 + 313 344 await Promise.all([decryptDocNames(docs), decryptDocNames(trashed)]); 314 345 315 346 allDocs = docs; ··· 317 348 renderDocuments(); 318 349 } catch (err) { 319 350 console.error('Failed to load documents:', err); 320 - showToast('Failed to load documents \u2014 try refreshing', 5000, true); 351 + const hadCache = await renderFromCache(); 352 + showToast( 353 + hadCache 354 + ? 'Offline \u2014 showing last-known document list' 355 + : 'Failed to load documents \u2014 try refreshing', 356 + 5000, 357 + true, 358 + ); 321 359 } 322 360 } 361 + 362 + // Mount offline indicator badge (fixed position, hidden until offline) 363 + mountOfflineIndicator(); 323 364 324 365 // --- Command Palette --- 325 366 createCommandPalette({
+106
src/lib/offline-cache.ts
··· 1 + /** 2 + * Offline cache for landing-page document lists. 3 + * 4 + * Caches the raw JSON responses from `/api/documents` and `/api/documents/trash` 5 + * to localStorage so the landing page can render something meaningful when the 6 + * network is unavailable (PWA cold-start offline, flaky WiFi, server down). 7 + * 8 + * Only metadata is cached — names stay encrypted, and decryption still happens 9 + * client-side using keys from `tools-keys` in localStorage, which is already 10 + * persisted locally and works offline. 11 + * 12 + * Format: 13 + * localStorage[KEY_ACTIVE] = JSON({ v: 1, ts: <ms>, docs: DocumentMeta[] }) 14 + * localStorage[KEY_TRASH] = JSON({ v: 1, ts: <ms>, docs: DocumentMeta[] }) 15 + * 16 + * Entries without _decryptedName / _keyStr (derived state) are stored as-is; 17 + * decryption re-runs on load. 18 + */ 19 + 20 + import type { DocumentMeta } from '../landing-types.js'; 21 + 22 + const KEY_ACTIVE = 'tools-doclist-cache'; 23 + const KEY_TRASH = 'tools-trash-cache'; 24 + const CACHE_VERSION = 1; 25 + 26 + interface CacheEntry { 27 + v: number; 28 + ts: number; 29 + docs: DocumentMeta[]; 30 + } 31 + 32 + /** Strip derived/in-memory fields before serializing. */ 33 + function sanitize(docs: DocumentMeta[]): DocumentMeta[] { 34 + return docs.map(d => ({ 35 + id: d.id, 36 + type: d.type, 37 + name_encrypted: d.name_encrypted, 38 + deleted_at: d.deleted_at, 39 + tags: d.tags, 40 + created_at: d.created_at, 41 + updated_at: d.updated_at, 42 + })); 43 + } 44 + 45 + function writeKey(key: string, docs: DocumentMeta[]): void { 46 + try { 47 + const entry: CacheEntry = { v: CACHE_VERSION, ts: Date.now(), docs: sanitize(docs) }; 48 + localStorage.setItem(key, JSON.stringify(entry)); 49 + } catch { 50 + // Quota exceeded or storage disabled — best effort 51 + } 52 + } 53 + 54 + function readKey(key: string): DocumentMeta[] | null { 55 + try { 56 + const raw = localStorage.getItem(key); 57 + if (!raw) return null; 58 + const entry = JSON.parse(raw) as Partial<CacheEntry>; 59 + if (!entry || entry.v !== CACHE_VERSION || !Array.isArray(entry.docs)) return null; 60 + return entry.docs; 61 + } catch { 62 + return null; 63 + } 64 + } 65 + 66 + /** Cache the active doc list. Called after a successful fetch. */ 67 + export function cacheActiveDocs(docs: DocumentMeta[]): void { 68 + writeKey(KEY_ACTIVE, docs); 69 + } 70 + 71 + /** Cache the trash doc list. Called after a successful fetch. */ 72 + export function cacheTrashDocs(docs: DocumentMeta[]): void { 73 + writeKey(KEY_TRASH, docs); 74 + } 75 + 76 + /** Load the cached active doc list, or null if none. */ 77 + export function loadCachedActiveDocs(): DocumentMeta[] | null { 78 + return readKey(KEY_ACTIVE); 79 + } 80 + 81 + /** Load the cached trash doc list, or null if none. */ 82 + export function loadCachedTrashDocs(): DocumentMeta[] | null { 83 + return readKey(KEY_TRASH); 84 + } 85 + 86 + /** Return the timestamp (ms) of the cached active list, or null if none. */ 87 + export function getCacheTimestamp(): number | null { 88 + try { 89 + const raw = localStorage.getItem(KEY_ACTIVE); 90 + if (!raw) return null; 91 + const entry = JSON.parse(raw) as Partial<CacheEntry>; 92 + return typeof entry.ts === 'number' ? entry.ts : null; 93 + } catch { 94 + return null; 95 + } 96 + } 97 + 98 + /** Clear both cache keys. For tests and logout flows. */ 99 + export function clearDocListCache(): void { 100 + try { 101 + localStorage.removeItem(KEY_ACTIVE); 102 + localStorage.removeItem(KEY_TRASH); 103 + } catch { 104 + // ignore 105 + } 106 + }
+79
src/lib/offline-indicator.ts
··· 1 + /** 2 + * Offline indicator UI. 3 + * 4 + * Mounts a small fixed badge in the corner of the page that appears when the 5 + * browser reports `navigator.onLine === false` and hides when back online. 6 + * Listens to `online` and `offline` window events so it reacts immediately. 7 + * 8 + * Usage: 9 + * import { mountOfflineIndicator } from '../lib/offline-indicator.js'; 10 + * mountOfflineIndicator(); 11 + */ 12 + 13 + const BADGE_ID = 'tools-offline-badge'; 14 + 15 + let mounted = false; 16 + let onlineHandler: (() => void) | null = null; 17 + let offlineHandler: (() => void) | null = null; 18 + 19 + function ensureBadge(): HTMLElement | null { 20 + if (typeof document === 'undefined') return null; 21 + let badge = document.getElementById(BADGE_ID); 22 + if (badge) return badge; 23 + badge = document.createElement('div'); 24 + badge.id = BADGE_ID; 25 + badge.className = 'offline-badge'; 26 + badge.setAttribute('role', 'status'); 27 + badge.setAttribute('aria-live', 'polite'); 28 + badge.innerHTML = '<span class="offline-badge-dot" aria-hidden="true"></span><span class="offline-badge-text">Offline</span>'; 29 + badge.style.display = 'none'; 30 + document.body.appendChild(badge); 31 + return badge; 32 + } 33 + 34 + function update(): void { 35 + const badge = ensureBadge(); 36 + if (!badge) return; 37 + const online = typeof navigator !== 'undefined' ? navigator.onLine !== false : true; 38 + badge.style.display = online ? 'none' : 'flex'; 39 + } 40 + 41 + /** 42 + * Mount the offline indicator. Safe to call multiple times — only wires 43 + * listeners once. Returns an unmount function for tests. 44 + */ 45 + export function mountOfflineIndicator(): () => void { 46 + if (mounted) return unmountOfflineIndicator; 47 + mounted = true; 48 + 49 + ensureBadge(); 50 + update(); 51 + 52 + if (typeof window !== 'undefined') { 53 + onlineHandler = () => update(); 54 + offlineHandler = () => update(); 55 + window.addEventListener('online', onlineHandler); 56 + window.addEventListener('offline', offlineHandler); 57 + } 58 + 59 + return unmountOfflineIndicator; 60 + } 61 + 62 + /** Remove the badge and its listeners. Exposed for tests. */ 63 + export function unmountOfflineIndicator(): void { 64 + if (!mounted) return; 65 + mounted = false; 66 + if (typeof window !== 'undefined') { 67 + if (onlineHandler) window.removeEventListener('online', onlineHandler); 68 + if (offlineHandler) window.removeEventListener('offline', offlineHandler); 69 + } 70 + onlineHandler = null; 71 + offlineHandler = null; 72 + const badge = typeof document !== 'undefined' ? document.getElementById(BADGE_ID) : null; 73 + if (badge) badge.remove(); 74 + } 75 + 76 + /** Returns true if the browser reports offline. Stable wrapper for tests. */ 77 + export function isOffline(): boolean { 78 + return typeof navigator !== 'undefined' && navigator.onLine === false; 79 + }
+2
src/sheets/main.ts
··· 12 12 import { storeKey, pushKeysToServer, fetchServerKeys, getLocalKeys } from '../lib/key-sync.js'; 13 13 import { ensureWrappingKey } from '../lib/key-passphrase.js'; 14 14 import { EncryptedProvider } from '../lib/provider.js'; 15 + import { mountOfflineIndicator } from '../lib/offline-indicator.js'; 15 16 import { evaluate, formatCell, cellId } from './formulas.js'; 16 17 import { RecalcEngine } from './recalc.js'; 17 18 import { getErrorInfo, formatErrorTooltip } from './error-tooltips.js'; ··· 840 841 updateStripedButtonState(); 841 842 renderNoteIndicators(); 842 843 setupTooltips(); 844 + mountOfflineIndicator();
+2
src/slides/main.ts
··· 10 10 import { importKey } from '../lib/crypto.js'; 11 11 import { EncryptedProvider } from '../lib/provider.js'; 12 12 import { setupTooltips } from '../lib/tooltips.js'; 13 + import { mountOfflineIndicator } from '../lib/offline-indicator.js'; 13 14 import { createDeck, slideCount } from './canvas-engine.js'; 14 15 import type { DeckState } from './canvas-engine.js'; 15 16 import { getLayouts, getThemes, createThemedDeck } from './layouts-themes.js'; ··· 128 129 129 130 initDropdowns(); 130 131 setupTooltips(); 132 + mountOfflineIndicator(); 131 133 setupEventHandlers(refs, actions); 132 134 setupAIChatPanel(refs, actions); 133 135
+174
tests/offline-cache.test.ts
··· 1 + // @vitest-environment jsdom 2 + import { describe, it, expect, beforeEach } from 'vitest'; 3 + import { 4 + cacheActiveDocs, 5 + cacheTrashDocs, 6 + loadCachedActiveDocs, 7 + loadCachedTrashDocs, 8 + getCacheTimestamp, 9 + clearDocListCache, 10 + } from '../src/lib/offline-cache.js'; 11 + import type { DocumentMeta } from '../src/landing-types.js'; 12 + 13 + function makeDoc(id: string, overrides: Partial<DocumentMeta> = {}): DocumentMeta { 14 + return { 15 + id, 16 + type: 'doc', 17 + name_encrypted: `enc-${id}`, 18 + deleted_at: null, 19 + tags: null, 20 + created_at: '2026-04-10T10:00:00Z', 21 + updated_at: '2026-04-10T10:00:00Z', 22 + ...overrides, 23 + }; 24 + } 25 + 26 + // Node 25 ships with a built-in `localStorage` that can shadow jsdom's. 27 + // Install a fresh in-memory shim for every test to guarantee isolation. 28 + function installLocalStorageShim(): void { 29 + const store = new Map<string, string>(); 30 + const shim = { 31 + get length() { return store.size; }, 32 + clear: () => store.clear(), 33 + getItem: (k: string) => (store.has(k) ? store.get(k)! : null), 34 + setItem: (k: string, v: string) => { store.set(k, String(v)); }, 35 + removeItem: (k: string) => { store.delete(k); }, 36 + key: (i: number) => Array.from(store.keys())[i] ?? null, 37 + }; 38 + Object.defineProperty(globalThis, 'localStorage', { 39 + configurable: true, 40 + writable: true, 41 + value: shim, 42 + }); 43 + if (typeof window !== 'undefined') { 44 + Object.defineProperty(window, 'localStorage', { 45 + configurable: true, 46 + writable: true, 47 + value: shim, 48 + }); 49 + } 50 + } 51 + 52 + describe('offline-cache', () => { 53 + beforeEach(() => { 54 + installLocalStorageShim(); 55 + }); 56 + 57 + describe('cacheActiveDocs + loadCachedActiveDocs', () => { 58 + it('returns null when nothing is cached', () => { 59 + expect(loadCachedActiveDocs()).toBeNull(); 60 + }); 61 + 62 + it('round-trips a list of docs', () => { 63 + const docs = [makeDoc('a'), makeDoc('b'), makeDoc('c')]; 64 + cacheActiveDocs(docs); 65 + const loaded = loadCachedActiveDocs(); 66 + expect(loaded).not.toBeNull(); 67 + expect(loaded).toHaveLength(3); 68 + expect(loaded?.[0]?.id).toBe('a'); 69 + expect(loaded?.[1]?.id).toBe('b'); 70 + expect(loaded?.[2]?.id).toBe('c'); 71 + }); 72 + 73 + it('round-trips an empty list', () => { 74 + cacheActiveDocs([]); 75 + expect(loadCachedActiveDocs()).toEqual([]); 76 + }); 77 + 78 + it('strips derived fields before persisting', () => { 79 + const doc = makeDoc('a'); 80 + doc._decryptedName = 'Secret'; 81 + doc._keyStr = 'key-material'; 82 + cacheActiveDocs([doc]); 83 + 84 + const loaded = loadCachedActiveDocs(); 85 + expect(loaded?.[0]?._decryptedName).toBeUndefined(); 86 + expect(loaded?.[0]?._keyStr).toBeUndefined(); 87 + expect(loaded?.[0]?.name_encrypted).toBe('enc-a'); 88 + }); 89 + 90 + it('overwrites previous cache on re-save', () => { 91 + cacheActiveDocs([makeDoc('old')]); 92 + cacheActiveDocs([makeDoc('new-1'), makeDoc('new-2')]); 93 + const loaded = loadCachedActiveDocs(); 94 + expect(loaded).toHaveLength(2); 95 + expect(loaded?.map(d => d.id)).toEqual(['new-1', 'new-2']); 96 + }); 97 + }); 98 + 99 + describe('cacheTrashDocs + loadCachedTrashDocs', () => { 100 + it('caches trash separately from active list', () => { 101 + cacheActiveDocs([makeDoc('active')]); 102 + cacheTrashDocs([makeDoc('trashed', { deleted_at: '2026-04-10T00:00:00Z' })]); 103 + 104 + expect(loadCachedActiveDocs()?.map(d => d.id)).toEqual(['active']); 105 + expect(loadCachedTrashDocs()?.map(d => d.id)).toEqual(['trashed']); 106 + }); 107 + 108 + it('returns null when trash cache is empty', () => { 109 + expect(loadCachedTrashDocs()).toBeNull(); 110 + }); 111 + }); 112 + 113 + describe('version mismatch & corruption', () => { 114 + it('returns null when stored JSON has wrong version', () => { 115 + localStorage.setItem( 116 + 'tools-doclist-cache', 117 + JSON.stringify({ v: 999, ts: Date.now(), docs: [makeDoc('a')] }), 118 + ); 119 + expect(loadCachedActiveDocs()).toBeNull(); 120 + }); 121 + 122 + it('returns null when stored value is not valid JSON', () => { 123 + localStorage.setItem('tools-doclist-cache', '{not valid json'); 124 + expect(loadCachedActiveDocs()).toBeNull(); 125 + }); 126 + 127 + it('returns null when docs field is missing', () => { 128 + localStorage.setItem( 129 + 'tools-doclist-cache', 130 + JSON.stringify({ v: 1, ts: Date.now() }), 131 + ); 132 + expect(loadCachedActiveDocs()).toBeNull(); 133 + }); 134 + 135 + it('returns null when docs field is not an array', () => { 136 + localStorage.setItem( 137 + 'tools-doclist-cache', 138 + JSON.stringify({ v: 1, ts: Date.now(), docs: 'not-an-array' }), 139 + ); 140 + expect(loadCachedActiveDocs()).toBeNull(); 141 + }); 142 + }); 143 + 144 + describe('getCacheTimestamp', () => { 145 + it('returns null when nothing is cached', () => { 146 + expect(getCacheTimestamp()).toBeNull(); 147 + }); 148 + 149 + it('returns a recent timestamp after caching', () => { 150 + const before = Date.now(); 151 + cacheActiveDocs([makeDoc('a')]); 152 + const after = Date.now(); 153 + const ts = getCacheTimestamp(); 154 + expect(ts).not.toBeNull(); 155 + expect(ts).not.toBeNull(); 156 + expect(ts as number).toBeGreaterThanOrEqual(before); 157 + expect(ts as number).toBeLessThanOrEqual(after); 158 + }); 159 + }); 160 + 161 + describe('clearDocListCache', () => { 162 + it('removes both active and trash caches', () => { 163 + cacheActiveDocs([makeDoc('a')]); 164 + cacheTrashDocs([makeDoc('b')]); 165 + clearDocListCache(); 166 + expect(loadCachedActiveDocs()).toBeNull(); 167 + expect(loadCachedTrashDocs()).toBeNull(); 168 + }); 169 + 170 + it('is a no-op when nothing was cached', () => { 171 + expect(() => clearDocListCache()).not.toThrow(); 172 + }); 173 + }); 174 + });
+101
tests/offline-indicator.test.ts
··· 1 + // @vitest-environment jsdom 2 + import { describe, it, expect, beforeEach, afterEach } from 'vitest'; 3 + import { 4 + mountOfflineIndicator, 5 + unmountOfflineIndicator, 6 + isOffline, 7 + } from '../src/lib/offline-indicator.js'; 8 + 9 + function setOnline(value: boolean): void { 10 + Object.defineProperty(navigator, 'onLine', { 11 + configurable: true, 12 + get: () => value, 13 + }); 14 + } 15 + 16 + describe('offline-indicator', () => { 17 + beforeEach(() => { 18 + document.body.innerHTML = ''; 19 + setOnline(true); 20 + }); 21 + 22 + afterEach(() => { 23 + unmountOfflineIndicator(); 24 + setOnline(true); 25 + }); 26 + 27 + it('creates a badge element on mount', () => { 28 + mountOfflineIndicator(); 29 + const badge = document.getElementById('tools-offline-badge'); 30 + expect(badge).not.toBeNull(); 31 + expect(badge!.getAttribute('role')).toBe('status'); 32 + expect(badge!.getAttribute('aria-live')).toBe('polite'); 33 + }); 34 + 35 + it('hides the badge when online', () => { 36 + setOnline(true); 37 + mountOfflineIndicator(); 38 + const badge = document.getElementById('tools-offline-badge'); 39 + expect(badge!.style.display).toBe('none'); 40 + }); 41 + 42 + it('shows the badge when offline at mount time', () => { 43 + setOnline(false); 44 + mountOfflineIndicator(); 45 + const badge = document.getElementById('tools-offline-badge'); 46 + expect(badge!.style.display).toBe('flex'); 47 + }); 48 + 49 + it('updates visibility when the offline event fires', () => { 50 + setOnline(true); 51 + mountOfflineIndicator(); 52 + const badge = document.getElementById('tools-offline-badge'); 53 + expect(badge!.style.display).toBe('none'); 54 + 55 + setOnline(false); 56 + window.dispatchEvent(new Event('offline')); 57 + expect(badge!.style.display).toBe('flex'); 58 + }); 59 + 60 + it('updates visibility when the online event fires', () => { 61 + setOnline(false); 62 + mountOfflineIndicator(); 63 + const badge = document.getElementById('tools-offline-badge'); 64 + expect(badge!.style.display).toBe('flex'); 65 + 66 + setOnline(true); 67 + window.dispatchEvent(new Event('online')); 68 + expect(badge!.style.display).toBe('none'); 69 + }); 70 + 71 + it('is safe to mount twice (idempotent)', () => { 72 + mountOfflineIndicator(); 73 + mountOfflineIndicator(); 74 + const badges = document.querySelectorAll('#tools-offline-badge'); 75 + expect(badges.length).toBe(1); 76 + }); 77 + 78 + it('removes the badge on unmount', () => { 79 + mountOfflineIndicator(); 80 + expect(document.getElementById('tools-offline-badge')).not.toBeNull(); 81 + unmountOfflineIndicator(); 82 + expect(document.getElementById('tools-offline-badge')).toBeNull(); 83 + }); 84 + 85 + it('stops reacting to events after unmount', () => { 86 + setOnline(true); 87 + mountOfflineIndicator(); 88 + unmountOfflineIndicator(); 89 + // Even if we fire events after unmount, no badge should come back. 90 + setOnline(false); 91 + window.dispatchEvent(new Event('offline')); 92 + expect(document.getElementById('tools-offline-badge')).toBeNull(); 93 + }); 94 + 95 + it('isOffline() reflects navigator.onLine', () => { 96 + setOnline(true); 97 + expect(isOffline()).toBe(false); 98 + setOnline(false); 99 + expect(isOffline()).toBe(true); 100 + }); 101 + });