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: daily notes / journal shortcut (#132)' (#117) from feat/daily-notes into main

scott bf95fa20 1163fb72

+235
+9
src/css/app.css
··· 429 429 line-height: 1.4; 430 430 } 431 431 432 + .create-card-accent { 433 + border-color: oklch(0.65 0.12 250 / 0.4); 434 + background: oklch(0.65 0.12 250 / 0.05); 435 + } 436 + .create-card-accent:hover { 437 + border-color: oklch(0.65 0.12 250 / 0.7); 438 + background: oklch(0.65 0.12 250 / 0.1); 439 + } 440 + 432 441 /* Document list */ 433 442 /* ======================================================== 434 443 Recent Documents (#116)
+63
src/daily-notes.ts
··· 1 + /** 2 + * Daily Notes — Obsidian-style journal shortcut. 3 + * 4 + * Pure logic module: date formatting, doc matching, template generation. 5 + * No DOM dependencies — UI integration is in landing.ts. 6 + */ 7 + 8 + const DAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']; 9 + 10 + /** 11 + * Format a date as a daily note name: "YYYY-MM-DD — DayName" 12 + */ 13 + export function formatDailyNoteName(date?: Date): string { 14 + const d = date || new Date(); 15 + const yyyy = d.getFullYear(); 16 + const mm = String(d.getMonth() + 1).padStart(2, '0'); 17 + const dd = String(d.getDate()).padStart(2, '0'); 18 + const day = DAYS[d.getDay()]; 19 + return `${yyyy}-${mm}-${dd} — ${day}`; 20 + } 21 + 22 + /** 23 + * Get just the date prefix "YYYY-MM-DD" for matching. 24 + */ 25 + function datePrefix(date: Date): string { 26 + const yyyy = date.getFullYear(); 27 + const mm = String(date.getMonth() + 1).padStart(2, '0'); 28 + const dd = String(date.getDate()).padStart(2, '0'); 29 + return `${yyyy}-${mm}-${dd}`; 30 + } 31 + 32 + interface DailyNoteDoc { 33 + id: string; 34 + _decryptedName?: string; 35 + type: 'doc' | 'sheet'; 36 + deleted_at?: string | null; 37 + } 38 + 39 + /** 40 + * Find an existing daily note doc for the given date. 41 + * Returns the doc ID if found, null otherwise. 42 + */ 43 + export function findDailyNote(docs: DailyNoteDoc[], date?: Date): string | null { 44 + const d = date || new Date(); 45 + const prefix = datePrefix(d); 46 + 47 + for (const doc of docs) { 48 + if (doc.type !== 'doc') continue; 49 + if (doc.deleted_at) continue; 50 + if (!doc._decryptedName) continue; 51 + if (doc._decryptedName.startsWith(prefix)) return doc.id; 52 + } 53 + return null; 54 + } 55 + 56 + /** 57 + * Generate HTML template for a new daily note. 58 + */ 59 + export function getDailyNoteTemplate(date?: Date): string { 60 + const d = date || new Date(); 61 + const name = formatDailyNoteName(d); 62 + return `<h1>${name}</h1><h2>Tasks</h2><ul data-type="taskList"><li data-type="taskItem" data-checked="false"><p></p></li></ul><h2>Notes</h2><p></p>`; 63 + }
+8
src/docs/main.ts
··· 971 971 window.__importInProgress = false; 972 972 } 973 973 } 974 + 975 + // Check for daily note template 976 + const templateKey = `daily-note-template-${docId}`; 977 + const templateHtml = sessionStorage.getItem(templateKey); 978 + if (templateHtml && editor.isEmpty) { 979 + sessionStorage.removeItem(templateKey); 980 + editor.commands.setContent(templateHtml); 981 + } 974 982 }); 975 983 976 984 // --- Download helper ---
+5
src/index.html
··· 46 46 <span class="create-card-title">New Spreadsheet</span> 47 47 <span class="create-card-desc">Formulas, formatting, multiple sheets, and real-time collaboration</span> 48 48 </a> 49 + <a class="create-card create-card-accent" id="daily-note" href="#"> 50 + <span class="create-card-icon">&#128197;</span> 51 + <span class="create-card-title">Today's Note</span> 52 + <span class="create-card-desc">Open or create today's journal entry</span> 53 + </a> 49 54 </div> 50 55 </header> 51 56
+56
src/landing.ts
··· 2 2 import { generateKey, exportKey, importKey, decryptString } from './lib/crypto.js'; 3 3 import { createCommandPalette, type PaletteAction } from './command-palette.js'; 4 4 import { parseTags, addTag, removeTag, saveDocumentTags, collectAllTags, filterByTag } from './tags.js'; 5 + import { formatDailyNoteName, findDailyNote, getDailyNoteTemplate } from './daily-notes.js'; 5 6 import { 6 7 sortDocuments, 7 8 toggleStar, ··· 33 34 const noResultsEl = document.getElementById('no-results') as HTMLElement; 34 35 const newDocBtn = document.getElementById('new-doc') as HTMLElement; 35 36 const newSheetBtn = document.getElementById('new-sheet') as HTMLElement; 37 + const dailyNoteBtn = document.getElementById('daily-note') as HTMLElement; 36 38 const searchInput = document.getElementById('search-input') as HTMLInputElement; 37 39 const searchClear = document.getElementById('search-clear') as HTMLElement; 38 40 const sortBtn = document.getElementById('sort-btn') as HTMLElement; ··· 189 191 190 192 newDocBtn.addEventListener('click', (e) => { e.preventDefault(); createDocument('doc'); }); 191 193 newSheetBtn.addEventListener('click', (e) => { e.preventDefault(); createDocument('sheet'); }); 194 + 195 + // --- Daily Note --- 196 + async function openDailyNote(): Promise<void> { 197 + // Check if today's note already exists 198 + const existingId = findDailyNote(allDocs); 199 + if (existingId) { 200 + const keys = JSON.parse(localStorage.getItem('tools-keys') || '{}'); 201 + const keyStr = keys[existingId]; 202 + if (keyStr) { 203 + recentIds = trackRecentDoc(recentIds, existingId); 204 + localStorage.setItem('tools-recent', JSON.stringify(recentIds)); 205 + window.location.href = `/docs/${existingId}#${keyStr}`; 206 + return; 207 + } 208 + } 209 + 210 + // Create a new daily note 211 + const key = await generateKey(); 212 + const keyStr = await exportKey(key); 213 + const name = formatDailyNoteName(); 214 + const nameBytes = new TextEncoder().encode(name); 215 + const { encrypt } = await import('./lib/crypto.js'); 216 + const encryptedName = await encrypt(nameBytes, key); 217 + const nameB64 = btoa(String.fromCharCode(...encryptedName)); 218 + 219 + const res = await fetch('/api/documents', { 220 + method: 'POST', 221 + headers: { 'Content-Type': 'application/json' }, 222 + body: JSON.stringify({ type: 'doc', name_encrypted: nameB64 }), 223 + }); 224 + const { id } = await res.json(); 225 + 226 + const keys = JSON.parse(localStorage.getItem('tools-keys') || '{}'); 227 + keys[id] = keyStr; 228 + localStorage.setItem('tools-keys', JSON.stringify(keys)); 229 + 230 + // Store template for the editor to pick up 231 + const template = getDailyNoteTemplate(); 232 + sessionStorage.setItem('daily-note-template-' + id, template); 233 + 234 + recentIds = trackRecentDoc(recentIds, id); 235 + localStorage.setItem('tools-recent', JSON.stringify(recentIds)); 236 + 237 + window.location.href = `/docs/${id}#${keyStr}`; 238 + } 239 + 240 + dailyNoteBtn.addEventListener('click', (e) => { e.preventDefault(); openDailyNote(); }); 192 241 193 242 // --- Sort --- 194 243 sortLabel.textContent = SORT_LABELS[currentSort] || SORT_LABELS.updated; ··· 999 1048 category: 'action', 1000 1049 icon: '\u25a6', 1001 1050 action: () => createDocument('sheet'), 1051 + }, 1052 + { 1053 + id: 'daily-note', 1054 + label: "Today's Note", 1055 + category: 'action', 1056 + icon: '\uD83D\uDCC5', 1057 + action: () => openDailyNote(), 1002 1058 }, 1003 1059 ], 1004 1060 fetchDocuments: async (): Promise<PaletteAction[]> => {
+94
tests/daily-notes.test.ts
··· 1 + import { describe, it, expect } from 'vitest'; 2 + import { formatDailyNoteName, findDailyNote, getDailyNoteTemplate } from '../src/daily-notes.js'; 3 + 4 + describe('Daily Notes', () => { 5 + describe('formatDailyNoteName', () => { 6 + it('formats a date as YYYY-MM-DD — Day', () => { 7 + const date = new Date(2026, 2, 23); // March 23, 2026 (Monday) 8 + expect(formatDailyNoteName(date)).toBe('2026-03-23 — Monday'); 9 + }); 10 + 11 + it('formats with zero-padded month and day', () => { 12 + const date = new Date(2026, 0, 5); // Jan 5 13 + expect(formatDailyNoteName(date)).toBe('2026-01-05 — Monday'); 14 + }); 15 + 16 + it('handles different days of the week', () => { 17 + // March 23, 2026 is a Monday 18 + expect(formatDailyNoteName(new Date(2026, 2, 23))).toContain('Monday'); 19 + expect(formatDailyNoteName(new Date(2026, 2, 24))).toContain('Tuesday'); 20 + expect(formatDailyNoteName(new Date(2026, 2, 25))).toContain('Wednesday'); 21 + expect(formatDailyNoteName(new Date(2026, 2, 26))).toContain('Thursday'); 22 + expect(formatDailyNoteName(new Date(2026, 2, 27))).toContain('Friday'); 23 + expect(formatDailyNoteName(new Date(2026, 2, 28))).toContain('Saturday'); 24 + expect(formatDailyNoteName(new Date(2026, 2, 29))).toContain('Sunday'); 25 + }); 26 + 27 + it('defaults to today when no date is provided', () => { 28 + const name = formatDailyNoteName(); 29 + const today = new Date(); 30 + const yyyy = today.getFullYear(); 31 + const mm = String(today.getMonth() + 1).padStart(2, '0'); 32 + const dd = String(today.getDate()).padStart(2, '0'); 33 + expect(name).toContain(`${yyyy}-${mm}-${dd}`); 34 + }); 35 + }); 36 + 37 + describe('findDailyNote', () => { 38 + it('finds existing doc matching daily note name', () => { 39 + const docs = [ 40 + { id: 'abc', _decryptedName: '2026-03-23 — Monday', type: 'doc' as const }, 41 + { id: 'def', _decryptedName: 'Other Doc', type: 'doc' as const }, 42 + ]; 43 + const result = findDailyNote(docs, new Date(2026, 2, 23)); 44 + expect(result).toBe('abc'); 45 + }); 46 + 47 + it('returns null when no matching doc exists', () => { 48 + const docs = [ 49 + { id: 'def', _decryptedName: 'Other Doc', type: 'doc' as const }, 50 + ]; 51 + const result = findDailyNote(docs, new Date(2026, 2, 23)); 52 + expect(result).toBeNull(); 53 + }); 54 + 55 + it('ignores sheets — only matches docs', () => { 56 + const docs = [ 57 + { id: 'abc', _decryptedName: '2026-03-23 — Monday', type: 'sheet' as const }, 58 + ]; 59 + const result = findDailyNote(docs, new Date(2026, 2, 23)); 60 + expect(result).toBeNull(); 61 + }); 62 + 63 + it('ignores deleted docs', () => { 64 + const docs = [ 65 + { id: 'abc', _decryptedName: '2026-03-23 — Monday', type: 'doc' as const, deleted_at: '2026-03-23' }, 66 + ]; 67 + const result = findDailyNote(docs, new Date(2026, 2, 23)); 68 + expect(result).toBeNull(); 69 + }); 70 + 71 + it('matches by date prefix even without day name', () => { 72 + const docs = [ 73 + { id: 'abc', _decryptedName: '2026-03-23', type: 'doc' as const }, 74 + ]; 75 + const result = findDailyNote(docs, new Date(2026, 2, 23)); 76 + expect(result).toBe('abc'); 77 + }); 78 + }); 79 + 80 + describe('getDailyNoteTemplate', () => { 81 + it('returns HTML template with date heading', () => { 82 + const html = getDailyNoteTemplate(new Date(2026, 2, 23)); 83 + expect(html).toContain('2026-03-23'); 84 + expect(html).toContain('Monday'); 85 + expect(html).toContain('<h1>'); 86 + }); 87 + 88 + it('includes placeholder sections', () => { 89 + const html = getDailyNoteTemplate(new Date(2026, 2, 23)); 90 + expect(html).toContain('Tasks'); 91 + expect(html).toContain('Notes'); 92 + }); 93 + }); 94 + });