/** * Client-side recurrence expansion for JSCalendar events. * * Stalwart does not yet support mutations on the synthetic IDs produced by * CalendarEvent/query?expandRecurrences=true, so we fetch raw events (with * real, mutable IDs) and expand recurring series into individual occurrences * in the browser. */ import { parseISO, format, addDays, addWeeks, addMonths, addYears } from 'date-fns'; import type { CalendarEvent, CalendarRecurrenceRule } from '@/lib/jmap/types'; const DAY_INDEX: Record = { su: 0, mo: 1, tu: 2, we: 3, th: 4, fr: 5, sa: 6 }; /** * Given a master event and a date range, return an array of "virtual" occurrence * events. Each occurrence carries a synthetic `id` (for dedup in React) and a * `masterEventId` field that points back to the real server-side ID so the * store can use it for mutations. * * Non-recurring events are returned as-is. For recurring events the master is * **not** returned — only expanded instances within the range. */ export function expandRecurringEvents( events: CalendarEvent[], rangeStart: string, rangeEnd: string, ): CalendarEvent[] { const start = parseISO(rangeStart); const end = parseISO(rangeEnd); const result: CalendarEvent[] = []; for (const event of events) { if (!event.recurrenceRules?.length) { // Non-recurring event — pass through unchanged result.push(event); continue; } // Expand each recurrence rule const occurrences = expandEvent(event, start, end); result.push(...occurrences); } return result; } function expandEvent( master: CalendarEvent, rangeStart: Date, rangeEnd: Date, ): CalendarEvent[] { const eventStart = parseISO(master.start); if (isNaN(eventStart.getTime())) return []; const rules = master.recurrenceRules || []; const overrides = master.recurrenceOverrides || {}; const occurrences: CalendarEvent[] = []; const seenDates = new Set(); // Process each rule for (const rule of rules) { const dates = generateDates(eventStart, rule, rangeStart, rangeEnd); for (const date of dates) { const dateKey = master.showWithoutTime ? format(date, 'yyyy-MM-dd') : date.toISOString(); if (seenDates.has(dateKey)) continue; seenDates.add(dateKey); const recurrenceId = master.showWithoutTime ? format(date, "yyyy-MM-dd'T'00:00:00") : format(date, "yyyy-MM-dd'T'HH:mm:ss"); const override = overrides[recurrenceId] as (Partial & { excluded?: boolean }) | undefined; if (override?.excluded) continue; const occurrence = createOccurrence(master, date, recurrenceId, override); occurrences.push(occurrence); } } // Also add any overrides that add new dates (not generated by rules) for (const [recurrenceId, rawOverride] of Object.entries(overrides)) { const override = rawOverride as Partial & { excluded?: boolean }; if (override.excluded) continue; const overrideDate = parseISO(recurrenceId); if (isNaN(overrideDate.getTime())) continue; if (overrideDate < rangeStart || overrideDate >= rangeEnd) continue; const dateKey = master.showWithoutTime ? format(overrideDate, 'yyyy-MM-dd') : overrideDate.toISOString(); if (seenDates.has(dateKey)) continue; seenDates.add(dateKey); const occurrence = createOccurrence(master, overrideDate, recurrenceId, override); occurrences.push(occurrence); } return occurrences; } function createOccurrence( master: CalendarEvent, date: Date, recurrenceId: string, override?: Partial, ): CalendarEvent { const startStr = master.showWithoutTime ? format(date, "yyyy-MM-dd'T'00:00:00") : format(date, "yyyy-MM-dd'T'HH:mm:ss"); return { ...master, ...(override || {}), id: `${master.id}:${recurrenceId}`, originalId: master.originalId || master.id, uid: master.uid, calendarIds: master.calendarIds, start: (override?.start) || startStr, recurrenceId, // Remove recurrence rules from instances — only the master has them recurrenceRules: master.recurrenceRules, recurrenceOverrides: master.recurrenceOverrides, excludedRecurrenceRules: master.excludedRecurrenceRules, }; } /** * Generate occurrence dates for a single recurrence rule within a range. * Capped at 500 occurrences to prevent runaway loops. */ function generateDates( eventStart: Date, rule: CalendarRecurrenceRule, rangeStart: Date, rangeEnd: Date, ): Date[] { const dates: Date[] = []; const interval = rule.interval || 1; const limit = rule.count || 500; const until = rule.until ? parseISO(rule.until) : null; let count = 0; let current = new Date(eventStart); // Safety: don't generate more than 500 occurrences const maxIterations = 2000; let iterations = 0; while (iterations++ < maxIterations) { if (count >= limit) break; if (until && current > until) break; if (current >= rangeEnd) break; // For weekly rules with byDay, expand into multiple days per week if (rule.frequency === 'weekly' && rule.byDay?.length) { const weekDates = expandByDay(current, rule.byDay, eventStart); for (const wd of weekDates) { if (until && wd > until) break; if (count >= limit) break; if (wd >= rangeEnd) break; count++; if (wd >= rangeStart) { dates.push(wd); } } } else if (rule.frequency === 'monthly' && rule.byDay?.length) { const monthDates = expandByDayMonthly(current, rule.byDay); for (const md of monthDates) { if (until && md > until) break; if (count >= limit) break; if (md >= rangeEnd) break; count++; if (md >= rangeStart) { dates.push(md); } } } else if (rule.frequency === 'monthly' && rule.byMonthDay?.length) { for (const day of rule.byMonthDay) { const d = new Date(current); d.setDate(day); if (d.getMonth() !== current.getMonth()) continue; // skip invalid days (e.g., Feb 30) if (until && d > until) break; if (count >= limit) break; count++; if (d >= rangeStart && d < rangeEnd) { dates.push(d); } } } else if (rule.frequency === 'yearly' && rule.byMonth?.length) { for (const monthStr of rule.byMonth) { const month = parseInt(monthStr, 10) - 1; // JSCalendar months are 1-indexed const d = new Date(current); d.setMonth(month); if (rule.byMonthDay?.length) { for (const day of rule.byMonthDay) { const dd = new Date(d); dd.setDate(day); if (dd.getMonth() !== month) continue; if (until && dd > until) break; if (count >= limit) break; count++; if (dd >= rangeStart && dd < rangeEnd) { dates.push(dd); } } } else { if (until && d > until) break; if (count >= limit) break; count++; if (d >= rangeStart && d < rangeEnd) { dates.push(d); } } } } else { count++; if (current >= rangeStart && current < rangeEnd) { dates.push(new Date(current)); } } current = advanceDate(current, rule.frequency, interval); } return dates; } function advanceDate( date: Date, frequency: CalendarRecurrenceRule['frequency'], interval: number, ): Date { switch (frequency) { case 'daily': return addDays(date, interval); case 'weekly': return addWeeks(date, interval); case 'monthly': return addMonths(date, interval); case 'yearly': return addYears(date, interval); case 'hourly': return new Date(date.getTime() + interval * 3600000); case 'minutely': return new Date(date.getTime() + interval * 60000); case 'secondly': return new Date(date.getTime() + interval * 1000); default: return addDays(date, interval); } } function expandByDay( weekStart: Date, byDay: { day: string; nthOfPeriod?: number }[], eventStart: Date, ): Date[] { const dates: Date[] = []; const baseDay = weekStart.getDay(); for (const { day } of byDay) { const targetDay = DAY_INDEX[day]; if (targetDay === undefined) continue; let diff = targetDay - baseDay; if (diff < 0) diff += 7; const d = addDays(weekStart, diff); // Preserve time from the original event d.setHours(eventStart.getHours(), eventStart.getMinutes(), eventStart.getSeconds()); dates.push(d); } return dates.sort((a, b) => a.getTime() - b.getTime()); } function expandByDayMonthly( monthStart: Date, byDay: { day: string; nthOfPeriod?: number }[], ): Date[] { const dates: Date[] = []; const year = monthStart.getFullYear(); const month = monthStart.getMonth(); for (const { day, nthOfPeriod } of byDay) { const targetDay = DAY_INDEX[day]; if (targetDay === undefined) continue; if (nthOfPeriod) { // Find the nth occurrence of this weekday in the month const d = nthWeekdayOfMonth(year, month, targetDay, nthOfPeriod); if (d) { d.setHours(monthStart.getHours(), monthStart.getMinutes(), monthStart.getSeconds()); dates.push(d); } } else { // Every occurrence of this weekday in the month let d = new Date(year, month, 1); while (d.getDay() !== targetDay) { d = addDays(d, 1); } while (d.getMonth() === month) { const occ = new Date(d); occ.setHours(monthStart.getHours(), monthStart.getMinutes(), monthStart.getSeconds()); dates.push(occ); d = addDays(d, 7); } } } return dates.sort((a, b) => a.getTime() - b.getTime()); } function nthWeekdayOfMonth( year: number, month: number, weekday: number, nth: number, ): Date | null { if (nth > 0) { // Forward from start of month let d = new Date(year, month, 1); while (d.getDay() !== weekday) { d = addDays(d, 1); } d = addDays(d, (nth - 1) * 7); return d.getMonth() === month ? d : null; } else { // Backward from end of month let d = new Date(year, month + 1, 0); // last day of month while (d.getDay() !== weekday) { d = addDays(d, -1); } if (nth < -1) { d = addDays(d, (nth + 1) * 7); } return d.getMonth() === month ? d : null; } }