Files
SRCmail/lib/recurrence-expansion.ts
T

333 lines
10 KiB
TypeScript

/**
* 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<string, number> = { 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<string>();
// 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<CalendarEvent> & { 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<CalendarEvent> & { 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>,
): 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;
}
}