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 recurring events (#572)' (#343) from feat/recurring-events into main

scott 3107b8b3 11740b4e

+428 -3
+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.27.0] — 2026-04-09 9 + 10 + ### Added 11 + - Add recurring events to calendar (daily, weekly, monthly, yearly with optional end date) (#572) 12 + - Add RRULE support to ICS export for recurring events (#572) 13 + 8 14 ## [0.26.0] — 2026-04-09 9 15 10 16 ### Added
+111
src/calendar/helpers.ts
··· 2 2 * Calendar pure helper functions — extracted for testability. 3 3 */ 4 4 5 + export type RecurrenceType = 'none' | 'daily' | 'weekly' | 'monthly' | 'yearly'; 6 + 7 + export interface Recurrence { 8 + type: RecurrenceType; 9 + /** End date for recurrence (YYYY-MM-DD), or empty for no end */ 10 + until?: string; 11 + } 12 + 5 13 export interface CalendarEvent { 6 14 id: string; 7 15 title: string; ··· 12 20 allDay: boolean; 13 21 color: string; 14 22 description: string; 23 + recurrence?: Recurrence; 15 24 createdAt: number; 16 25 updatedAt: number; 17 26 } ··· 132 141 const totalDays = daysInMonth(year, month); 133 142 return Math.ceil((firstDay + totalDays) / 7) * 7; 134 143 } 144 + 145 + /** 146 + * Expand recurring events into concrete instances within a date range. 147 + * Non-recurring events are passed through unchanged. 148 + * Recurring events produce virtual copies with adjusted dates. 149 + */ 150 + export function expandRecurringEvents( 151 + events: CalendarEvent[], 152 + rangeStart: string, 153 + rangeEnd: string, 154 + ): CalendarEvent[] { 155 + const result: CalendarEvent[] = []; 156 + 157 + for (const evt of events) { 158 + if (!evt.recurrence || evt.recurrence.type === 'none') { 159 + result.push(evt); 160 + continue; 161 + } 162 + 163 + const { type, until } = evt.recurrence; 164 + const effectiveEnd = until && until < rangeEnd ? until : rangeEnd; 165 + const eventDuration = evt.endDate 166 + ? daysBetween(evt.date, evt.endDate) 167 + : 0; 168 + 169 + let current = parseEventDate(evt.date); 170 + const endDate = parseEventDate(effectiveEnd); 171 + const startRange = parseEventDate(rangeStart); 172 + 173 + // Cap at 365 iterations to prevent runaway loops 174 + let iterations = 0; 175 + while (current <= endDate && iterations < 365) { 176 + const dateStr = formatDate(current); 177 + 178 + // Only include if the instance falls within the visible range 179 + const instanceEnd = eventDuration > 0 180 + ? formatDate(addDays(current, eventDuration)) 181 + : dateStr; 182 + 183 + if (instanceEnd >= rangeStart && dateStr <= rangeEnd) { 184 + const instance: CalendarEvent = { 185 + ...evt, 186 + date: dateStr, 187 + ...(eventDuration > 0 ? { endDate: instanceEnd } : {}), 188 + }; 189 + result.push(instance); 190 + } 191 + 192 + // Advance to next occurrence 193 + current = nextOccurrence(current, type); 194 + iterations++; 195 + 196 + // Skip instances before our visible range 197 + if (current < startRange && iterations < 365) { 198 + continue; 199 + } 200 + } 201 + } 202 + 203 + return result; 204 + } 205 + 206 + function daysBetween(startStr: string, endStr: string): number { 207 + const a = parseEventDate(startStr).getTime(); 208 + const b = parseEventDate(endStr).getTime(); 209 + return Math.round((b - a) / (24 * 60 * 60 * 1000)); 210 + } 211 + 212 + function addDays(d: Date, n: number): Date { 213 + const result = new Date(d); 214 + result.setDate(result.getDate() + n); 215 + return result; 216 + } 217 + 218 + function nextOccurrence(current: Date, type: RecurrenceType): Date { 219 + const next = new Date(current); 220 + switch (type) { 221 + case 'daily': 222 + next.setDate(next.getDate() + 1); 223 + break; 224 + case 'weekly': 225 + next.setDate(next.getDate() + 7); 226 + break; 227 + case 'monthly': 228 + next.setMonth(next.getMonth() + 1); 229 + break; 230 + case 'yearly': 231 + next.setFullYear(next.getFullYear() + 1); 232 + break; 233 + default: 234 + next.setDate(next.getDate() + 1); 235 + } 236 + return next; 237 + } 238 + 239 + export const RECURRENCE_OPTIONS: Array<{ type: RecurrenceType; label: string }> = [ 240 + { type: 'none', label: 'Does not repeat' }, 241 + { type: 'daily', label: 'Daily' }, 242 + { type: 'weekly', label: 'Weekly' }, 243 + { type: 'monthly', label: 'Monthly' }, 244 + { type: 'yearly', label: 'Yearly' }, 245 + ];
+18
src/calendar/ics-export.ts
··· 114 114 115 115 lines.push(`SUMMARY:${escapeIcsText(event.title)}`); 116 116 117 + // Recurrence rule (RFC 5545 RRULE) 118 + if (event.recurrence && event.recurrence.type !== 'none') { 119 + const freqMap: Record<string, string> = { 120 + daily: 'DAILY', 121 + weekly: 'WEEKLY', 122 + monthly: 'MONTHLY', 123 + yearly: 'YEARLY', 124 + }; 125 + const freq = freqMap[event.recurrence.type]; 126 + if (freq) { 127 + let rrule = `RRULE:FREQ=${freq}`; 128 + if (event.recurrence.until) { 129 + rrule += `;UNTIL=${toIcsDate(event.recurrence.until)}`; 130 + } 131 + lines.push(rrule); 132 + } 133 + } 134 + 117 135 if (event.description) { 118 136 lines.push(`DESCRIPTION:${escapeIcsText(event.description)}`); 119 137 }
+16
src/calendar/index.html
··· 137 137 </div> 138 138 139 139 <div class="event-modal-field"> 140 + <label for="event-recurrence">Repeat</label> 141 + <select id="event-recurrence" class="event-modal-input"> 142 + <option value="none">Does not repeat</option> 143 + <option value="daily">Daily</option> 144 + <option value="weekly">Weekly</option> 145 + <option value="monthly">Monthly</option> 146 + <option value="yearly">Yearly</option> 147 + </select> 148 + </div> 149 + 150 + <div class="event-modal-field" id="recurrence-until-field" style="display:none"> 151 + <label for="event-recurrence-until">Repeat until</label> 152 + <input type="date" id="event-recurrence-until" class="event-modal-input"> 153 + </div> 154 + 155 + <div class="event-modal-field"> 140 156 <label>Color</label> 141 157 <div class="event-color-picker" id="event-color-picker"> 142 158 <button class="event-color-swatch active" data-color="#3a8a7a" style="background:#3a8a7a" title="Teal" type="button"></button>
+70 -3
src/calendar/main.ts
··· 15 15 import { 16 16 type CalendarEvent, 17 17 type CalendarView, 18 + type Recurrence, 19 + type RecurrenceType, 18 20 EVENT_COLORS, 19 21 DAYS_OF_WEEK, 20 22 MONTHS, ··· 32 34 getDayOfWeekFull, 33 35 eventsOnDate as eventsOnDateHelper, 34 36 multiDayPosition, 37 + expandRecurringEvents, 35 38 } from './helpers.js'; 36 39 import { parseIcsFile } from './ics-parser.js'; 37 40 import { exportIcsFile } from './ics-export.js'; ··· 128 131 const modalDuplicate = document.getElementById('btn-event-duplicate') as HTMLButtonElement; 129 132 const modalCancel = document.getElementById('btn-event-cancel') as HTMLButtonElement; 130 133 const modalTitleEl = document.getElementById('event-modal-title') as HTMLElement; 134 + const modalRecurrence = document.getElementById('event-recurrence') as HTMLSelectElement; 135 + const modalRecurrenceUntil = document.getElementById('event-recurrence-until') as HTMLInputElement; 136 + const recurrenceUntilField = document.getElementById('recurrence-until-field') as HTMLElement; 131 137 132 138 // --------------------------------------------------------------------------- 133 139 // Helpers ··· 139 145 return div.innerHTML; 140 146 } 141 147 148 + /** Cached expanded events for the current render pass */ 149 + let expandedEventsCache: { key: string; events: CalendarEvent[] } | null = null; 150 + 151 + function getExpandedEvents(rangeStart: string, rangeEnd: string): CalendarEvent[] { 152 + const key = `${rangeStart}:${rangeEnd}`; 153 + if (expandedEventsCache?.key === key) return expandedEventsCache.events; 154 + const expanded = expandRecurringEvents(state.events, rangeStart, rangeEnd); 155 + expandedEventsCache = { key, events: expanded }; 156 + return expanded; 157 + } 158 + 142 159 function eventsOnDate(dateStr: string): CalendarEvent[] { 143 - return eventsOnDateHelper(state.events, dateStr); 160 + const events = expandedEventsCache?.events ?? state.events; 161 + return eventsOnDateHelper(events, dateStr); 144 162 } 145 163 146 164 // --------------------------------------------------------------------------- ··· 155 173 } catch { /* skip corrupt entries */ } 156 174 } 157 175 state.events = items; 176 + expandedEventsCache = null; 158 177 } 159 178 160 179 function findYjsIndex(eventId: string): number { ··· 289 308 // Render dispatcher 290 309 // --------------------------------------------------------------------------- 291 310 311 + function getVisibleRange(): [string, string] { 312 + const d = state.currentDate; 313 + switch (state.view) { 314 + case 'month': { 315 + // Month grid shows prev/next month overflow cells 316 + const first = new Date(d.getFullYear(), d.getMonth(), 1); 317 + const start = new Date(first); 318 + start.setDate(start.getDate() - first.getDay()); // back to Sunday 319 + const last = new Date(d.getFullYear(), d.getMonth() + 1, 0); 320 + const end = new Date(last); 321 + end.setDate(end.getDate() + (6 - last.getDay())); // forward to Saturday 322 + return [formatDate(start), formatDate(end)]; 323 + } 324 + case 'week': { 325 + const ws = getWeekStart(d); 326 + const we = getWeekEnd(d); 327 + return [formatDate(ws), formatDate(we)]; 328 + } 329 + case 'day': 330 + return [formatDate(d), formatDate(d)]; 331 + case 'agenda': { 332 + const end = new Date(d); 333 + end.setDate(end.getDate() + AGENDA_DAYS); 334 + return [formatDate(d), formatDate(end)]; 335 + } 336 + } 337 + } 338 + 292 339 function renderView(): void { 293 340 updateDateLabel(); 341 + // Pre-expand recurring events for the visible range 342 + const [rangeStart, rangeEnd] = getVisibleRange(); 343 + getExpandedEvents(rangeStart, rangeEnd); 294 344 syncMiniToState(); 295 345 renderMiniCalendar(); 296 346 switch (state.view) { ··· 643 693 const end = new Date(start); 644 694 end.setDate(end.getDate() + AGENDA_DAYS); 645 695 646 - // Collect events in the range, grouped by date 696 + // Collect events in the range (with recurring expanded), grouped by date 697 + const expanded = expandedEventsCache?.events ?? state.events; 647 698 const dateGroups: Map<string, CalendarEvent[]> = new Map(); 648 - for (const evt of state.events) { 699 + for (const evt of expanded) { 649 700 const evtDate = parseEventDate(evt.date); 650 701 if (evtDate >= start && evtDate < end) { 651 702 const dateStr = evt.date; ··· 747 798 modalColorPicker.querySelectorAll('.event-color-swatch').forEach(swatch => { 748 799 swatch.classList.toggle('active', (swatch as HTMLElement).dataset.color === activeColor); 749 800 }); 801 + 802 + // Recurrence fields 803 + const rec = (evt as CalendarEvent).recurrence; 804 + modalRecurrence.value = rec?.type ?? 'none'; 805 + modalRecurrenceUntil.value = rec?.until ?? ''; 806 + updateRecurrenceUntilVisibility(); 750 807 751 808 // Toggle time fields visibility based on all-day 752 809 updateTimeFieldsVisibility(); ··· 773 830 modalEndTime.parentElement!.style.display = hidden ? 'none' : ''; 774 831 } 775 832 833 + function updateRecurrenceUntilVisibility(): void { 834 + recurrenceUntilField.style.display = modalRecurrence.value !== 'none' ? '' : 'none'; 835 + } 836 + 776 837 function saveEvent(): void { 777 838 const now = Date.now(); 778 839 const endDateVal = modalEndDate.value; 840 + const recType = modalRecurrence.value as RecurrenceType; 841 + const recurrence: Recurrence | undefined = recType !== 'none' 842 + ? { type: recType, ...(modalRecurrenceUntil.value ? { until: modalRecurrenceUntil.value } : {}) } 843 + : undefined; 779 844 const event: CalendarEvent = { 780 845 id: editingEventId ?? crypto.randomUUID(), 781 846 title: modalTitle.value.trim() || 'Untitled', ··· 786 851 allDay: modalAllDay.checked, 787 852 color: (modalColorPicker.querySelector('.event-color-swatch.active') as HTMLElement)?.dataset.color || EVENT_COLORS[0] || '#4a90d9', 788 853 description: modalDescription.value.trim(), 854 + ...(recurrence ? { recurrence } : {}), 789 855 createdAt: editingEventId 790 856 ? (state.events.find(e => e.id === editingEventId)?.createdAt ?? now) 791 857 : now, ··· 831 897 modalDuplicate.addEventListener('click', duplicateEvent); 832 898 modalCancel.addEventListener('click', closeModal); 833 899 modalAllDay.addEventListener('change', updateTimeFieldsVisibility); 900 + modalRecurrence.addEventListener('change', updateRecurrenceUntilVisibility); 834 901 modalBackdrop.addEventListener('click', (e) => { 835 902 if (e.target === modalBackdrop) closeModal(); 836 903 });
+207
tests/recurring-events.test.ts
··· 1 + /** 2 + * Tests for expandRecurringEvents() in calendar/helpers.ts (#572). 3 + */ 4 + import { describe, it, expect } from 'vitest'; 5 + import { 6 + expandRecurringEvents, 7 + type CalendarEvent, 8 + type Recurrence, 9 + formatDate, 10 + } from '../src/calendar/helpers.js'; 11 + 12 + function makeEvent(overrides: Partial<CalendarEvent> = {}): CalendarEvent { 13 + return { 14 + id: 'e1', 15 + title: 'Test', 16 + date: '2026-04-01', 17 + startTime: '09:00', 18 + endTime: '10:00', 19 + allDay: false, 20 + color: '#3a8a7a', 21 + description: '', 22 + createdAt: 1, 23 + updatedAt: 1, 24 + ...overrides, 25 + }; 26 + } 27 + 28 + describe('expandRecurringEvents', () => { 29 + it('passes through non-recurring events unchanged', () => { 30 + const events = [makeEvent()]; 31 + const result = expandRecurringEvents(events, '2026-04-01', '2026-04-30'); 32 + expect(result).toHaveLength(1); 33 + expect(result[0].id).toBe('e1'); 34 + expect(result[0].date).toBe('2026-04-01'); 35 + }); 36 + 37 + it('passes through events with recurrence type "none"', () => { 38 + const events = [makeEvent({ recurrence: { type: 'none' } })]; 39 + const result = expandRecurringEvents(events, '2026-04-01', '2026-04-30'); 40 + expect(result).toHaveLength(1); 41 + }); 42 + 43 + describe('daily recurrence', () => { 44 + it('expands daily events within range', () => { 45 + const events = [makeEvent({ 46 + recurrence: { type: 'daily' }, 47 + })]; 48 + const result = expandRecurringEvents(events, '2026-04-01', '2026-04-05'); 49 + expect(result).toHaveLength(5); 50 + expect(result[0].date).toBe('2026-04-01'); 51 + expect(result[4].date).toBe('2026-04-05'); 52 + }); 53 + 54 + it('respects "until" end date', () => { 55 + const events = [makeEvent({ 56 + recurrence: { type: 'daily', until: '2026-04-03' }, 57 + })]; 58 + const result = expandRecurringEvents(events, '2026-04-01', '2026-04-10'); 59 + expect(result).toHaveLength(3); 60 + expect(result[2].date).toBe('2026-04-03'); 61 + }); 62 + }); 63 + 64 + describe('weekly recurrence', () => { 65 + it('expands weekly events', () => { 66 + const events = [makeEvent({ 67 + date: '2026-04-01', // Wednesday 68 + recurrence: { type: 'weekly' }, 69 + })]; 70 + const result = expandRecurringEvents(events, '2026-04-01', '2026-04-30'); 71 + // Apr 1, 8, 15, 22, 29 72 + expect(result).toHaveLength(5); 73 + expect(result[0].date).toBe('2026-04-01'); 74 + expect(result[1].date).toBe('2026-04-08'); 75 + expect(result[2].date).toBe('2026-04-15'); 76 + expect(result[3].date).toBe('2026-04-22'); 77 + expect(result[4].date).toBe('2026-04-29'); 78 + }); 79 + }); 80 + 81 + describe('monthly recurrence', () => { 82 + it('expands monthly events', () => { 83 + const events = [makeEvent({ 84 + date: '2026-01-15', 85 + recurrence: { type: 'monthly' }, 86 + })]; 87 + const result = expandRecurringEvents(events, '2026-01-01', '2026-06-30'); 88 + // Jan 15, Feb 15, Mar 15, Apr 15, May 15, Jun 15 89 + expect(result).toHaveLength(6); 90 + expect(result[0].date).toBe('2026-01-15'); 91 + expect(result[5].date).toBe('2026-06-15'); 92 + }); 93 + }); 94 + 95 + describe('yearly recurrence', () => { 96 + it('expands yearly events', () => { 97 + const events = [makeEvent({ 98 + date: '2024-03-20', 99 + recurrence: { type: 'yearly' }, 100 + })]; 101 + const result = expandRecurringEvents(events, '2024-01-01', '2027-12-31'); 102 + // 2024, 2025, 2026, 2027 103 + expect(result).toHaveLength(4); 104 + expect(result[0].date).toBe('2024-03-20'); 105 + expect(result[3].date).toBe('2027-03-20'); 106 + }); 107 + }); 108 + 109 + describe('multi-day recurring events', () => { 110 + it('preserves event duration across instances', () => { 111 + const events = [makeEvent({ 112 + date: '2026-04-01', 113 + endDate: '2026-04-03', // 2-day event 114 + recurrence: { type: 'weekly' }, 115 + })]; 116 + const result = expandRecurringEvents(events, '2026-04-01', '2026-04-15'); 117 + // Apr 1-3, Apr 8-10, Apr 15-17 (15 is in range) 118 + expect(result).toHaveLength(3); 119 + expect(result[0].date).toBe('2026-04-01'); 120 + expect(result[0].endDate).toBe('2026-04-03'); 121 + expect(result[1].date).toBe('2026-04-08'); 122 + expect(result[1].endDate).toBe('2026-04-10'); 123 + }); 124 + }); 125 + 126 + describe('range filtering', () => { 127 + it('excludes instances before range start', () => { 128 + const events = [makeEvent({ 129 + date: '2026-01-01', 130 + recurrence: { type: 'monthly' }, 131 + })]; 132 + const result = expandRecurringEvents(events, '2026-04-01', '2026-06-30'); 133 + // Only Apr 1, May 1, Jun 1 134 + expect(result).toHaveLength(3); 135 + expect(result[0].date).toBe('2026-04-01'); 136 + }); 137 + 138 + it('excludes instances after range end', () => { 139 + const events = [makeEvent({ 140 + date: '2026-04-01', 141 + recurrence: { type: 'daily' }, 142 + })]; 143 + const result = expandRecurringEvents(events, '2026-04-01', '2026-04-03'); 144 + expect(result).toHaveLength(3); 145 + }); 146 + }); 147 + 148 + describe('mixed events', () => { 149 + it('handles mix of recurring and non-recurring', () => { 150 + const events = [ 151 + makeEvent({ id: 'once', date: '2026-04-05' }), 152 + makeEvent({ id: 'recurring', date: '2026-04-01', recurrence: { type: 'weekly' } }), 153 + ]; 154 + const result = expandRecurringEvents(events, '2026-04-01', '2026-04-14'); 155 + // once: Apr 5 156 + // recurring: Apr 1, Apr 8 157 + expect(result).toHaveLength(3); 158 + expect(result.filter(e => e.id === 'once')).toHaveLength(1); 159 + expect(result.filter(e => e.id === 'recurring')).toHaveLength(2); 160 + }); 161 + }); 162 + 163 + describe('safety cap', () => { 164 + it('does not produce more than 365 instances', () => { 165 + const events = [makeEvent({ 166 + date: '2020-01-01', 167 + recurrence: { type: 'daily' }, 168 + })]; 169 + // Range spans 2 years — would be 730+ days without cap 170 + const result = expandRecurringEvents(events, '2020-01-01', '2021-12-31'); 171 + expect(result.length).toBeLessThanOrEqual(365); 172 + }); 173 + }); 174 + 175 + describe('edge cases', () => { 176 + it('handles empty events array', () => { 177 + const result = expandRecurringEvents([], '2026-04-01', '2026-04-30'); 178 + expect(result).toHaveLength(0); 179 + }); 180 + 181 + it('handles event starting after range', () => { 182 + const events = [makeEvent({ 183 + date: '2026-05-01', 184 + recurrence: { type: 'daily' }, 185 + })]; 186 + const result = expandRecurringEvents(events, '2026-04-01', '2026-04-30'); 187 + expect(result).toHaveLength(0); 188 + }); 189 + 190 + it('preserves all event properties on instances', () => { 191 + const events = [makeEvent({ 192 + title: 'Standup', 193 + startTime: '09:00', 194 + endTime: '09:30', 195 + color: '#d94a4a', 196 + description: 'Daily standup', 197 + recurrence: { type: 'daily' }, 198 + })]; 199 + const result = expandRecurringEvents(events, '2026-04-01', '2026-04-02'); 200 + expect(result[1].title).toBe('Standup'); 201 + expect(result[1].startTime).toBe('09:00'); 202 + expect(result[1].endTime).toBe('09:30'); 203 + expect(result[1].color).toBe('#d94a4a'); 204 + expect(result[1].description).toBe('Daily standup'); 205 + }); 206 + }); 207 + });