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): settings, hover preview, keyboard nav' (#348) from feat/cal-polish into main

scott fa7cef38 e76d76bb

+513 -35
+7
CHANGELOG.md
··· 5 5 The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 6 and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 7 8 + ## [0.29.0] — 2026-04-09 9 + 10 + ### Added 11 + - Add calendar settings panel with configurable week start day and 12/24h time format (#580) 12 + - Add event hover preview tooltip showing title, time, and description (#581) 13 + - Add keyboard event navigation: Tab to focus events, arrow keys to move, Enter to edit (#582) 14 + 8 15 ## [0.28.0] — 2026-04-09 9 16 10 17 ### Added
+1 -1
package.json
··· 1 1 { 2 2 "name": "tools", 3 - "version": "0.28.0", 3 + "version": "0.29.0", 4 4 "private": true, 5 5 "type": "module", 6 6 "main": "electron/main.js",
+72
src/calendar/helpers.ts
··· 254 254 return Math.ceil((((date.getTime() - yearStart.getTime()) / 86400000) + 1) / 7); 255 255 } 256 256 257 + // --------------------------------------------------------------------------- 258 + // Settings 259 + // --------------------------------------------------------------------------- 260 + 261 + export interface CalendarSettings { 262 + weekStartDay: 0 | 1; // 0 = Sunday, 1 = Monday 263 + use24Hour: boolean; 264 + } 265 + 266 + const SETTINGS_KEY = 'tools-cal-settings'; 267 + 268 + export function loadCalendarSettings(): CalendarSettings { 269 + try { 270 + const raw = localStorage.getItem(SETTINGS_KEY); 271 + if (raw) { 272 + const parsed = JSON.parse(raw); 273 + return { 274 + weekStartDay: parsed.weekStartDay === 1 ? 1 : 0, 275 + use24Hour: !!parsed.use24Hour, 276 + }; 277 + } 278 + } catch { /* ignore */ } 279 + return { weekStartDay: 0, use24Hour: false }; 280 + } 281 + 282 + export function saveCalendarSettings(settings: CalendarSettings): void { 283 + localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings)); 284 + } 285 + 286 + /** Format a 24h time string (HH:MM) according to 12/24h preference. */ 287 + export function formatTime(time: string, use24Hour: boolean): string { 288 + if (!time) return ''; 289 + const parts = time.split(':'); 290 + const hStr = parts[0] ?? '0'; 291 + const mStr = parts[1] ?? '00'; 292 + const h = parseInt(hStr, 10); 293 + if (use24Hour) { 294 + return `${String(h).padStart(2, '0')}:${mStr}`; 295 + } 296 + const ampm = h >= 12 ? 'pm' : 'am'; 297 + const h12 = h === 0 ? 12 : h > 12 ? h - 12 : h; 298 + return mStr === '00' ? `${h12}${ampm}` : `${h12}:${mStr}${ampm}`; 299 + } 300 + 301 + /** Return DAYS_OF_WEEK rotated so that `startDay` is first. */ 302 + export function getRotatedDays(startDay: 0 | 1): string[] { 303 + if (startDay === 0) return DAYS_OF_WEEK; 304 + return [...DAYS_OF_WEEK.slice(startDay), ...DAYS_OF_WEEK.slice(0, startDay)]; 305 + } 306 + 307 + /** Return single-letter day headers rotated by `startDay`. */ 308 + export function getRotatedDayLetters(startDay: 0 | 1): string[] { 309 + const letters = ['S', 'M', 'T', 'W', 'T', 'F', 'S']; 310 + if (startDay === 0) return letters; 311 + return [...letters.slice(startDay), ...letters.slice(0, startDay)]; 312 + } 313 + 314 + /** Get the start of the week containing `d`, respecting weekStartDay. */ 315 + export function getWeekStartForDay(d: Date, startDay: 0 | 1): Date { 316 + const result = new Date(d); 317 + const dayOfWeek = result.getDay(); 318 + const diff = (dayOfWeek - startDay + 7) % 7; 319 + result.setDate(result.getDate() - diff); 320 + return result; 321 + } 322 + 323 + /** Number of blank cells before day 1 in a month grid with a given start day. */ 324 + export function monthGridFirstDayOffset(year: number, month: number, startDay: 0 | 1): number { 325 + const firstDow = new Date(year, month, 1).getDay(); 326 + return (firstDow - startDay + 7) % 7; 327 + } 328 + 257 329 /** Filter events by search query (matches title and description, case-insensitive). */ 258 330 export function searchEvents(events: CalendarEvent[], query: string): CalendarEvent[] { 259 331 const q = query.trim().toLowerCase();
+31
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 + <button class="btn-icon" id="btn-cal-settings" title="Calendar settings"> 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="2.5"/><path d="M8 1v2M8 13v2M1 8h2M13 8h2M2.9 2.9l1.4 1.4M11.7 11.7l1.4 1.4M2.9 13.1l1.4-1.4M11.7 4.3l1.4-1.4"/></svg> 44 + </button> 42 45 <button class="btn-icon" id="btn-theme-toggle" title="Toggle theme"> 43 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"><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 47 </button> ··· 88 91 <div class="cal-shortcut"><kbd>N</kbd> New event</div> 89 92 <div class="cal-shortcut"><kbd>&larr;</kbd><kbd>&rarr;</kbd> Navigate</div> 90 93 <div class="cal-shortcut"><kbd>S</kbd> Sidebar</div> 94 + <div class="cal-shortcut"><kbd>Tab</kbd> Focus events</div> 95 + <div class="cal-shortcut"><kbd>&uarr;</kbd><kbd>&darr;</kbd> Move between events</div> 91 96 </div> 92 97 </aside> 93 98 ··· 103 108 </div> 104 109 </div> 105 110 </main> 111 + 112 + <!-- Settings popover --> 113 + <div class="cal-settings-popover" id="cal-settings-popover" style="display:none"> 114 + <div class="cal-settings-title">Settings</div> 115 + <label class="cal-settings-row"> 116 + <span>Week starts on</span> 117 + <select id="cal-setting-weekstart" class="cal-settings-select"> 118 + <option value="0">Sunday</option> 119 + <option value="1">Monday</option> 120 + </select> 121 + </label> 122 + <label class="cal-settings-row"> 123 + <span>Time format</span> 124 + <select id="cal-setting-timeformat" class="cal-settings-select"> 125 + <option value="12">12-hour (2:30pm)</option> 126 + <option value="24">24-hour (14:30)</option> 127 + </select> 128 + </label> 129 + </div> 130 + 131 + <!-- Event hover preview tooltip --> 132 + <div class="cal-event-preview" id="cal-event-preview" style="display:none"> 133 + <div class="cal-preview-title" id="cal-preview-title"></div> 134 + <div class="cal-preview-time" id="cal-preview-time"></div> 135 + <div class="cal-preview-desc" id="cal-preview-desc"></div> 136 + </div> 106 137 107 138 <!-- Event modal --> 108 139 <div class="modal-backdrop" id="event-modal-backdrop" style="display:none">
+186 -34
src/calendar/main.ts
··· 15 15 import { 16 16 type CalendarEvent, 17 17 type CalendarView, 18 + type CalendarSettings, 18 19 type Recurrence, 19 20 type RecurrenceType, 20 21 EVENT_COLORS, ··· 25 26 parseEventDate, 26 27 isSameDay, 27 28 isToday, 28 - getWeekStart, 29 - getWeekEnd, 30 29 daysInMonth, 31 30 timeToMinutes, 32 31 minutesToTime, 33 - formatTimeDisplay, 34 32 getDayOfWeekFull, 35 33 eventsOnDate as eventsOnDateHelper, 36 34 multiDayPosition, 37 35 expandRecurringEvents, 38 36 getISOWeekNumber, 39 37 searchEvents, 38 + loadCalendarSettings, 39 + saveCalendarSettings, 40 + formatTime, 41 + getRotatedDays, 42 + getRotatedDayLetters, 43 + getWeekStartForDay, 44 + monthGridFirstDayOffset, 40 45 } from './helpers.js'; 41 46 import { parseIcsFile } from './ics-parser.js'; 42 47 import { exportIcsFile } from './ics-export.js'; ··· 142 147 const searchClear = document.getElementById('cal-search-clear') as HTMLButtonElement; 143 148 let searchQuery = ''; 144 149 150 + // Settings 151 + let calSettings: CalendarSettings = loadCalendarSettings(); 152 + 153 + // Event preview tooltip refs 154 + const eventPreview = document.getElementById('cal-event-preview') as HTMLElement; 155 + const previewTitle = document.getElementById('cal-preview-title') as HTMLElement; 156 + const previewTime = document.getElementById('cal-preview-time') as HTMLElement; 157 + const previewDesc = document.getElementById('cal-preview-desc') as HTMLElement; 158 + 145 159 // --------------------------------------------------------------------------- 146 160 // Helpers 147 161 // --------------------------------------------------------------------------- ··· 152 166 return div.innerHTML; 153 167 } 154 168 169 + function hourLabel(h: number): string { 170 + if (calSettings.use24Hour) return `${String(h).padStart(2, '0')}:00`; 171 + return h === 0 ? '12 AM' : h < 12 ? `${h} AM` : h === 12 ? '12 PM' : `${h - 12} PM`; 172 + } 173 + 174 + function fmtTime(time: string): string { 175 + return formatTime(time, calSettings.use24Hour); 176 + } 177 + 155 178 /** Cached expanded events for the current render pass */ 156 179 let expandedEventsCache: { key: string; events: CalendarEvent[] } | null = null; 157 180 ··· 234 257 dateLabel.textContent = `${MONTHS[d.getMonth()]} ${d.getFullYear()}`; 235 258 break; 236 259 case 'week': { 237 - const ws = getWeekStart(d); 238 - const we = getWeekEnd(d); 260 + const ws = getWeekStartForDay(d, calSettings.weekStartDay); 261 + const we = new Date(ws); 262 + we.setDate(we.getDate() + 6); 239 263 if (ws.getMonth() === we.getMonth()) { 240 264 dateLabel.textContent = `${MONTHS_SHORT[ws.getMonth()]} ${ws.getDate()}\u2013${we.getDate()}, ${we.getFullYear()}`; 241 265 } else if (ws.getFullYear() === we.getFullYear()) { ··· 322 346 case 'month': { 323 347 // Month grid shows prev/next month overflow cells 324 348 const first = new Date(d.getFullYear(), d.getMonth(), 1); 325 - const start = new Date(first); 326 - start.setDate(start.getDate() - first.getDay()); // back to Sunday 349 + const start = getWeekStartForDay(first, calSettings.weekStartDay); 327 350 const last = new Date(d.getFullYear(), d.getMonth() + 1, 0); 328 351 const end = new Date(last); 329 - end.setDate(end.getDate() + (6 - last.getDay())); // forward to Saturday 352 + const endDayOffset = (calSettings.weekStartDay + 6 - last.getDay() + 7) % 7; 353 + end.setDate(end.getDate() + endDayOffset); 330 354 return [formatDate(start), formatDate(end)]; 331 355 } 332 356 case 'week': { 333 - const ws = getWeekStart(d); 334 - const we = getWeekEnd(d); 357 + const ws = getWeekStartForDay(d, calSettings.weekStartDay); 358 + const we = new Date(ws); 359 + we.setDate(we.getDate() + 6); 335 360 return [formatDate(ws), formatDate(we)]; 336 361 } 337 362 case 'day': ··· 426 451 const d = state.currentDate; 427 452 const year = d.getFullYear(); 428 453 const month = d.getMonth(); 429 - const firstDay = new Date(year, month, 1).getDay(); // 0=Sun 454 + const offset = monthGridFirstDayOffset(year, month, calSettings.weekStartDay); 430 455 const totalDays = daysInMonth(year, month); 431 456 const prevMonthDays = daysInMonth(year, month - 1); 432 457 ··· 434 459 435 460 // Day-of-week headers (with week number gutter) 436 461 html += '<div class="cal-week-num-header">Wk</div>'; 437 - for (const day of DAYS_OF_WEEK) { 462 + for (const day of getRotatedDays(calSettings.weekStartDay)) { 438 463 html += `<div class="cal-month-header-cell">${day}</div>`; 439 464 } 440 465 441 466 // Calculate how many rows we need 442 - const totalCells = Math.ceil((firstDay + totalDays) / 7) * 7; 467 + const totalCells = Math.ceil((offset + totalDays) / 7) * 7; 443 468 444 469 for (let i = 0; i < totalCells; i++) { 445 - const dayNum = i - firstDay + 1; 470 + const dayNum = i - offset + 1; 446 471 let cellDate: Date; 447 472 let otherMonth = false; 448 473 let displayNum: number; ··· 478 503 for (const evt of visible) { 479 504 const pos = multiDayPosition(evt, dateStr); 480 505 const multiClass = pos !== 'single' ? ` event-multi-${pos}` : ''; 481 - const timeStr = evt.allDay || pos !== 'single' ? '' : formatTimeDisplay(evt.startTime); 506 + const timeStr = evt.allDay || pos !== 'single' ? '' : fmtTime(evt.startTime); 482 507 const timeLabel = timeStr ? `<span class="cal-pill-time">${escapeHtml(timeStr)}</span> ` : ''; 483 508 const showTitle = pos === 'start' || pos === 'single'; 484 - html += `<div class="cal-event-pill${multiClass}" data-event-id="${escapeHtml(evt.id)}" draggable="true" style="--pill-color: ${evt.color}">`; 509 + html += `<div class="cal-event-pill${multiClass}" data-event-id="${escapeHtml(evt.id)}" draggable="true" tabindex="0" role="button" style="--pill-color: ${evt.color}">`; 485 510 html += showTitle ? `${timeLabel}${escapeHtml(evt.title || 'Untitled')}` : '&nbsp;'; 486 511 html += '</div>'; 487 512 } ··· 503 528 // --------------------------------------------------------------------------- 504 529 505 530 function renderWeekView(): void { 506 - const weekStart = getWeekStart(state.currentDate); 531 + const weekStart = getWeekStartForDay(state.currentDate, calSettings.weekStartDay); 507 532 508 533 // Collect days and their events 509 534 const days: { date: Date; dateStr: string; allDay: CalendarEvent[]; timed: CalendarEvent[] }[] = []; ··· 542 567 for (const day of days) { 543 568 html += `<div class="cal-allday-cell" data-date="${day.dateStr}">`; 544 569 for (const evt of day.allDay) { 545 - html += `<div class="cal-event-pill" data-event-id="${escapeHtml(evt.id)}" style="--pill-color: ${evt.color}">`; 570 + html += `<div class="cal-event-pill" data-event-id="${escapeHtml(evt.id)}" tabindex="0" role="button" style="--pill-color: ${evt.color}">`; 546 571 html += escapeHtml(evt.title || 'Untitled'); 547 572 html += '</div>'; 548 573 } ··· 555 580 html += '<div class="cal-week-body">'; 556 581 html += '<div class="cal-time-gutter">'; 557 582 for (let h = 0; h < 24; h++) { 558 - const label = h === 0 ? '12 AM' : h < 12 ? `${h} AM` : h === 12 ? '12 PM' : `${h - 12} PM`; 559 - html += `<div class="cal-hour-label" style="height:${HOUR_HEIGHT}px">${label}</div>`; 583 + html += `<div class="cal-hour-label" style="height:${HOUR_HEIGHT}px">${hourLabel(h)}</div>`; 560 584 } 561 585 html += '</div>'; 562 586 ··· 581 605 const height = Math.max(((endMin - startMin) / 60) * HOUR_HEIGHT, 20); 582 606 const leftPct = (col / totalCols) * 100; 583 607 const widthPct = (1 / totalCols) * 100; 584 - html += `<div class="cal-event-block" data-event-id="${escapeHtml(evt.id)}" style="top:${top}px;height:${height}px;left:calc(${leftPct}% + 1px);width:calc(${widthPct}% - 2px);--pill-color:${evt.color}">`; 585 - html += `<div class="cal-event-block-time">${formatTimeDisplay(evt.startTime)}\u2013${formatTimeDisplay(evt.endTime)}</div>`; 608 + html += `<div class="cal-event-block" data-event-id="${escapeHtml(evt.id)}" tabindex="0" role="button" style="top:${top}px;height:${height}px;left:calc(${leftPct}% + 1px);width:calc(${widthPct}% - 2px);--pill-color:${evt.color}">`; 609 + html += `<div class="cal-event-block-time">${fmtTime(evt.startTime)}\u2013${fmtTime(evt.endTime)}</div>`; 586 610 html += `<div class="cal-event-block-title">${escapeHtml(evt.title || 'Untitled')}</div>`; 587 611 html += '</div>'; 588 612 } ··· 631 655 html += '<div class="cal-time-gutter cal-day-allday-gutter">All day</div>'; 632 656 html += '<div class="cal-day-allday-cell">'; 633 657 for (const evt of allDay) { 634 - html += `<div class="cal-event-pill" data-event-id="${escapeHtml(evt.id)}" style="--pill-color: ${evt.color}">`; 658 + html += `<div class="cal-event-pill" data-event-id="${escapeHtml(evt.id)}" tabindex="0" role="button" style="--pill-color: ${evt.color}">`; 635 659 html += escapeHtml(evt.title || 'Untitled'); 636 660 html += '</div>'; 637 661 } ··· 643 667 html += '<div class="cal-day-body">'; 644 668 html += '<div class="cal-time-gutter">'; 645 669 for (let h = 0; h < 24; h++) { 646 - const label = h === 0 ? '12 AM' : h < 12 ? `${h} AM` : h === 12 ? '12 PM' : `${h - 12} PM`; 647 - html += `<div class="cal-hour-label" style="height:${HOUR_HEIGHT}px">${label}</div>`; 670 + html += `<div class="cal-hour-label" style="height:${HOUR_HEIGHT}px">${hourLabel(h)}</div>`; 648 671 } 649 672 html += '</div>'; 650 673 ··· 666 689 const height = Math.max(((endMin - startMin) / 60) * HOUR_HEIGHT, 20); 667 690 const leftPct = (col / totalCols) * 100; 668 691 const widthPct = (1 / totalCols) * 100; 669 - html += `<div class="cal-event-block" data-event-id="${escapeHtml(evt.id)}" style="top:${top}px;height:${height}px;left:calc(${leftPct}% + 1px);width:calc(${widthPct}% - 2px);--pill-color:${evt.color}">`; 670 - html += `<div class="cal-event-block-time">${formatTimeDisplay(evt.startTime)}\u2013${formatTimeDisplay(evt.endTime)}</div>`; 692 + html += `<div class="cal-event-block" data-event-id="${escapeHtml(evt.id)}" tabindex="0" role="button" style="top:${top}px;height:${height}px;left:calc(${leftPct}% + 1px);width:calc(${widthPct}% - 2px);--pill-color:${evt.color}">`; 693 + html += `<div class="cal-event-block-time">${fmtTime(evt.startTime)}\u2013${fmtTime(evt.endTime)}</div>`; 671 694 html += `<div class="cal-event-block-title">${escapeHtml(evt.title || 'Untitled')}</div>`; 672 695 html += '</div>'; 673 696 } ··· 754 777 for (const evt of events) { 755 778 const timeStr = evt.allDay 756 779 ? 'All day' 757 - : `${formatTimeDisplay(evt.startTime)}\u2013${formatTimeDisplay(evt.endTime)}`; 780 + : `${fmtTime(evt.startTime)}\u2013${fmtTime(evt.endTime)}`; 758 781 const desc = evt.description 759 782 ? `<div class="cal-agenda-desc">${escapeHtml(evt.description.slice(0, 120))}${evt.description.length > 120 ? '\u2026' : ''}</div>` 760 783 : ''; 761 784 762 - html += `<div class="cal-agenda-item" data-event-id="${escapeHtml(evt.id)}">`; 785 + html += `<div class="cal-agenda-item" data-event-id="${escapeHtml(evt.id)}" tabindex="0" role="button">`; 763 786 html += `<span class="cal-agenda-dot" style="background:${evt.color}"></span>`; 764 787 html += '<div class="cal-agenda-content">'; 765 788 html += `<div class="cal-agenda-title">${escapeHtml(evt.title || 'Untitled')}</div>`; ··· 1011 1034 } 1012 1035 1013 1036 function renderMiniCalendar(): void { 1014 - const firstDay = new Date(miniYear, miniMonth, 1).getDay(); 1037 + const offset = monthGridFirstDayOffset(miniYear, miniMonth, calSettings.weekStartDay); 1015 1038 const totalDays = daysInMonth(miniYear, miniMonth); 1016 1039 const prevDays = daysInMonth(miniYear, miniMonth - 1); 1017 - const totalCells = Math.ceil((firstDay + totalDays) / 7) * 7; 1040 + const totalCells = Math.ceil((offset + totalDays) / 7) * 7; 1018 1041 1019 1042 let html = '<div class="cal-mini-header">'; 1020 1043 html += `<button class="cal-mini-nav" id="mini-prev" aria-label="Previous month">&#9664;</button>`; ··· 1025 1048 html += '<div class="cal-mini-grid">'; 1026 1049 1027 1050 // Day-of-week headers (single letter) 1028 - const dayLetters = ['S', 'M', 'T', 'W', 'T', 'F', 'S']; 1029 - for (const letter of dayLetters) { 1051 + for (const letter of getRotatedDayLetters(calSettings.weekStartDay)) { 1030 1052 html += `<div class="cal-mini-dow">${letter}</div>`; 1031 1053 } 1032 1054 1033 1055 for (let i = 0; i < totalCells; i++) { 1034 - const dayNum = i - firstDay + 1; 1056 + const dayNum = i - offset + 1; 1035 1057 let cellDate: Date; 1036 1058 let otherMonth = false; 1037 1059 let displayNum: number; ··· 1210 1232 }); 1211 1233 1212 1234 // --------------------------------------------------------------------------- 1235 + // Settings panel 1236 + // --------------------------------------------------------------------------- 1237 + 1238 + const settingsPopover = document.getElementById('cal-settings-popover') as HTMLElement; 1239 + const settingsBtn = document.getElementById('btn-cal-settings') as HTMLButtonElement; 1240 + const weekStartSelect = document.getElementById('cal-setting-weekstart') as HTMLSelectElement; 1241 + const timeFormatSelect = document.getElementById('cal-setting-timeformat') as HTMLSelectElement; 1242 + 1243 + // Initialize selects from current settings 1244 + weekStartSelect.value = String(calSettings.weekStartDay); 1245 + timeFormatSelect.value = calSettings.use24Hour ? '24' : '12'; 1246 + 1247 + settingsBtn?.addEventListener('click', (e) => { 1248 + e.stopPropagation(); 1249 + const isOpen = settingsPopover.style.display !== 'none'; 1250 + settingsPopover.style.display = isOpen ? 'none' : ''; 1251 + if (!isOpen) { 1252 + // Position below the button 1253 + const rect = settingsBtn.getBoundingClientRect(); 1254 + settingsPopover.style.top = `${rect.bottom + 4}px`; 1255 + settingsPopover.style.right = `${window.innerWidth - rect.right}px`; 1256 + } 1257 + }); 1258 + 1259 + // Close on outside click 1260 + document.addEventListener('click', (e) => { 1261 + if (settingsPopover.style.display !== 'none' && 1262 + !settingsPopover.contains(e.target as Node) && 1263 + e.target !== settingsBtn) { 1264 + settingsPopover.style.display = 'none'; 1265 + } 1266 + }); 1267 + 1268 + weekStartSelect.addEventListener('change', () => { 1269 + calSettings.weekStartDay = parseInt(weekStartSelect.value, 10) as 0 | 1; 1270 + saveCalendarSettings(calSettings); 1271 + renderView(); 1272 + renderMiniCalendar(); 1273 + }); 1274 + 1275 + timeFormatSelect.addEventListener('change', () => { 1276 + calSettings.use24Hour = timeFormatSelect.value === '24'; 1277 + saveCalendarSettings(calSettings); 1278 + renderView(); 1279 + }); 1280 + 1281 + // --------------------------------------------------------------------------- 1282 + // Event hover preview tooltip 1283 + // --------------------------------------------------------------------------- 1284 + 1285 + let previewTimeout: ReturnType<typeof setTimeout> | null = null; 1286 + 1287 + calendarGrid.addEventListener('mouseover', (e) => { 1288 + const pill = (e.target as HTMLElement).closest('[data-event-id]') as HTMLElement | null; 1289 + if (!pill) return; 1290 + if (previewTimeout) clearTimeout(previewTimeout); 1291 + previewTimeout = setTimeout(() => { 1292 + const eventId = pill.dataset.eventId!; 1293 + const evt = state.events.find(ev => ev.id === eventId); 1294 + if (!evt) return; 1295 + 1296 + previewTitle.textContent = evt.title || 'Untitled'; 1297 + if (evt.allDay) { 1298 + previewTime.textContent = 'All day'; 1299 + } else if (evt.startTime) { 1300 + previewTime.textContent = `${fmtTime(evt.startTime)}\u2013${fmtTime(evt.endTime)}`; 1301 + } else { 1302 + previewTime.textContent = ''; 1303 + } 1304 + previewDesc.textContent = evt.description ? evt.description.slice(0, 150) : ''; 1305 + previewDesc.style.display = evt.description ? '' : 'none'; 1306 + 1307 + const rect = pill.getBoundingClientRect(); 1308 + eventPreview.style.display = ''; 1309 + eventPreview.style.top = `${rect.bottom + 4}px`; 1310 + eventPreview.style.left = `${Math.min(rect.left, window.innerWidth - 260)}px`; 1311 + }, 400); 1312 + }); 1313 + 1314 + calendarGrid.addEventListener('mouseout', (e) => { 1315 + const pill = (e.target as HTMLElement).closest('[data-event-id]'); 1316 + if (!pill) return; 1317 + if (previewTimeout) { clearTimeout(previewTimeout); previewTimeout = null; } 1318 + // Small delay so user can move to the preview itself 1319 + previewTimeout = setTimeout(() => { 1320 + if (!eventPreview.matches(':hover')) { 1321 + eventPreview.style.display = 'none'; 1322 + } 1323 + }, 200); 1324 + }); 1325 + 1326 + eventPreview.addEventListener('mouseleave', () => { 1327 + eventPreview.style.display = 'none'; 1328 + }); 1329 + 1330 + // Hide preview on scroll or view change 1331 + calendarGrid.addEventListener('scroll', () => { eventPreview.style.display = 'none'; }, { passive: true }); 1332 + 1333 + // --------------------------------------------------------------------------- 1334 + // Keyboard event navigation (Tab focuses pills, arrows move, Enter edits) 1335 + // --------------------------------------------------------------------------- 1336 + 1337 + calendarGrid.addEventListener('keydown', (e) => { 1338 + const target = e.target as HTMLElement; 1339 + if (!target.dataset.eventId) return; 1340 + 1341 + if (e.key === 'Enter' || e.key === ' ') { 1342 + e.preventDefault(); 1343 + const eventId = target.dataset.eventId!; 1344 + const evt = state.events.find(ev => ev.id === eventId); 1345 + if (evt) openModal(evt); 1346 + return; 1347 + } 1348 + 1349 + if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { 1350 + e.preventDefault(); 1351 + const items = Array.from(calendarGrid.querySelectorAll<HTMLElement>('[data-event-id][tabindex]')); 1352 + const idx = items.indexOf(target); 1353 + if (idx < 0) return; 1354 + const next = e.key === 'ArrowDown' ? idx + 1 : idx - 1; 1355 + if (next >= 0 && next < items.length) { 1356 + items[next]!.focus(); 1357 + } 1358 + } 1359 + }); 1360 + 1361 + // --------------------------------------------------------------------------- 1213 1362 // iCal import (shared logic for button and drag-and-drop) 1214 1363 // --------------------------------------------------------------------------- 1215 1364 ··· 1439 1588 1440 1589 // Don't intercept if Cmd/Ctrl is held (let command palette through) 1441 1590 if (e.metaKey || e.ctrlKey) return; 1591 + 1592 + // Let the grid keyboard handler manage events with focus 1593 + if ((e.target as HTMLElement).dataset.eventId) return; 1442 1594 1443 1595 switch (e.key) { 1444 1596 case 't':
+99
src/css/app.css
··· 10451 10451 } 10452 10452 10453 10453 10454 + /* ── Settings Popover ────────────────────────────────────────────────── */ 10455 + 10456 + .cal-settings-popover { 10457 + position: fixed; 10458 + z-index: 200; 10459 + background: var(--color-surface); 10460 + border: 1px solid var(--color-border); 10461 + border-radius: var(--radius, 6px); 10462 + padding: var(--space-sm) var(--space-md); 10463 + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12); 10464 + min-width: 200px; 10465 + } 10466 + 10467 + .cal-settings-title { 10468 + font-size: 0.7rem; 10469 + font-weight: 600; 10470 + color: var(--color-text-muted); 10471 + text-transform: uppercase; 10472 + letter-spacing: 0.05em; 10473 + margin-bottom: var(--space-sm); 10474 + } 10475 + 10476 + .cal-settings-row { 10477 + display: flex; 10478 + align-items: center; 10479 + justify-content: space-between; 10480 + gap: var(--space-md); 10481 + padding: var(--space-xs) 0; 10482 + font-size: 0.8rem; 10483 + color: var(--color-text); 10484 + cursor: default; 10485 + } 10486 + 10487 + .cal-settings-select { 10488 + font-size: 0.75rem; 10489 + padding: 2px 6px; 10490 + border: 1px solid var(--color-border); 10491 + border-radius: 4px; 10492 + background: var(--color-bg); 10493 + color: var(--color-text); 10494 + cursor: pointer; 10495 + } 10496 + 10497 + .cal-settings-select:focus { 10498 + outline: 2px solid var(--color-focus); 10499 + outline-offset: 1px; 10500 + } 10501 + 10502 + 10503 + /* ── Event Hover Preview ─────────────────────────────────────────────── */ 10504 + 10505 + .cal-event-preview { 10506 + position: fixed; 10507 + z-index: 210; 10508 + background: var(--color-surface); 10509 + border: 1px solid var(--color-border); 10510 + border-radius: var(--radius, 6px); 10511 + padding: var(--space-sm) var(--space-md); 10512 + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12); 10513 + max-width: 250px; 10514 + pointer-events: auto; 10515 + } 10516 + 10517 + .cal-preview-title { 10518 + font-weight: 600; 10519 + font-size: 0.8rem; 10520 + color: var(--color-text); 10521 + margin-bottom: 2px; 10522 + } 10523 + 10524 + .cal-preview-time { 10525 + font-size: 0.72rem; 10526 + color: var(--color-text-muted); 10527 + margin-bottom: 4px; 10528 + } 10529 + 10530 + .cal-preview-desc { 10531 + font-size: 0.7rem; 10532 + color: var(--color-text-faint); 10533 + line-height: 1.4; 10534 + white-space: pre-line; 10535 + overflow: hidden; 10536 + text-overflow: ellipsis; 10537 + display: -webkit-box; 10538 + -webkit-line-clamp: 3; 10539 + -webkit-box-orient: vertical; 10540 + } 10541 + 10542 + 10543 + /* ── Keyboard-focused event pill ─────────────────────────────────────── */ 10544 + 10545 + .cal-event-pill:focus-visible, 10546 + .cal-event-block:focus-visible { 10547 + outline: 2px solid var(--color-teal); 10548 + outline-offset: 1px; 10549 + z-index: 5; 10550 + } 10551 + 10552 + 10454 10553 /* ── Print ───────────────────────────────────────────────────────────── */ 10455 10554 10456 10555 @media print {
+117
tests/calendar-helpers.test.ts
··· 15 15 multiDayPosition, 16 16 getISOWeekNumber, 17 17 searchEvents, 18 + formatTime, 19 + getRotatedDays, 20 + getRotatedDayLetters, 21 + getWeekStartForDay, 22 + monthGridFirstDayOffset, 18 23 EVENT_COLORS, 19 24 DAYS_OF_WEEK, 20 25 MONTHS, ··· 616 621 // "review" appears in title "Project Review" and description has "review" 617 622 const result = searchEvents(events, 'annual'); 618 623 expect(result).toHaveLength(1); 624 + }); 625 + }); 626 + 627 + // ------------------------------------------------------------------------- 628 + // Settings-related helpers 629 + // ------------------------------------------------------------------------- 630 + 631 + describe('formatTime', () => { 632 + it('returns 24h format when use24Hour is true', () => { 633 + expect(formatTime('14:30', true)).toBe('14:30'); 634 + expect(formatTime('09:00', true)).toBe('09:00'); 635 + expect(formatTime('00:00', true)).toBe('00:00'); 636 + }); 637 + 638 + it('returns 12h format when use24Hour is false', () => { 639 + expect(formatTime('14:30', false)).toBe('2:30pm'); 640 + expect(formatTime('09:00', false)).toBe('9am'); 641 + expect(formatTime('00:00', false)).toBe('12am'); 642 + expect(formatTime('12:00', false)).toBe('12pm'); 643 + }); 644 + 645 + it('returns empty string for empty input', () => { 646 + expect(formatTime('', true)).toBe(''); 647 + expect(formatTime('', false)).toBe(''); 648 + }); 649 + }); 650 + 651 + describe('getRotatedDays', () => { 652 + it('returns Sun-first order for weekStartDay=0', () => { 653 + expect(getRotatedDays(0)).toEqual(['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']); 654 + }); 655 + 656 + it('returns Mon-first order for weekStartDay=1', () => { 657 + expect(getRotatedDays(1)).toEqual(['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']); 658 + }); 659 + 660 + it('returns 7 elements', () => { 661 + expect(getRotatedDays(0)).toHaveLength(7); 662 + expect(getRotatedDays(1)).toHaveLength(7); 663 + }); 664 + }); 665 + 666 + describe('getRotatedDayLetters', () => { 667 + it('returns Sun-first letters for weekStartDay=0', () => { 668 + expect(getRotatedDayLetters(0)).toEqual(['S', 'M', 'T', 'W', 'T', 'F', 'S']); 669 + }); 670 + 671 + it('returns Mon-first letters for weekStartDay=1', () => { 672 + expect(getRotatedDayLetters(1)).toEqual(['M', 'T', 'W', 'T', 'F', 'S', 'S']); 673 + }); 674 + }); 675 + 676 + describe('getWeekStartForDay', () => { 677 + it('returns Sunday for weekStartDay=0', () => { 678 + const wed = new Date(2026, 3, 8); // Wednesday 679 + const start = getWeekStartForDay(wed, 0); 680 + expect(start.getDay()).toBe(0); 681 + expect(start.getDate()).toBe(5); 682 + }); 683 + 684 + it('returns Monday for weekStartDay=1', () => { 685 + const wed = new Date(2026, 3, 8); 686 + const start = getWeekStartForDay(wed, 1); 687 + expect(start.getDay()).toBe(1); 688 + expect(start.getDate()).toBe(6); 689 + }); 690 + 691 + it('returns same day if already on start day', () => { 692 + expect(getWeekStartForDay(new Date(2026, 3, 5), 0).getDate()).toBe(5); // Sunday 693 + expect(getWeekStartForDay(new Date(2026, 3, 6), 1).getDate()).toBe(6); // Monday 694 + }); 695 + 696 + it('handles Sunday with Monday start (goes back to prev Monday)', () => { 697 + const sun = new Date(2026, 3, 5); 698 + const start = getWeekStartForDay(sun, 1); 699 + expect(start.getDay()).toBe(1); 700 + expect(start.getMonth()).toBe(2); // March 701 + expect(start.getDate()).toBe(30); 702 + }); 703 + 704 + it('handles month boundary', () => { 705 + const fri = new Date(2026, 4, 1); // May 1 = Friday 706 + expect(getWeekStartForDay(fri, 0).getDate()).toBe(26); // April 26 707 + expect(getWeekStartForDay(fri, 1).getDate()).toBe(27); // April 27 708 + }); 709 + }); 710 + 711 + describe('monthGridFirstDayOffset', () => { 712 + it('returns correct offset for Sunday-start week', () => { 713 + // April 2026 starts on Wednesday (day 3), offset = 3 714 + expect(monthGridFirstDayOffset(2026, 3, 0)).toBe(3); 715 + }); 716 + 717 + it('returns correct offset for Monday-start week', () => { 718 + // April 2026 starts on Wednesday (day 3), offset = (3-1+7)%7 = 2 719 + expect(monthGridFirstDayOffset(2026, 3, 1)).toBe(2); 720 + }); 721 + 722 + it('returns 0 when month starts on configured start day', () => { 723 + // Feb 2026 starts on Sunday 724 + expect(monthGridFirstDayOffset(2026, 1, 0)).toBe(0); 725 + }); 726 + 727 + it('returns 6 when month starts day before configured start', () => { 728 + // Feb 2026 starts on Sunday, Monday-start: (0-1+7)%7 = 6 729 + expect(monthGridFirstDayOffset(2026, 1, 1)).toBe(6); 730 + }); 731 + 732 + it('handles January', () => { 733 + // Jan 1 2026 is Thursday (day 4) 734 + expect(monthGridFirstDayOffset(2026, 0, 0)).toBe(4); 735 + expect(monthGridFirstDayOffset(2026, 0, 1)).toBe(3); 619 736 }); 620 737 }); 621 738 });