Full document, spreadsheet, slideshow, and diagram tooling
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(calendar): add event search and ISO week numbers (#575 #576)

- Search input in toolbar filters events by title/description across all views
- Cmd/Ctrl+F focuses search, clear button resets filter
- ISO 8601 week numbers displayed in month view left gutter
- Week numbers hidden on mobile (<768px) for space
- Tests for getISOWeekNumber and searchEvents helpers

+253 -10
+6
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.28.0] — 2026-04-09 9 + 10 + ### Added 11 + - Add event search/filter to calendar toolbar with Cmd/Ctrl+F shortcut (#575) 12 + - Add ISO week numbers to month view (#576) 13 + 8 14 ## [0.27.0] — 2026-04-09 9 15 10 16 ### Added
+1 -1
package.json
··· 1 1 { 2 2 "name": "tools", 3 - "version": "0.27.0", 3 + "version": "0.28.0", 4 4 "private": true, 5 5 "type": "module", 6 6 "main": "electron/main.js",
+20
src/calendar/helpers.ts
··· 243 243 { type: 'monthly', label: 'Monthly' }, 244 244 { type: 'yearly', label: 'Yearly' }, 245 245 ]; 246 + 247 + /** ISO 8601 week number (Monday-based, week 1 contains Jan 4). */ 248 + export function getISOWeekNumber(d: Date): number { 249 + const date = new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate())); 250 + // Set to nearest Thursday: current date + 4 - current day number (Mon=1, Sun=7) 251 + const dayNum = date.getUTCDay() || 7; 252 + date.setUTCDate(date.getUTCDate() + 4 - dayNum); 253 + const yearStart = new Date(Date.UTC(date.getUTCFullYear(), 0, 1)); 254 + return Math.ceil((((date.getTime() - yearStart.getTime()) / 86400000) + 1) / 7); 255 + } 256 + 257 + /** Filter events by search query (matches title and description, case-insensitive). */ 258 + export function searchEvents(events: CalendarEvent[], query: string): CalendarEvent[] { 259 + const q = query.trim().toLowerCase(); 260 + if (!q) return events; 261 + return events.filter(e => 262 + e.title.toLowerCase().includes(q) || 263 + e.description.toLowerCase().includes(q) 264 + ); 265 + }
+4
src/calendar/index.html
··· 58 58 <button class="cal-nav-btn" id="btn-next" title="Next period (&rarr;)">&#9654;</button> 59 59 <span class="cal-date-label" id="current-label"></span> 60 60 <span class="topbar-spacer"></span> 61 + <div class="cal-search-wrapper" id="cal-search-wrapper"> 62 + <input type="text" id="cal-search-input" class="cal-search-input" placeholder="Search events..." aria-label="Search events"> 63 + <button class="cal-search-clear" id="cal-search-clear" title="Clear search" style="display:none">&times;</button> 64 + </div> 61 65 <button class="cal-nav-btn" id="btn-import" title="Import .ics calendar file">&#8679; Import</button> 62 66 <input type="file" id="ics-import-input" accept=".ics,.ical,.ifb,.icalendar" style="display:none"> 63 67 <button class="cal-nav-btn" id="btn-export" title="Export calendar as .ics file">&#8681; Export</button>
+47 -6
src/calendar/main.ts
··· 35 35 eventsOnDate as eventsOnDateHelper, 36 36 multiDayPosition, 37 37 expandRecurringEvents, 38 + getISOWeekNumber, 39 + searchEvents, 38 40 } from './helpers.js'; 39 41 import { parseIcsFile } from './ics-parser.js'; 40 42 import { exportIcsFile } from './ics-export.js'; ··· 135 137 const modalRecurrenceUntil = document.getElementById('event-recurrence-until') as HTMLInputElement; 136 138 const recurrenceUntilField = document.getElementById('recurrence-until-field') as HTMLElement; 137 139 140 + // Search refs 141 + const searchInput = document.getElementById('cal-search-input') as HTMLInputElement; 142 + const searchClear = document.getElementById('cal-search-clear') as HTMLButtonElement; 143 + let searchQuery = ''; 144 + 138 145 // --------------------------------------------------------------------------- 139 146 // Helpers 140 147 // --------------------------------------------------------------------------- ··· 158 165 159 166 function eventsOnDate(dateStr: string): CalendarEvent[] { 160 167 const events = expandedEventsCache?.events ?? state.events; 161 - return eventsOnDateHelper(events, dateStr); 168 + const filtered = searchQuery ? searchEvents(events, searchQuery) : events; 169 + return eventsOnDateHelper(filtered, dateStr); 162 170 } 163 171 164 172 // --------------------------------------------------------------------------- ··· 422 430 const totalDays = daysInMonth(year, month); 423 431 const prevMonthDays = daysInMonth(year, month - 1); 424 432 425 - let html = '<div class="cal-month-grid">'; 433 + let html = '<div class="cal-month-grid cal-month-grid-weeknums">'; 426 434 427 - // Day-of-week headers 435 + // Day-of-week headers (with week number gutter) 436 + html += '<div class="cal-week-num-header">Wk</div>'; 428 437 for (const day of DAYS_OF_WEEK) { 429 438 html += `<div class="cal-month-header-cell">${day}</div>`; 430 439 } ··· 439 448 let displayNum: number; 440 449 441 450 if (dayNum < 1) { 442 - // Previous month 443 451 displayNum = prevMonthDays + dayNum; 444 452 cellDate = new Date(year, month - 1, displayNum); 445 453 otherMonth = true; 446 454 } else if (dayNum > totalDays) { 447 - // Next month 448 455 displayNum = dayNum - totalDays; 449 456 cellDate = new Date(year, month + 1, displayNum); 450 457 otherMonth = true; 451 458 } else { 452 459 displayNum = dayNum; 453 460 cellDate = new Date(year, month, dayNum); 461 + } 462 + 463 + // Week number cell at the start of each row (every 7 cells) 464 + if (i % 7 === 0) { 465 + const weekNum = getISOWeekNumber(cellDate); 466 + html += `<div class="cal-week-num" title="Week ${weekNum}">${weekNum}</div>`; 454 467 } 455 468 456 469 const dateStr = formatDate(cellDate); ··· 694 707 end.setDate(end.getDate() + AGENDA_DAYS); 695 708 696 709 // Collect events in the range (with recurring expanded), grouped by date 697 - const expanded = expandedEventsCache?.events ?? state.events; 710 + const rawExpanded = expandedEventsCache?.events ?? state.events; 711 + const expanded = searchQuery ? searchEvents(rawExpanded, searchQuery) : rawExpanded; 698 712 const dateGroups: Map<string, CalendarEvent[]> = new Map(); 699 713 for (const evt of expanded) { 700 714 const evtDate = parseEventDate(evt.date); ··· 1167 1181 else navigatePrev(); 1168 1182 } 1169 1183 }, { passive: true }); 1184 + 1185 + // --------------------------------------------------------------------------- 1186 + // Event search 1187 + // --------------------------------------------------------------------------- 1188 + 1189 + searchInput?.addEventListener('input', () => { 1190 + searchQuery = searchInput.value; 1191 + searchClear.style.display = searchQuery ? '' : 'none'; 1192 + renderView(); 1193 + }); 1194 + 1195 + searchClear?.addEventListener('click', () => { 1196 + searchQuery = ''; 1197 + searchInput.value = ''; 1198 + searchClear.style.display = 'none'; 1199 + renderView(); 1200 + searchInput.focus(); 1201 + }); 1202 + 1203 + // Cmd/Ctrl+F focuses the search input 1204 + document.addEventListener('keydown', (e) => { 1205 + if ((e.metaKey || e.ctrlKey) && e.key === 'f') { 1206 + e.preventDefault(); 1207 + searchInput?.focus(); 1208 + searchInput?.select(); 1209 + } 1210 + }); 1170 1211 1171 1212 // --------------------------------------------------------------------------- 1172 1213 // iCal import (shared logic for button and drag-and-drop)
+102 -3
src/css/app.css
··· 9565 9565 outline-offset: 1px; 9566 9566 } 9567 9567 9568 + /* Search input */ 9569 + .cal-search-wrapper { 9570 + position: relative; 9571 + display: flex; 9572 + align-items: center; 9573 + } 9574 + 9575 + .cal-search-input { 9576 + width: 160px; 9577 + padding: 0.3rem 1.6rem 0.3rem 0.5rem; 9578 + border: 1px solid var(--color-border); 9579 + border-radius: var(--radius-sm); 9580 + background: transparent; 9581 + color: var(--color-text); 9582 + font-family: var(--font-body); 9583 + font-size: 0.8rem; 9584 + line-height: 1.3; 9585 + min-height: 28px; 9586 + transition: border-color var(--transition-fast), width var(--transition-fast); 9587 + } 9588 + 9589 + .cal-search-input::placeholder { 9590 + color: var(--color-text-muted); 9591 + opacity: 0.7; 9592 + } 9593 + 9594 + .cal-search-input:focus { 9595 + outline: none; 9596 + border-color: var(--color-accent); 9597 + width: 220px; 9598 + } 9599 + 9600 + .cal-search-clear { 9601 + position: absolute; 9602 + right: 4px; 9603 + top: 50%; 9604 + transform: translateY(-50%); 9605 + border: none; 9606 + background: none; 9607 + color: var(--color-text-muted); 9608 + font-size: 1rem; 9609 + cursor: pointer; 9610 + padding: 0 4px; 9611 + line-height: 1; 9612 + } 9613 + 9614 + .cal-search-clear:hover { 9615 + color: var(--color-text); 9616 + } 9617 + 9568 9618 .cal-date-label { 9569 9619 font-family: var(--font-display); 9570 9620 font-size: 1.2rem; ··· 9632 9682 touch-action: pan-y; /* allow vertical touch gestures; we capture swipe at touchend */ 9633 9683 } 9634 9684 9685 + /* Week number variant: 8 columns (wk + 7 days) */ 9686 + .cal-month-grid-weeknums { 9687 + grid-template-columns: 28px repeat(7, 1fr); 9688 + } 9689 + 9690 + .cal-week-num-header { 9691 + position: sticky; 9692 + top: 0; 9693 + z-index: var(--z-component); 9694 + padding: var(--space-xs) 2px; 9695 + font-family: var(--font-display); 9696 + font-size: 0.6rem; 9697 + font-weight: 600; 9698 + text-transform: uppercase; 9699 + letter-spacing: 0.04em; 9700 + color: var(--color-text-muted); 9701 + background: var(--color-surface); 9702 + border-bottom: 1px solid var(--color-border-strong); 9703 + text-align: center; 9704 + user-select: none; 9705 + } 9706 + 9707 + .cal-week-num { 9708 + display: flex; 9709 + align-items: center; 9710 + justify-content: center; 9711 + font-size: 0.62rem; 9712 + font-weight: 500; 9713 + color: var(--color-text-muted); 9714 + border-bottom: 1px solid var(--color-border); 9715 + user-select: none; 9716 + opacity: 0.7; 9717 + } 9718 + 9635 9719 .cal-month-header { 9636 9720 display: contents; 9637 9721 } ··· 9663 9747 position: relative; 9664 9748 } 9665 9749 9666 - .cal-day-cell:nth-child(7n) { 9667 - border-right: none; 9668 - } 9750 + /* Grid overflow clips the last column's right border naturally */ 9669 9751 9670 9752 .cal-day-cell:hover { 9671 9753 background: var(--color-hover); ··· 10460 10542 .event-modal-row { 10461 10543 flex-direction: column; 10462 10544 gap: 0; 10545 + } 10546 + 10547 + /* Hide week numbers on tablet */ 10548 + .cal-week-num, 10549 + .cal-week-num-header { 10550 + display: none; 10551 + } 10552 + .cal-month-grid-weeknums { 10553 + grid-template-columns: repeat(7, 1fr); 10554 + } 10555 + 10556 + /* Shrink search input */ 10557 + .cal-search-input { 10558 + width: 120px; 10559 + } 10560 + .cal-search-input:focus { 10561 + width: 160px; 10463 10562 } 10464 10563 10465 10564 /* Agenda — less padding */
+73
tests/calendar-helpers.test.ts
··· 13 13 eventsOnDate, 14 14 monthGridCellCount, 15 15 multiDayPosition, 16 + getISOWeekNumber, 17 + searchEvents, 16 18 EVENT_COLORS, 17 19 DAYS_OF_WEEK, 18 20 MONTHS, ··· 543 545 it('returns end for last day of multi-day event', () => { 544 546 const evt = makeEvent({ date: '2026-04-08', endDate: '2026-04-10' }); 545 547 expect(multiDayPosition(evt, '2026-04-10')).toBe('end'); 548 + }); 549 + }); 550 + 551 + describe('getISOWeekNumber', () => { 552 + it('returns week 1 for Jan 1 2026 (Thursday)', () => { 553 + expect(getISOWeekNumber(new Date(2026, 0, 1))).toBe(1); 554 + }); 555 + 556 + it('returns week 1 for Jan 4 (always in week 1)', () => { 557 + expect(getISOWeekNumber(new Date(2026, 0, 4))).toBe(1); 558 + }); 559 + 560 + it('returns week 2 for Jan 5 2026 (Monday)', () => { 561 + expect(getISOWeekNumber(new Date(2026, 0, 5))).toBe(2); 562 + }); 563 + 564 + it('returns week 53 for Dec 31 2026 (belongs to week 53 of 2026)', () => { 565 + // 2026 has 53 weeks because Jan 1 is Thursday 566 + expect(getISOWeekNumber(new Date(2026, 11, 31))).toBe(53); 567 + }); 568 + 569 + it('returns correct week for mid-year date', () => { 570 + // April 9, 2026 is a Thursday — ISO week 15 571 + expect(getISOWeekNumber(new Date(2026, 3, 9))).toBe(15); 572 + }); 573 + 574 + it('handles year boundary — Dec 29 2025 may be week 1 of 2026', () => { 575 + // Dec 29, 2025 is Monday — Jan 1 2026 is Thursday — so Dec 29 is in week 1 of 2026 576 + expect(getISOWeekNumber(new Date(2025, 11, 29))).toBe(1); 577 + }); 578 + }); 579 + 580 + describe('searchEvents', () => { 581 + const events = [ 582 + makeEvent({ title: 'Team Standup', description: 'Daily sync meeting' }), 583 + makeEvent({ title: 'Lunch with Bob', description: '' }), 584 + makeEvent({ title: 'Project Review', description: 'Quarterly review of progress' }), 585 + makeEvent({ title: 'Dentist', description: 'Annual checkup' }), 586 + ]; 587 + 588 + it('returns all events for empty query', () => { 589 + expect(searchEvents(events, '')).toEqual(events); 590 + expect(searchEvents(events, ' ')).toEqual(events); 591 + }); 592 + 593 + it('matches title case-insensitively', () => { 594 + const result = searchEvents(events, 'lunch'); 595 + expect(result).toHaveLength(1); 596 + expect(result[0]!.title).toBe('Lunch with Bob'); 597 + }); 598 + 599 + it('matches description', () => { 600 + const result = searchEvents(events, 'quarterly'); 601 + expect(result).toHaveLength(1); 602 + expect(result[0]!.title).toBe('Project Review'); 603 + }); 604 + 605 + it('matches partial strings', () => { 606 + const result = searchEvents(events, 'stand'); 607 + expect(result).toHaveLength(1); 608 + expect(result[0]!.title).toBe('Team Standup'); 609 + }); 610 + 611 + it('returns empty array when nothing matches', () => { 612 + expect(searchEvents(events, 'xyz')).toHaveLength(0); 613 + }); 614 + 615 + it('matches multiple events', () => { 616 + // "review" appears in title "Project Review" and description has "review" 617 + const result = searchEvents(events, 'annual'); 618 + expect(result).toHaveLength(1); 546 619 }); 547 620 }); 548 621 });