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): add multi-day event support' (#322) from feat/calendar-multi-day into main

scott 1dc9fd70 81607010

+111 -7
+19 -4
src/calendar/helpers.ts
··· 5 5 export interface CalendarEvent { 6 6 id: string; 7 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 8 + date: string; // YYYY-MM-DD 9 + endDate?: string; // YYYY-MM-DD (for multi-day events; omitted for single-day) 10 + startTime: string; // HH:MM (24h) or '' for all-day 11 + endTime: string; // HH:MM (24h) or '' for all-day 11 12 allDay: boolean; 12 13 color: string; 13 14 description: string; ··· 103 104 104 105 export function eventsOnDate(events: CalendarEvent[], dateStr: string): CalendarEvent[] { 105 106 return events 106 - .filter(e => e.date === dateStr) 107 + .filter(e => { 108 + if (e.date === dateStr) return true; 109 + if (e.endDate && e.endDate > e.date) { 110 + return dateStr >= e.date && dateStr <= e.endDate; 111 + } 112 + return false; 113 + }) 107 114 .sort((a, b) => { 108 115 if (a.allDay && !b.allDay) return -1; 109 116 if (!a.allDay && b.allDay) return 1; 110 117 return timeToMinutes(a.startTime) - timeToMinutes(b.startTime); 111 118 }); 119 + } 120 + 121 + /** Get the position of a date within a multi-day event span (for rendering) */ 122 + export function multiDayPosition(event: CalendarEvent, dateStr: string): 'start' | 'middle' | 'end' | 'single' { 123 + if (!event.endDate || event.endDate === event.date) return 'single'; 124 + if (dateStr === event.date) return 'start'; 125 + if (dateStr === event.endDate) return 'end'; 126 + return 'middle'; 112 127 } 113 128 114 129 /** Build the month grid cell count (variable rows based on month layout). */
+5
src/calendar/index.html
··· 107 107 <input type="date" id="event-date" class="event-modal-input"> 108 108 </div> 109 109 110 + <div class="event-modal-field" id="end-date-field"> 111 + <label for="event-end-date">End Date</label> 112 + <input type="date" id="event-end-date" class="event-modal-input"> 113 + </div> 114 + 110 115 <div class="event-modal-row"> 111 116 <div class="event-modal-field"> 112 117 <label for="event-start-time">Start</label>
+11 -3
src/calendar/main.ts
··· 31 31 formatTimeDisplay, 32 32 getDayOfWeekFull, 33 33 eventsOnDate as eventsOnDateHelper, 34 + multiDayPosition, 34 35 } from './helpers.js'; 35 36 import { parseIcsFile } from './ics-parser.js'; 36 37 import { exportIcsFile } from './ics-export.js'; ··· 114 115 // Modal form fields 115 116 const modalTitle = document.getElementById('event-title') as HTMLInputElement; 116 117 const modalDate = document.getElementById('event-date') as HTMLInputElement; 118 + const modalEndDate = document.getElementById('event-end-date') as HTMLInputElement; 117 119 const modalStartTime = document.getElementById('event-start-time') as HTMLInputElement; 118 120 const modalEndTime = document.getElementById('event-end-time') as HTMLInputElement; 119 121 const modalAllDay = document.getElementById('event-all-day') as HTMLInputElement; ··· 408 410 409 411 const visible = dayEvents.slice(0, MAX_VISIBLE_PILLS); 410 412 for (const evt of visible) { 411 - const timeStr = evt.allDay ? '' : formatTimeDisplay(evt.startTime); 413 + const pos = multiDayPosition(evt, dateStr); 414 + const multiClass = pos !== 'single' ? ` event-multi-${pos}` : ''; 415 + const timeStr = evt.allDay || pos !== 'single' ? '' : formatTimeDisplay(evt.startTime); 412 416 const timeLabel = timeStr ? `<span class="cal-pill-time">${escapeHtml(timeStr)}</span> ` : ''; 413 - html += `<div class="cal-event-pill" data-event-id="${escapeHtml(evt.id)}" style="--pill-color: ${evt.color}">`; 414 - html += `${timeLabel}${escapeHtml(evt.title || 'Untitled')}`; 417 + const showTitle = pos === 'start' || pos === 'single'; 418 + html += `<div class="cal-event-pill${multiClass}" data-event-id="${escapeHtml(evt.id)}" style="--pill-color: ${evt.color}">`; 419 + html += showTitle ? `${timeLabel}${escapeHtml(evt.title || 'Untitled')}` : '&nbsp;'; 415 420 html += '</div>'; 416 421 } 417 422 ··· 728 733 729 734 modalTitle.value = evt.title ?? ''; 730 735 modalDate.value = evt.date ?? formatDate(state.currentDate); 736 + modalEndDate.value = (evt as CalendarEvent).endDate ?? ''; 731 737 modalStartTime.value = evt.startTime ?? '09:00'; 732 738 modalEndTime.value = evt.endTime ?? '10:00'; 733 739 modalAllDay.checked = evt.allDay ?? false; ··· 765 771 766 772 function saveEvent(): void { 767 773 const now = Date.now(); 774 + const endDateVal = modalEndDate.value; 768 775 const event: CalendarEvent = { 769 776 id: editingEventId ?? crypto.randomUUID(), 770 777 title: modalTitle.value.trim() || 'Untitled', 771 778 date: modalDate.value, 779 + ...(endDateVal && endDateVal > modalDate.value ? { endDate: endDateVal } : {}), 772 780 startTime: modalAllDay.checked ? '' : modalStartTime.value, 773 781 endTime: modalAllDay.checked ? '' : modalEndTime.value, 774 782 allDay: modalAllDay.checked,
+15
src/css/app.css
··· 9726 9726 color: oklch(0.97 0.005 75); 9727 9727 } 9728 9728 9729 + /* Multi-day event rendering */ 9730 + .cal-event-pill.event-multi-start { 9731 + border-radius: var(--radius-sm) 0 0 var(--radius-sm); 9732 + margin-right: 0; 9733 + } 9734 + .cal-event-pill.event-multi-middle { 9735 + border-radius: 0; 9736 + margin-left: 0; 9737 + margin-right: 0; 9738 + } 9739 + .cal-event-pill.event-multi-end { 9740 + border-radius: 0 var(--radius-sm) var(--radius-sm) 0; 9741 + margin-left: 0; 9742 + } 9743 + 9729 9744 .cal-more-link { 9730 9745 display: block; 9731 9746 padding: 1px 5px;
+61
tests/calendar-helpers.test.ts
··· 12 12 getDayOfWeekFull, 13 13 eventsOnDate, 14 14 monthGridCellCount, 15 + multiDayPosition, 15 16 EVENT_COLORS, 16 17 DAYS_OF_WEEK, 17 18 MONTHS, ··· 482 483 expect(MONTHS).toHaveLength(12); 483 484 expect(MONTHS[0]).toBe('January'); 484 485 expect(MONTHS[11]).toBe('December'); 486 + }); 487 + }); 488 + 489 + describe('eventsOnDate — multi-day events', () => { 490 + it('includes event on start date', () => { 491 + const events = [makeEvent({ date: '2026-04-08', endDate: '2026-04-10' })]; 492 + expect(eventsOnDate(events, '2026-04-08')).toHaveLength(1); 493 + }); 494 + 495 + it('includes event on middle date', () => { 496 + const events = [makeEvent({ date: '2026-04-08', endDate: '2026-04-10' })]; 497 + expect(eventsOnDate(events, '2026-04-09')).toHaveLength(1); 498 + }); 499 + 500 + it('includes event on end date', () => { 501 + const events = [makeEvent({ date: '2026-04-08', endDate: '2026-04-10' })]; 502 + expect(eventsOnDate(events, '2026-04-10')).toHaveLength(1); 503 + }); 504 + 505 + it('excludes event after end date', () => { 506 + const events = [makeEvent({ date: '2026-04-08', endDate: '2026-04-10' })]; 507 + expect(eventsOnDate(events, '2026-04-11')).toHaveLength(0); 508 + }); 509 + 510 + it('excludes event before start date', () => { 511 + const events = [makeEvent({ date: '2026-04-08', endDate: '2026-04-10' })]; 512 + expect(eventsOnDate(events, '2026-04-07')).toHaveLength(0); 513 + }); 514 + 515 + it('treats missing endDate as single-day', () => { 516 + const events = [makeEvent({ date: '2026-04-08' })]; 517 + expect(eventsOnDate(events, '2026-04-08')).toHaveLength(1); 518 + expect(eventsOnDate(events, '2026-04-09')).toHaveLength(0); 519 + }); 520 + }); 521 + 522 + describe('multiDayPosition', () => { 523 + it('returns single for event without endDate', () => { 524 + const evt = makeEvent({ date: '2026-04-08' }); 525 + expect(multiDayPosition(evt, '2026-04-08')).toBe('single'); 526 + }); 527 + 528 + it('returns single for event with endDate equal to date', () => { 529 + const evt = makeEvent({ date: '2026-04-08', endDate: '2026-04-08' }); 530 + expect(multiDayPosition(evt, '2026-04-08')).toBe('single'); 531 + }); 532 + 533 + it('returns start for first day of multi-day event', () => { 534 + const evt = makeEvent({ date: '2026-04-08', endDate: '2026-04-10' }); 535 + expect(multiDayPosition(evt, '2026-04-08')).toBe('start'); 536 + }); 537 + 538 + it('returns middle for middle day of multi-day event', () => { 539 + const evt = makeEvent({ date: '2026-04-08', endDate: '2026-04-10' }); 540 + expect(multiDayPosition(evt, '2026-04-09')).toBe('middle'); 541 + }); 542 + 543 + it('returns end for last day of multi-day event', () => { 544 + const evt = makeEvent({ date: '2026-04-08', endDate: '2026-04-10' }); 545 + expect(multiDayPosition(evt, '2026-04-10')).toBe('end'); 485 546 }); 486 547 }); 487 548 });