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(calendar): make calendar a singleton per user' (#310) from feat/singleton-calendar into main

scott f2c77d1f db45c88c

+77 -7
+71 -1
src/landing-create.ts
··· 1 1 /** 2 2 * Document creation functions: new doc/sheet/form/slide/diagram, 3 - * template-based creation, and daily notes. 3 + * template-based creation, daily notes, and singleton calendar. 4 4 * 5 5 * Extracted from landing.ts for decomposition. 6 6 */ ··· 113 113 114 114 const path = template.type === 'doc' ? '/docs' : '/sheets'; 115 115 window.location.href = `${path}/${id}#${keyStr}`; 116 + } 117 + 118 + // ── Singleton calendar ─────────────────────────────────────── 119 + 120 + /** 121 + * Open the user's calendar — creates one if none exists. 122 + * Calendar is a singleton: each user has exactly one. 123 + */ 124 + export async function openCalendar(deps: CreateDeps): Promise<void> { 125 + // Fast path: check localStorage for a known calendar doc ID 126 + const cachedId = localStorage.getItem('tools-calendar-id'); 127 + if (cachedId) { 128 + const keys = await getLocalKeys(); 129 + const keyStr = keys[cachedId]; 130 + if (keyStr) { 131 + // Verify the doc still exists in the loaded list 132 + const doc = deps.getAllDocs().find(d => d.id === cachedId && d.type === 'calendar'); 133 + if (doc) { 134 + const updatedRecent = trackRecentDoc(deps.getRecentIds(), cachedId); 135 + deps.setRecentIds(updatedRecent); 136 + localStorage.setItem('tools-recent', JSON.stringify(updatedRecent)); 137 + window.location.href = `/calendar/${cachedId}#${keyStr}`; 138 + return; 139 + } 140 + } 141 + } 142 + 143 + // Check all docs for an existing calendar 144 + const existing = deps.getAllDocs().find(d => d.type === 'calendar'); 145 + if (existing) { 146 + const keys = await getLocalKeys(); 147 + const keyStr = keys[existing.id]; 148 + if (keyStr) { 149 + localStorage.setItem('tools-calendar-id', existing.id); 150 + const updatedRecent = trackRecentDoc(deps.getRecentIds(), existing.id); 151 + deps.setRecentIds(updatedRecent); 152 + localStorage.setItem('tools-recent', JSON.stringify(updatedRecent)); 153 + window.location.href = `/calendar/${existing.id}#${keyStr}`; 154 + return; 155 + } 156 + } 157 + 158 + // No calendar exists — create one 159 + const key = await generateKey(); 160 + const keyStr = await exportKey(key); 161 + 162 + const nameBytes = new TextEncoder().encode('Calendar'); 163 + const { encrypt } = await import('./lib/crypto.js'); 164 + const encryptedName = await encrypt(nameBytes, key); 165 + const nameB64 = btoa(String.fromCharCode(...encryptedName)); 166 + 167 + const res = await fetch('/api/documents', { 168 + method: 'POST', 169 + headers: { 'Content-Type': 'application/json' }, 170 + body: JSON.stringify({ type: 'calendar', name_encrypted: nameB64 }), 171 + }); 172 + if (!res.ok) { showToast('Failed to create calendar', 4000, true); return; } 173 + const { id } = await res.json(); 174 + 175 + await ensureWrappingKeyForStore(); 176 + await storeKey(id, keyStr); 177 + pushKeysToServer({ [id]: keyStr }); 178 + 179 + localStorage.setItem('tools-calendar-id', id); 180 + 181 + const updatedRecent = trackRecentDoc(deps.getRecentIds(), id); 182 + deps.setRecentIds(updatedRecent); 183 + localStorage.setItem('tools-recent', JSON.stringify(updatedRecent)); 184 + 185 + window.location.href = `/calendar/${id}#${keyStr}`; 116 186 } 117 187 118 188 // ── Daily note ───────────────────────────────────────────────
+2 -2
src/landing-types.ts
··· 4 4 5 5 export interface DocumentMeta { 6 6 id: string; 7 - type: 'doc' | 'sheet' | 'form' | 'slide' | 'diagram'; 7 + type: 'doc' | 'sheet' | 'form' | 'slide' | 'diagram' | 'calendar'; 8 8 name_encrypted: string | null; 9 9 deleted_at: string | null; 10 10 tags: string | null; ··· 55 55 expired: string[]; 56 56 } 57 57 58 - export type DocType = 'doc' | 'sheet' | 'form' | 'slide' | 'diagram'; 58 + export type DocType = 'doc' | 'sheet' | 'form' | 'slide' | 'diagram' | 'calendar'; 59 59 export type ImportType = 'docx' | 'xlsx' | 'csv' | 'md'; 60 60 61 61 export interface SortLabels {
+4 -4
src/landing.ts
··· 15 15 import type { RenderDeps } from './landing-render.js'; 16 16 import { setupDelegatedListeners, setupOneOffListeners, initUsername } from './landing-events.js'; 17 17 import type { EventDeps } from './landing-events.js'; 18 - import { createDocument, createFromTemplate, openDailyNote } from './landing-create.js'; 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 21 ··· 175 175 newFormBtn.addEventListener('click', (e) => { e.preventDefault(); createDocument(createDeps, 'form'); }); 176 176 newSlideBtn.addEventListener('click', (e) => { e.preventDefault(); createDocument(createDeps, 'slide'); }); 177 177 newDiagramBtn.addEventListener('click', (e) => { e.preventDefault(); createDocument(createDeps, 'diagram'); }); 178 - newCalendarBtn.addEventListener('click', (e) => { e.preventDefault(); createDocument(createDeps, 'calendar'); }); 178 + newCalendarBtn.addEventListener('click', (e) => { e.preventDefault(); openCalendar(createDeps); }); 179 179 dailyNoteBtn.addEventListener('click', (e) => { e.preventDefault(); openDailyNote(createDeps); }); 180 180 181 181 // --- Drag-and-drop file import --- ··· 262 262 { id: 'new-form', label: 'New Form', category: 'action', icon: '\u2637', action: () => createDocument(createDeps, 'form') }, 263 263 { id: 'new-slide', label: 'New Presentation', category: 'action', icon: '\u25eb', action: () => createDocument(createDeps, 'slide') }, 264 264 { id: 'new-diagram', label: 'New Diagram', category: 'action', icon: '\u25d3', action: () => createDocument(createDeps, 'diagram') }, 265 - { id: 'new-calendar', label: 'Calendar', category: 'action', icon: '\u2630', action: () => createDocument(createDeps, 'calendar') }, 265 + { id: 'new-calendar', label: 'Calendar', category: 'action', icon: '\u2630', action: () => openCalendar(createDeps) }, 266 266 { id: 'daily-note', label: "Today's Note", category: 'action', icon: '\u2666', action: () => openDailyNote(createDeps) }, 267 267 { id: 'backup-export', label: 'Export Backup', category: 'action', icon: '\u2913', action: () => backupExportBtn.click() }, 268 268 { id: 'backup-import', label: 'Restore Backup', category: 'action', icon: '\u2912', action: () => backupImportBtn.click() }, ··· 323 323 'new-form': () => createDocument(createDeps, 'form'), 324 324 'new-slide': () => createDocument(createDeps, 'slide'), 325 325 'new-diagram': () => createDocument(createDeps, 'diagram'), 326 - 'new-calendar': () => createDocument(createDeps, 'calendar'), 326 + 'new-calendar': () => openCalendar(createDeps), 327 327 }; 328 328 const handler = actionMap[urlAction]; 329 329 if (handler) {