333 lines
10 KiB
TypeScript
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;
|
|
}
|
|
}
|