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 'fix(calendar): polish CSS/HTML alignment, colors, tests' (#306) from fix/calendar-polish into main

scott 9e69ac77 781917a1

+487 -158
+119
src/calendar/helpers.ts
··· 1 + /** 2 + * Calendar pure helper functions — extracted for testability. 3 + */ 4 + 5 + export interface CalendarEvent { 6 + id: string; 7 + title: string; 8 + date: string; // YYYY-MM-DD 9 + startTime: string; // HH:MM (24h) or '' for all-day 10 + endTime: string; // HH:MM (24h) or '' for all-day 11 + allDay: boolean; 12 + color: string; 13 + description: string; 14 + createdAt: number; 15 + updatedAt: number; 16 + } 17 + 18 + export type CalendarView = 'month' | 'week' | 'day' | 'agenda'; 19 + 20 + export const EVENT_COLORS = ['#3a8a7a', '#d94a4a', '#d9974a', '#4a7ee8', '#8e6abf', '#4aad5b']; 21 + 22 + export const DAYS_OF_WEEK = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; 23 + export const MONTHS = [ 24 + 'January', 'February', 'March', 'April', 'May', 'June', 25 + 'July', 'August', 'September', 'October', 'November', 'December', 26 + ]; 27 + export const MONTHS_SHORT = [ 28 + 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 29 + 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec', 30 + ]; 31 + 32 + export function formatDate(d: Date): string { 33 + const y = d.getFullYear(); 34 + const m = String(d.getMonth() + 1).padStart(2, '0'); 35 + const day = String(d.getDate()).padStart(2, '0'); 36 + return `${y}-${m}-${day}`; 37 + } 38 + 39 + export function parseEventDate(dateStr: string): Date { 40 + const parts = dateStr.split('-').map(Number); 41 + const y = parts[0] ?? 0; 42 + const m = parts[1] ?? 1; 43 + const d = parts[2] ?? 1; 44 + return new Date(y, m - 1, d); 45 + } 46 + 47 + export function isSameDay(a: Date, b: Date): boolean { 48 + return a.getFullYear() === b.getFullYear() && 49 + a.getMonth() === b.getMonth() && 50 + a.getDate() === b.getDate(); 51 + } 52 + 53 + export function isToday(d: Date): boolean { 54 + return isSameDay(d, new Date()); 55 + } 56 + 57 + export function getWeekStart(d: Date): Date { 58 + const result = new Date(d); 59 + result.setDate(result.getDate() - result.getDay()); 60 + return result; 61 + } 62 + 63 + export function getWeekEnd(d: Date): Date { 64 + const start = getWeekStart(d); 65 + const end = new Date(start); 66 + end.setDate(end.getDate() + 6); 67 + return end; 68 + } 69 + 70 + export function daysInMonth(year: number, month: number): number { 71 + return new Date(year, month + 1, 0).getDate(); 72 + } 73 + 74 + export function timeToMinutes(time: string): number { 75 + if (!time) return 0; 76 + const parts = time.split(':').map(Number); 77 + const h = parts[0] ?? 0; 78 + const m = parts[1] ?? 0; 79 + return h * 60 + m; 80 + } 81 + 82 + export function minutesToTime(minutes: number): string { 83 + const h = Math.floor(minutes / 60); 84 + const m = minutes % 60; 85 + return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`; 86 + } 87 + 88 + export function formatTimeDisplay(time: string): string { 89 + if (!time) return ''; 90 + const parts = time.split(':'); 91 + const hStr = parts[0] ?? '0'; 92 + const mStr = parts[1] ?? '00'; 93 + const h = parseInt(hStr, 10); 94 + const ampm = h >= 12 ? 'pm' : 'am'; 95 + const h12 = h === 0 ? 12 : h > 12 ? h - 12 : h; 96 + return mStr === '00' ? `${h12}${ampm}` : `${h12}:${mStr}${ampm}`; 97 + } 98 + 99 + export function getDayOfWeekFull(d: Date): string { 100 + const days = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']; 101 + return days[d.getDay()] ?? 'Sunday'; 102 + } 103 + 104 + export function eventsOnDate(events: CalendarEvent[], dateStr: string): CalendarEvent[] { 105 + return events 106 + .filter(e => e.date === dateStr) 107 + .sort((a, b) => { 108 + if (a.allDay && !b.allDay) return -1; 109 + if (!a.allDay && b.allDay) return 1; 110 + return timeToMinutes(a.startTime) - timeToMinutes(b.startTime); 111 + }); 112 + } 113 + 114 + /** Build the month grid cell count (variable rows based on month layout). */ 115 + export function monthGridCellCount(year: number, month: number): number { 116 + const firstDay = new Date(year, month, 1).getDay(); 117 + const totalDays = daysInMonth(year, month); 118 + return Math.ceil((firstDay + totalDays) / 7) * 7; 119 + }
+19 -22
src/calendar/index.html
··· 39 39 <input class="doc-title-input" id="calendar-title" type="text" value="Untitled Calendar" spellcheck="false"> 40 40 <span class="topbar-spacer"></span> 41 41 <span class="save-status" id="save-status"></span> 42 - <div class="calendar-view-switcher"> 43 - <button class="btn-secondary calendar-view-btn active" data-view="month">Month</button> 44 - <button class="btn-secondary calendar-view-btn" data-view="week">Week</button> 45 - <button class="btn-secondary calendar-view-btn" data-view="day">Day</button> 46 - <button class="btn-secondary calendar-view-btn" data-view="agenda">Agenda</button> 47 - </div> 42 + <button class="btn-icon" id="btn-theme-toggle" title="Toggle theme"> 43 + <svg class="tb-icon" viewBox="0 0 16 16" style="width:16px;height:16px" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="8" cy="8" r="3.5"/><path d="M8 1.5v1M8 13.5v1M1.5 8h1M13.5 8h1M3.4 3.4l.7.7M11.9 11.9l.7.7M3.4 12.6l.7-.7M11.9 4.1l.7-.7"/></svg> 44 + </button> 48 45 <button class="btn-icon" id="btn-ai-chat" title="AI Chat (Cmd+Shift+L)"> 49 46 <svg class="tb-icon" viewBox="0 0 16 16" style="width:16px;height:16px" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M2 3h12a1 1 0 0 1 1 1v7a1 1 0 0 1-1 1H5l-3 3V4a1 1 0 0 1 1-1z"/><circle cx="5.5" cy="7.5" r="0.5" fill="currentColor" stroke="none"/><circle cx="8" cy="7.5" r="0.5" fill="currentColor" stroke="none"/><circle cx="10.5" cy="7.5" r="0.5" fill="currentColor" stroke="none"/></svg> 50 47 </button> ··· 52 49 53 50 <main class="calendar-main" id="main-content"> 54 51 <!-- Calendar toolbar --> 55 - <div class="calendar-toolbar" id="calendar-toolbar"> 56 - <button class="btn-secondary" id="btn-today" title="Go to today">Today</button> 57 - <button class="btn-icon" id="btn-prev" title="Previous period">&#9664;</button> 58 - <button class="btn-icon" id="btn-next" title="Next period">&#9654;</button> 59 - <span class="calendar-current-label" id="current-label"></span> 52 + <div class="cal-toolbar" id="calendar-toolbar"> 53 + <button class="cal-nav-btn" id="btn-today" title="Go to today (T)">Today</button> 54 + <button class="cal-nav-btn" id="btn-prev" title="Previous period (&larr;)">&#9664;</button> 55 + <button class="cal-nav-btn" id="btn-next" title="Next period (&rarr;)">&#9654;</button> 56 + <span class="cal-date-label" id="current-label"></span> 60 57 <span class="topbar-spacer"></span> 61 - <div class="calendar-toolbar-views"> 62 - <button class="btn-icon calendar-toolbar-view active" data-view="month" title="Month view">Month</button> 63 - <button class="btn-icon calendar-toolbar-view" data-view="week" title="Week view">Week</button> 64 - <button class="btn-icon calendar-toolbar-view" data-view="day" title="Day view">Day</button> 65 - <button class="btn-icon calendar-toolbar-view" data-view="agenda" title="Agenda view">Agenda</button> 58 + <div class="cal-view-group"> 59 + <button class="cal-view-btn active" data-view="month" title="Month view (M)">Month</button> 60 + <button class="cal-view-btn" data-view="week" title="Week view (W)">Week</button> 61 + <button class="cal-view-btn" data-view="day" title="Day view (D)">Day</button> 62 + <button class="cal-view-btn" data-view="agenda" title="Agenda view (A)">Agenda</button> 66 63 </div> 67 64 </div> 68 65 ··· 104 101 <div class="event-modal-field"> 105 102 <label>Color</label> 106 103 <div class="event-color-picker" id="event-color-picker"> 107 - <button class="event-color-swatch active" data-color="#3a8a7a" style="background:#3a8a7a" title="Teal"></button> 108 - <button class="event-color-swatch" data-color="#e85d4a" style="background:#e85d4a" title="Red"></button> 109 - <button class="event-color-swatch" data-color="#e8a94a" style="background:#e8a94a" title="Amber"></button> 110 - <button class="event-color-swatch" data-color="#4a7ee8" style="background:#4a7ee8" title="Blue"></button> 111 - <button class="event-color-swatch" data-color="#8a4ae8" style="background:#8a4ae8" title="Purple"></button> 112 - <button class="event-color-swatch" data-color="#4aad5b" style="background:#4aad5b" title="Green"></button> 104 + <button class="event-color-swatch active" data-color="#3a8a7a" style="background:#3a8a7a" title="Teal" type="button"></button> 105 + <button class="event-color-swatch" data-color="#d94a4a" style="background:#d94a4a" title="Red" type="button"></button> 106 + <button class="event-color-swatch" data-color="#d9974a" style="background:#d9974a" title="Amber" type="button"></button> 107 + <button class="event-color-swatch" data-color="#4a7ee8" style="background:#4a7ee8" title="Blue" type="button"></button> 108 + <button class="event-color-swatch" data-color="#8e6abf" style="background:#8e6abf" title="Purple" type="button"></button> 109 + <button class="event-color-swatch" data-color="#4aad5b" style="background:#4aad5b" title="Green" type="button"></button> 113 110 </div> 114 111 </div> 115 112
+65 -122
src/calendar/main.ts
··· 12 12 import { EncryptedProvider } from '../lib/provider.js'; 13 13 import { setupTooltips } from '../lib/tooltips.js'; 14 14 import { createCommandPalette } from '../command-palette.js'; 15 + import { 16 + type CalendarEvent, 17 + type CalendarView, 18 + EVENT_COLORS, 19 + DAYS_OF_WEEK, 20 + MONTHS, 21 + MONTHS_SHORT, 22 + formatDate, 23 + parseEventDate, 24 + isSameDay, 25 + isToday, 26 + getWeekStart, 27 + getWeekEnd, 28 + daysInMonth, 29 + timeToMinutes, 30 + minutesToTime, 31 + formatTimeDisplay, 32 + getDayOfWeekFull, 33 + eventsOnDate as eventsOnDateHelper, 34 + } from './helpers.js'; 15 35 16 36 // --------------------------------------------------------------------------- 17 - // Types 37 + // Types & constants 18 38 // --------------------------------------------------------------------------- 19 39 20 - interface CalendarEvent { 21 - id: string; 22 - title: string; 23 - date: string; // YYYY-MM-DD 24 - startTime: string; // HH:MM (24h) or '' for all-day 25 - endTime: string; // HH:MM (24h) or '' for all-day 26 - allDay: boolean; 27 - color: string; 28 - description: string; 29 - createdAt: number; 30 - updatedAt: number; 31 - } 32 - 33 - type CalendarView = 'month' | 'week' | 'day' | 'agenda'; 34 - 35 40 interface CalendarState { 36 41 currentDate: Date; 37 42 view: CalendarView; ··· 41 46 docId: string; 42 47 } 43 48 44 - const EVENT_COLORS = ['#4a90d9', '#e06666', '#6aa84f', '#f1c232', '#8e7cc3', '#e69138']; 45 - 46 49 const HOUR_HEIGHT = 60; // px per hour row in week/day views 47 50 const MAX_VISIBLE_PILLS = 3; 48 51 const AGENDA_DAYS = 30; 49 - const DAYS_OF_WEEK = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; 50 - const MONTHS = [ 51 - 'January', 'February', 'March', 'April', 'May', 'June', 52 - 'July', 'August', 'September', 'October', 'November', 'December', 53 - ]; 54 - const MONTHS_SHORT = [ 55 - 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 56 - 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec', 57 - ]; 58 52 59 53 // --------------------------------------------------------------------------- 60 54 // URL parsing ··· 134 128 return div.innerHTML; 135 129 } 136 130 137 - function formatDate(d: Date): string { 138 - const y = d.getFullYear(); 139 - const m = String(d.getMonth() + 1).padStart(2, '0'); 140 - const day = String(d.getDate()).padStart(2, '0'); 141 - return `${y}-${m}-${day}`; 142 - } 143 - 144 - function parseEventDate(dateStr: string): Date { 145 - const parts = dateStr.split('-').map(Number); 146 - const y = parts[0] ?? 0; 147 - const m = parts[1] ?? 1; 148 - const d = parts[2] ?? 1; 149 - return new Date(y, m - 1, d); 150 - } 151 - 152 - function isSameDay(a: Date, b: Date): boolean { 153 - return a.getFullYear() === b.getFullYear() && 154 - a.getMonth() === b.getMonth() && 155 - a.getDate() === b.getDate(); 156 - } 157 - 158 - function isToday(d: Date): boolean { 159 - return isSameDay(d, new Date()); 160 - } 161 - 162 - function getWeekStart(d: Date): Date { 163 - const result = new Date(d); 164 - result.setDate(result.getDate() - result.getDay()); 165 - return result; 166 - } 167 - 168 - function getWeekEnd(d: Date): Date { 169 - const start = getWeekStart(d); 170 - const end = new Date(start); 171 - end.setDate(end.getDate() + 6); 172 - return end; 173 - } 174 - 175 - function daysInMonth(year: number, month: number): number { 176 - return new Date(year, month + 1, 0).getDate(); 177 - } 178 - 179 - function timeToMinutes(time: string): number { 180 - if (!time) return 0; 181 - const parts = time.split(':').map(Number); 182 - const h = parts[0] ?? 0; 183 - const m = parts[1] ?? 0; 184 - return h * 60 + m; 185 - } 186 - 187 - function minutesToTime(minutes: number): string { 188 - const h = Math.floor(minutes / 60); 189 - const m = minutes % 60; 190 - return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`; 191 - } 192 - 193 - function formatTimeDisplay(time: string): string { 194 - if (!time) return ''; 195 - const parts = time.split(':'); 196 - const hStr = parts[0] ?? '0'; 197 - const mStr = parts[1] ?? '00'; 198 - const h = parseInt(hStr, 10); 199 - const ampm = h >= 12 ? 'pm' : 'am'; 200 - const h12 = h === 0 ? 12 : h > 12 ? h - 12 : h; 201 - return mStr === '00' ? `${h12}${ampm}` : `${h12}:${mStr}${ampm}`; 202 - } 203 - 204 131 function eventsOnDate(dateStr: string): CalendarEvent[] { 205 - return state.events 206 - .filter(e => e.date === dateStr) 207 - .sort((a, b) => { 208 - // All-day events first, then by start time 209 - if (a.allDay && !b.allDay) return -1; 210 - if (!a.allDay && b.allDay) return 1; 211 - return timeToMinutes(a.startTime) - timeToMinutes(b.startTime); 212 - }); 213 - } 214 - 215 - function getDayOfWeekFull(d: Date): string { 216 - const days = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']; 217 - return days[d.getDay()] ?? 'Sunday'; 132 + return eventsOnDateHelper(state.events, dateStr); 218 133 } 219 134 220 135 // --------------------------------------------------------------------------- ··· 389 304 390 305 // Day-of-week headers 391 306 for (const day of DAYS_OF_WEEK) { 392 - html += `<div class="cal-day-header">${day}</div>`; 307 + html += `<div class="cal-month-header-cell">${day}</div>`; 393 308 } 394 309 395 310 // Calculate how many rows we need ··· 473 388 html += '<div class="cal-week-header">'; 474 389 html += '<div class="cal-time-gutter cal-week-header-gutter"></div>'; 475 390 for (const day of days) { 476 - const todayClass = isToday(day.date) ? ' cal-day-today' : ''; 477 - html += `<div class="cal-week-day-header${todayClass}">`; 391 + const todayClass = isToday(day.date) ? ' is-today' : ''; 392 + html += `<div class="cal-week-header-cell${todayClass}">`; 478 393 html += `<span class="cal-week-day-name">${DAYS_OF_WEEK[day.date.getDay()]}</span>`; 479 - html += `<span class="cal-week-day-num">${day.date.getDate()}</span>`; 394 + html += `<span class="cal-week-header-date">${day.date.getDate()}</span>`; 480 395 html += '</div>'; 481 396 } 482 397 html += '</div>'; ··· 485 400 const hasAllDay = days.some(d => d.allDay.length > 0); 486 401 if (hasAllDay) { 487 402 html += '<div class="cal-allday-bar">'; 488 - html += '<div class="cal-time-gutter cal-allday-label">All day</div>'; 403 + html += '<div class="cal-time-gutter cal-allday-gutter">All day</div>'; 489 404 for (const day of days) { 490 405 html += `<div class="cal-allday-cell" data-date="${day.dateStr}">`; 491 406 for (const evt of day.allDay) { ··· 508 423 html += '</div>'; 509 424 510 425 for (const day of days) { 511 - const todayClass = isToday(day.date) ? ' cal-day-today' : ''; 512 - html += `<div class="cal-week-day-col${todayClass}" data-date="${day.dateStr}">`; 426 + const dayIsToday = isToday(day.date); 427 + const todayClass = dayIsToday ? ' cal-day-today' : ''; 428 + html += `<div class="cal-day-column${todayClass}" data-date="${day.dateStr}">`; 513 429 514 430 // Hour rows (click targets) 515 431 for (let h = 0; h < 24; h++) { ··· 528 444 html += '</div>'; 529 445 } 530 446 447 + // Current time indicator 448 + if (dayIsToday) { 449 + const now = new Date(); 450 + const nowMin = now.getHours() * 60 + now.getMinutes(); 451 + const nowTop = (nowMin / 60) * HOUR_HEIGHT; 452 + html += `<div class="cal-now-line" style="top:${nowTop}px"></div>`; 453 + } 454 + 531 455 html += '</div>'; 532 456 } 533 457 ··· 551 475 552 476 // All-day section 553 477 if (allDay.length > 0) { 554 - html += '<div class="cal-allday-bar cal-day-allday">'; 555 - html += '<div class="cal-time-gutter cal-allday-label">All day</div>'; 556 - html += '<div class="cal-allday-cell">'; 478 + html += '<div class="cal-allday-bar">'; 479 + html += '<div class="cal-time-gutter cal-day-allday-gutter">All day</div>'; 480 + html += '<div class="cal-day-allday-cell">'; 557 481 for (const evt of allDay) { 558 482 html += `<div class="cal-event-pill" data-event-id="${escapeHtml(evt.id)}" style="--pill-color: ${evt.color}">`; 559 483 html += escapeHtml(evt.title || 'Untitled'); ··· 572 496 } 573 497 html += '</div>'; 574 498 575 - html += `<div class="cal-day-col" data-date="${dateStr}">`; 499 + html += `<div class="cal-day-column" data-date="${dateStr}">`; 576 500 577 501 // Hour rows 578 502 for (let h = 0; h < 24; h++) { ··· 591 515 html += '</div>'; 592 516 } 593 517 594 - html += '</div>'; // cal-day-col 518 + // Current time indicator 519 + if (isToday(d)) { 520 + const now = new Date(); 521 + const nowMin = now.getHours() * 60 + now.getMinutes(); 522 + const nowTop = (nowMin / 60) * HOUR_HEIGHT; 523 + html += `<div class="cal-now-line" style="top:${nowTop}px"></div>`; 524 + } 525 + 526 + html += '</div>'; // cal-day-column 595 527 html += '</div>'; // cal-day-body 596 528 html += '</div>'; // cal-day-grid 597 529 calendarGrid.innerHTML = html; ··· 660 592 : ''; 661 593 662 594 html += `<div class="cal-agenda-item" data-event-id="${escapeHtml(evt.id)}">`; 663 - html += `<span class="cal-agenda-color" style="background:${evt.color}"></span>`; 595 + html += `<span class="cal-agenda-dot" style="background:${evt.color}"></span>`; 664 596 html += '<div class="cal-agenda-content">'; 665 597 html += `<div class="cal-agenda-title">${escapeHtml(evt.title || 'Untitled')}</div>`; 666 598 html += `<div class="cal-agenda-time">${escapeHtml(timeStr)}</div>`; ··· 975 907 createCommandPalette({ 976 908 actions: [ 977 909 { id: 'back', label: 'Back to Documents', category: 'action', icon: '\u2190', action: () => { window.location.href = '/'; } }, 978 - { id: 'new-calendar', label: 'New Calendar', category: 'action', icon: '\u25A6', action: () => { window.open('/', '_blank'); } }, 910 + { id: 'new-event', label: 'New Event', category: 'action', icon: '+', shortcut: 'N', action: () => openModal({ date: formatDate(new Date()) }) }, 979 911 { id: 'today', label: 'Today', category: 'action', icon: '\u25C9', shortcut: 'T', action: goToday }, 980 912 { id: 'month-view', label: 'Month View', category: 'action', icon: '\u2588', shortcut: 'M', action: () => setView('month') }, 981 913 { id: 'week-view', label: 'Week View', category: 'action', icon: '\u2630', shortcut: 'W', action: () => setView('week') }, 982 914 { id: 'day-view', label: 'Day View', category: 'action', icon: '\u25A1', shortcut: 'D', action: () => setView('day') }, 983 915 { id: 'agenda-view', label: 'Agenda View', category: 'action', icon: '\u2261', shortcut: 'A', action: () => setView('agenda') }, 984 - { id: 'new-event', label: 'New Event', category: 'action', icon: '+', shortcut: 'N', action: () => openModal({ date: formatDate(new Date()) }) }, 985 916 ], 986 917 }); 987 918 ··· 996 927 } 997 928 998 929 window.addEventListener('resize', handleResize); 930 + 931 + // --------------------------------------------------------------------------- 932 + // Theme toggle 933 + // --------------------------------------------------------------------------- 934 + 935 + document.getElementById('btn-theme-toggle')?.addEventListener('click', () => { 936 + const html = document.documentElement; 937 + const current = html.getAttribute('data-theme'); 938 + const next = current === 'dark' ? 'light' : 'dark'; 939 + html.setAttribute('data-theme', next); 940 + localStorage.setItem('tools-theme', next); 941 + }); 999 942 1000 943 // --------------------------------------------------------------------------- 1001 944 // Initialize
+24 -11
src/css/app.css
··· 9527 9527 font-family: var(--font-body); 9528 9528 color: #f7f5f1; 9529 9529 color: oklch(0.97 0.005 75); 9530 - background: var(--cal-ev-teal); 9530 + background: var(--pill-color, var(--cal-ev-teal)); 9531 9531 white-space: nowrap; 9532 9532 overflow: hidden; 9533 9533 text-overflow: ellipsis; ··· 9715 9715 line-height: 1.3; 9716 9716 color: #f7f5f1; 9717 9717 color: oklch(0.97 0.005 75); 9718 - background: var(--cal-ev-teal); 9718 + background: var(--pill-color, var(--cal-ev-teal)); 9719 9719 overflow: hidden; 9720 9720 cursor: pointer; 9721 9721 z-index: 1; ··· 9897 9897 width: 8px; 9898 9898 height: 8px; 9899 9899 border-radius: 50%; 9900 - background: var(--cal-ev-teal); 9901 9900 margin-top: 0.35em; 9901 + /* background set inline via style attr */ 9902 9902 } 9903 9903 9904 9904 .cal-agenda-time { ··· 9963 9963 box-shadow: var(--shadow-lg); 9964 9964 } 9965 9965 9966 - .event-modal h3 { 9966 + .event-modal-title { 9967 9967 font-family: var(--font-display); 9968 9968 font-size: 1.2rem; 9969 - margin-bottom: var(--space-md); 9969 + margin: 0 0 var(--space-md); 9970 9970 color: var(--color-text); 9971 9971 } 9972 9972 ··· 10017 10017 line-height: 1.5; 10018 10018 } 10019 10019 10020 + .event-modal-checkbox { 10021 + flex-direction: row; 10022 + align-items: center; 10023 + } 10024 + 10025 + .event-modal-checkbox input[type="checkbox"] { 10026 + width: auto; 10027 + margin: 0; 10028 + } 10029 + 10030 + .event-modal-checkbox label { 10031 + margin: 0; 10032 + } 10033 + 10020 10034 .event-modal-row { 10021 10035 display: flex; 10022 10036 gap: var(--space-sm); ··· 10028 10042 } 10029 10043 10030 10044 /* Color swatches */ 10031 - .event-modal-colors { 10045 + .event-color-picker { 10032 10046 display: flex; 10033 10047 gap: var(--space-sm); 10034 10048 flex-wrap: wrap; 10035 10049 } 10036 10050 10037 - .event-modal-color { 10051 + .event-color-swatch { 10038 10052 width: 24px; 10039 10053 height: 24px; 10040 10054 border-radius: 50%; ··· 10042 10056 cursor: pointer; 10043 10057 transition: border-color var(--transition-fast), transform var(--transition-fast); 10044 10058 padding: 0; 10045 - background: none; 10046 10059 } 10047 10060 10048 - .event-modal-color:hover { 10061 + .event-color-swatch:hover { 10049 10062 transform: scale(1.15); 10050 10063 } 10051 10064 10052 - .event-modal-color.selected { 10065 + .event-color-swatch.active { 10053 10066 border-color: var(--color-text); 10054 10067 box-shadow: 0 0 0 2px var(--color-bg), 0 0 0 4px var(--color-text-muted); 10055 10068 } 10056 10069 10057 - .event-modal-color:focus-visible { 10070 + .event-color-swatch:focus-visible { 10058 10071 outline: 2px solid var(--color-accent); 10059 10072 outline-offset: 2px; 10060 10073 }
+2 -2
src/index.html
··· 72 72 </a> 73 73 <a class="create-card" id="new-calendar" href="#"> 74 74 <span class="create-card-icon">&#9776;</span> 75 - <span class="create-card-title">New Calendar</span> 76 - <span class="create-card-desc">Personal calendar with month, week, and day views</span> 75 + <span class="create-card-title">Calendar</span> 76 + <span class="create-card-desc">E2EE calendar with month, week, day, and agenda views</span> 77 77 </a> 78 78 <a class="create-card create-card-accent" id="daily-note" href="#"> 79 79 <span class="create-card-icon">&#9830;</span>
+1 -1
src/landing.ts
··· 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: 'New Calendar', category: 'action', icon: '\u2630', action: () => createDocument(createDeps, 'calendar') }, 265 + { id: 'new-calendar', label: 'Calendar', category: 'action', icon: '\u2630', action: () => createDocument(createDeps, 'calendar') }, 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() },
+257
tests/calendar-helpers.test.ts
··· 1 + import { describe, it, expect } from 'vitest'; 2 + import { 3 + formatDate, 4 + parseEventDate, 5 + isSameDay, 6 + getWeekStart, 7 + getWeekEnd, 8 + daysInMonth, 9 + timeToMinutes, 10 + minutesToTime, 11 + formatTimeDisplay, 12 + getDayOfWeekFull, 13 + eventsOnDate, 14 + monthGridCellCount, 15 + EVENT_COLORS, 16 + DAYS_OF_WEEK, 17 + MONTHS, 18 + type CalendarEvent, 19 + } from '../src/calendar/helpers.js'; 20 + 21 + function makeEvent(overrides: Partial<CalendarEvent> = {}): CalendarEvent { 22 + return { 23 + id: crypto.randomUUID(), 24 + title: 'Test Event', 25 + date: '2026-04-08', 26 + startTime: '09:00', 27 + endTime: '10:00', 28 + allDay: false, 29 + color: '#3a8a7a', 30 + description: '', 31 + createdAt: Date.now(), 32 + updatedAt: Date.now(), 33 + ...overrides, 34 + }; 35 + } 36 + 37 + describe('Calendar Helpers', () => { 38 + describe('formatDate', () => { 39 + it('formats a date as YYYY-MM-DD', () => { 40 + expect(formatDate(new Date(2026, 0, 5))).toBe('2026-01-05'); 41 + expect(formatDate(new Date(2026, 11, 25))).toBe('2026-12-25'); 42 + }); 43 + 44 + it('zero-pads single-digit month and day', () => { 45 + expect(formatDate(new Date(2026, 2, 3))).toBe('2026-03-03'); 46 + }); 47 + }); 48 + 49 + describe('parseEventDate', () => { 50 + it('parses YYYY-MM-DD to a Date', () => { 51 + const d = parseEventDate('2026-04-08'); 52 + expect(d.getFullYear()).toBe(2026); 53 + expect(d.getMonth()).toBe(3); // April = 3 54 + expect(d.getDate()).toBe(8); 55 + }); 56 + 57 + it('roundtrips with formatDate', () => { 58 + const original = new Date(2026, 5, 15); 59 + expect(parseEventDate(formatDate(original)).getDate()).toBe(15); 60 + expect(parseEventDate(formatDate(original)).getMonth()).toBe(5); 61 + }); 62 + }); 63 + 64 + describe('isSameDay', () => { 65 + it('returns true for same day', () => { 66 + const a = new Date(2026, 3, 8, 10, 30); 67 + const b = new Date(2026, 3, 8, 22, 0); 68 + expect(isSameDay(a, b)).toBe(true); 69 + }); 70 + 71 + it('returns false for different days', () => { 72 + const a = new Date(2026, 3, 8); 73 + const b = new Date(2026, 3, 9); 74 + expect(isSameDay(a, b)).toBe(false); 75 + }); 76 + 77 + it('returns false for same day different month', () => { 78 + const a = new Date(2026, 3, 8); 79 + const b = new Date(2026, 4, 8); 80 + expect(isSameDay(a, b)).toBe(false); 81 + }); 82 + }); 83 + 84 + describe('getWeekStart / getWeekEnd', () => { 85 + it('returns Sunday as week start', () => { 86 + // 2026-04-08 is a Wednesday 87 + const wed = new Date(2026, 3, 8); 88 + const start = getWeekStart(wed); 89 + expect(start.getDay()).toBe(0); // Sunday 90 + expect(start.getDate()).toBe(5); 91 + }); 92 + 93 + it('returns Saturday as week end', () => { 94 + const wed = new Date(2026, 3, 8); 95 + const end = getWeekEnd(wed); 96 + expect(end.getDay()).toBe(6); // Saturday 97 + expect(end.getDate()).toBe(11); 98 + }); 99 + 100 + it('handles Sunday input (already week start)', () => { 101 + const sun = new Date(2026, 3, 5); 102 + expect(getWeekStart(sun).getDate()).toBe(5); 103 + expect(getWeekEnd(sun).getDate()).toBe(11); 104 + }); 105 + }); 106 + 107 + describe('daysInMonth', () => { 108 + it('returns 31 for January', () => { 109 + expect(daysInMonth(2026, 0)).toBe(31); 110 + }); 111 + 112 + it('returns 28 for non-leap February', () => { 113 + expect(daysInMonth(2026, 1)).toBe(28); 114 + }); 115 + 116 + it('returns 29 for leap year February', () => { 117 + expect(daysInMonth(2028, 1)).toBe(29); 118 + }); 119 + 120 + it('returns 30 for April', () => { 121 + expect(daysInMonth(2026, 3)).toBe(30); 122 + }); 123 + }); 124 + 125 + describe('timeToMinutes', () => { 126 + it('converts HH:MM to minutes', () => { 127 + expect(timeToMinutes('00:00')).toBe(0); 128 + expect(timeToMinutes('09:30')).toBe(570); 129 + expect(timeToMinutes('23:59')).toBe(1439); 130 + }); 131 + 132 + it('returns 0 for empty string', () => { 133 + expect(timeToMinutes('')).toBe(0); 134 + }); 135 + }); 136 + 137 + describe('minutesToTime', () => { 138 + it('converts minutes to HH:MM', () => { 139 + expect(minutesToTime(0)).toBe('00:00'); 140 + expect(minutesToTime(570)).toBe('09:30'); 141 + expect(minutesToTime(1439)).toBe('23:59'); 142 + }); 143 + 144 + it('roundtrips with timeToMinutes', () => { 145 + expect(minutesToTime(timeToMinutes('14:45'))).toBe('14:45'); 146 + }); 147 + }); 148 + 149 + describe('formatTimeDisplay', () => { 150 + it('formats midnight as 12am', () => { 151 + expect(formatTimeDisplay('00:00')).toBe('12am'); 152 + }); 153 + 154 + it('formats noon as 12pm', () => { 155 + expect(formatTimeDisplay('12:00')).toBe('12pm'); 156 + }); 157 + 158 + it('formats morning time', () => { 159 + expect(formatTimeDisplay('09:00')).toBe('9am'); 160 + }); 161 + 162 + it('formats afternoon time', () => { 163 + expect(formatTimeDisplay('14:00')).toBe('2pm'); 164 + }); 165 + 166 + it('includes minutes when not :00', () => { 167 + expect(formatTimeDisplay('09:30')).toBe('9:30am'); 168 + expect(formatTimeDisplay('14:15')).toBe('2:15pm'); 169 + }); 170 + 171 + it('returns empty for empty input', () => { 172 + expect(formatTimeDisplay('')).toBe(''); 173 + }); 174 + }); 175 + 176 + describe('getDayOfWeekFull', () => { 177 + it('returns full day names', () => { 178 + // 2026-04-05 is Sunday 179 + expect(getDayOfWeekFull(new Date(2026, 3, 5))).toBe('Sunday'); 180 + expect(getDayOfWeekFull(new Date(2026, 3, 8))).toBe('Wednesday'); 181 + expect(getDayOfWeekFull(new Date(2026, 3, 11))).toBe('Saturday'); 182 + }); 183 + }); 184 + 185 + describe('eventsOnDate', () => { 186 + it('filters events by date', () => { 187 + const events = [ 188 + makeEvent({ date: '2026-04-08', title: 'A' }), 189 + makeEvent({ date: '2026-04-09', title: 'B' }), 190 + makeEvent({ date: '2026-04-08', title: 'C' }), 191 + ]; 192 + const result = eventsOnDate(events, '2026-04-08'); 193 + expect(result).toHaveLength(2); 194 + expect(result.map(e => e.title)).toEqual(['A', 'C']); 195 + }); 196 + 197 + it('sorts all-day events before timed events', () => { 198 + const events = [ 199 + makeEvent({ date: '2026-04-08', allDay: false, startTime: '09:00', title: 'Timed' }), 200 + makeEvent({ date: '2026-04-08', allDay: true, startTime: '', title: 'All Day' }), 201 + ]; 202 + const result = eventsOnDate(events, '2026-04-08'); 203 + expect(result[0]!.title).toBe('All Day'); 204 + expect(result[1]!.title).toBe('Timed'); 205 + }); 206 + 207 + it('sorts timed events by start time', () => { 208 + const events = [ 209 + makeEvent({ date: '2026-04-08', startTime: '14:00', title: 'Afternoon' }), 210 + makeEvent({ date: '2026-04-08', startTime: '08:00', title: 'Morning' }), 211 + makeEvent({ date: '2026-04-08', startTime: '12:00', title: 'Noon' }), 212 + ]; 213 + const result = eventsOnDate(events, '2026-04-08'); 214 + expect(result.map(e => e.title)).toEqual(['Morning', 'Noon', 'Afternoon']); 215 + }); 216 + 217 + it('returns empty for date with no events', () => { 218 + const events = [makeEvent({ date: '2026-04-08' })]; 219 + expect(eventsOnDate(events, '2026-04-09')).toEqual([]); 220 + }); 221 + }); 222 + 223 + describe('monthGridCellCount', () => { 224 + it('returns correct cell count for a month', () => { 225 + // April 2026: starts on Wednesday (3), 30 days → 5 weeks = 35 cells 226 + const count = monthGridCellCount(2026, 3); 227 + expect(count % 7).toBe(0); // always a multiple of 7 228 + expect(count).toBeGreaterThanOrEqual(28); 229 + expect(count).toBeLessThanOrEqual(42); 230 + }); 231 + 232 + it('returns 35 or 42 cells for February starting on Sunday', () => { 233 + // Feb 2026 starts on Sunday 234 + const count = monthGridCellCount(2026, 1); 235 + expect(count).toBe(28); // 28 days, starts Sunday → exactly 4 weeks 236 + }); 237 + }); 238 + 239 + describe('constants', () => { 240 + it('has 6 event colors', () => { 241 + expect(EVENT_COLORS).toHaveLength(6); 242 + EVENT_COLORS.forEach(c => expect(c).toMatch(/^#[0-9a-f]{6}$/)); 243 + }); 244 + 245 + it('has 7 days of week', () => { 246 + expect(DAYS_OF_WEEK).toHaveLength(7); 247 + expect(DAYS_OF_WEEK[0]).toBe('Sun'); 248 + expect(DAYS_OF_WEEK[6]).toBe('Sat'); 249 + }); 250 + 251 + it('has 12 months', () => { 252 + expect(MONTHS).toHaveLength(12); 253 + expect(MONTHS[0]).toBe('January'); 254 + expect(MONTHS[11]).toBe('December'); 255 + }); 256 + }); 257 + });