Files
webmail/lib/recurrence-expansion.ts
T
Linus Rath 4c20b09c1c fix: show moved occurrences on 0.16.23 and keep organizers on save
- Stalwart 0.16.23 answers `utcStart` and `utcEnd` of a counted
  series' master with "32548-12-04T15:30:07Z" once every occurrence is
  overridden or excluded, and with the next plain occurrence's times
  once the first one is; 1.0 answers the master's own. Expanding such
  a series in the browser took the occurrences' length from those
  instants and copied them onto the occurrences, so a moved occurrence
  fell out of the range. The master's instants now count only where
  they fit its wall clock, the length falls back to the JSCalendar
  duration, the range check ignores instants that do not parse or fit,
  and no occurrence gets the master's instants, so the alerts of an
  all-day series expanded in the browser go off before each day, not
  before the first.
- The event editor rebuilt the participants around the user's first
  address on every save, set `organizerCalendarAddress` to it and put
  every attendee back to needs-action. An invitation the user saved in
  their own calendar became theirs and lost their answer, and one
  occurrence of a series organized under another address or letter
  case got a different owner; Stalwart 1.0 refuses a changed
  `organizerCalendarAddress` there. The editor now keeps the event's
  own organizer, writes `organizerCalendarAddress` only for an event
  without one, and keeps the id and answer of every attendee who
  stays.
2026-09-25 20:20:00 +02:00

956 lines
36 KiB
TypeScript

/**
* Client-side recurrence expansion for JSCalendar events.
*
* Implements JSCalendar 2.0 (draft-ietf-calext-jscalendarbis-15) §3.3.3.1
* recurrence rule interpretation algorithm with full byX filtering,
* implicit byX property addition, and bySetPosition support.
*
* Used for servers that do not accept the synthetic ids produced by
* CalendarEvent/query?expandRecurrences=true in CalendarEvent/set (Stalwart
* before 0.16.20, see lib/recurrence-instances.ts): raw events (with real,
* mutable ids) are fetched and recurring series are expanded into individual
* occurrences in the browser. Occurrences a server already expanded (no
* recurrence rule of their own) pass through unchanged.
*/
import { parseISO, format, addDays, addWeeks, addMonths, addYears, differenceInCalendarDays } from 'date-fns';
import type { CalendarEvent, CalendarRecurrenceRule, CalendarNDay } from '@/lib/jmap/types';
import { parseDurationSeconds } from '@/lib/calendar-event-normalization';
import { applyOverridePatch, isServerRecurrenceInstance } from '@/lib/recurrence-instances';
const DAY_INDEX: Record<string, number> = { su: 0, mo: 1, tu: 2, we: 3, th: 4, fr: 5, sa: 6 };
const INDEX_TO_DAY: string[] = ['su', 'mo', 'tu', 'we', 'th', 'fr', 'sa'];
/**
* 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[] = [];
// Collect UIDs of master recurring events so we can skip their
// server-returned override instances (which have recurrenceId set).
// The master's recurrenceOverrides already accounts for them.
const recurringUids = new Set<string>();
for (const event of events) {
if (event.recurrenceRules?.length && event.uid && !event.recurrenceId) {
recurringUids.add(event.uid);
}
}
for (const event of events) {
// Skip override instances returned by the server - they belong to a
// master recurring event and are already handled via recurrenceOverrides.
if (event.recurrenceId && event.uid && recurringUids.has(event.uid)) {
continue;
}
// An occurrence the server already expanded is a single instance even
// though it carries its series' recurrence rule (lib/recurrence-instances.ts
// hydrates it with that); never expand it again.
if (!event.recurrenceRules?.length || isServerRecurrenceInstance(event)) {
result.push(event);
continue;
}
const occurrences = expandEvent(event, start, end);
result.push(...occurrences);
}
return result;
}
function occurrenceDateKey(master: CalendarEvent, date: Date): string {
return master.showWithoutTime ? format(date, 'yyyy-MM-dd') : date.toISOString();
}
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>();
// RFC 8984 §4.3.3: occurrences produced by excludedRecurrenceRules
// (iCalendar EXRULE) are removed from the recurrence set.
const excludedDates = new Set<string>();
for (const exRule of master.excludedRecurrenceRules || []) {
// includeStartDate=false: "the series start is always an occurrence"
// applies to recurrence rules, not to exclusion rules.
for (const date of generateDates(eventStart, exRule, rangeStart, rangeEnd, false)) {
excludedDates.add(occurrenceDateKey(master, date));
}
}
for (const rule of rules) {
const dates = generateDates(eventStart, rule, rangeStart, rangeEnd);
for (const date of dates) {
const dateKey = occurrenceDateKey(master, date);
if (excludedDates.has(dateKey)) continue;
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);
if (isInRange(occurrence, date, override, rangeStart, rangeEnd)) occurrences.push(occurrence);
}
}
// Add overrides that define new dates not generated by rules (RDATE
// equivalent), and occurrences an override moved here from outside the range.
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;
// Overrides take precedence over excludedRecurrenceRules (they re-add
// a concrete instance), so only dedupe against already-generated dates.
const dateKey = occurrenceDateKey(master, overrideDate);
if (seenDates.has(dateKey)) continue;
const occurrence = createOccurrence(master, overrideDate, recurrenceId, override);
if (!isInRange(occurrence, overrideDate, override, rangeStart, rangeEnd)) continue;
seenDates.add(dateKey);
occurrences.push(occurrence);
}
return occurrences;
}
// Override keys that move or resize an occurrence (their first segment).
const TIMING_KEYS = new Set(['start', 'duration', 'timeZone', 'showWithoutTime']);
/**
* Whether an occurrence belongs in [rangeStart, rangeEnd). One that its
* override moves or resizes goes by its own start and end - where the rule
* put it says nothing about where it is now. Any other goes by `date`, where
* the rule put it, like the plain occurrences around it.
*/
function isInRange(
occurrence: CalendarEvent,
date: Date,
override: Partial<CalendarEvent> | undefined,
rangeStart: Date,
rangeEnd: Date,
): boolean {
const moved = !!override && Object.keys(override).some((key) => TIMING_KEYS.has(key.split('/', 1)[0]));
if (!moved) return date >= rangeStart && date < rangeEnd;
const own = occurrence.showWithoutTime ? null : plausibleInstants(occurrence);
const start = own?.start ?? validDate(occurrence.start);
if (!start) return false;
const end = own?.end ?? new Date(start.getTime() + (parseDurationSeconds(occurrence.duration) ?? 0) * 1000);
if (end <= start) return start >= rangeStart && start < rangeEnd;
return start < rangeEnd && end > rangeStart;
}
// UTC-12 to UTC+14: the furthest an instant is from the wall clock it names.
const MAX_ZONE_OFFSET_MS = 14 * 60 * 60 * 1000;
/** `value` parsed, or null when it is missing or does not parse. */
function validDate(value: string | null | undefined): Date | null {
if (!value) return null;
const date = parseISO(value);
return isNaN(date.getTime()) ? null : date;
}
/** The wall-clock fields of a parsed LocalDateTime, read as UTC. */
function wallAsUtc(wall: Date): number {
return Date.UTC(
wall.getFullYear(), wall.getMonth(), wall.getDate(),
wall.getHours(), wall.getMinutes(), wall.getSeconds(),
);
}
/** `value` parsed, when it is within a time zone's offset of the wall clock `wallMs`. */
function instantNear(value: string | null | undefined, wallMs: number): Date | null {
const instant = validDate(value);
return instant && Math.abs(instant.getTime() - wallMs) <= MAX_ZONE_OFFSET_MS ? instant : null;
}
/**
* An event's utcStart and utcEnd, where they can be its own: each within a
* time zone's offset of the wall-clock start and end, the end not before the
* start. `end` is null when only the start passes. For a series' master
* 0.16.23 has answered both with "32548-12-04T15:30:07Z" once every
* occurrence was overridden or excluded, and with the next plain
* occurrence's times once the first one was.
*/
function plausibleInstants(
event: Pick<CalendarEvent, 'start' | 'duration' | 'utcStart' | 'utcEnd'>,
): { start: Date; end: Date | null } | null {
const wall = validDate(event.start);
if (!wall) return null;
const wallStart = wallAsUtc(wall);
const start = instantNear(event.utcStart, wallStart);
if (!start) return null;
const end = instantNear(event.utcEnd, wallStart + (parseDurationSeconds(event.duration) ?? 0) * 1000);
return { start, end: end && end >= start ? end : null };
}
// Cache Intl formatters per IANA timezone id - constructing them is expensive
// and expansion runs over hundreds of occurrences. `null` marks ids the
// runtime rejected so we don't retry them.
const tzFormatterCache = new Map<string, Intl.DateTimeFormat | null>();
function getTzFormatter(timeZone: string): Intl.DateTimeFormat | null {
let formatter = tzFormatterCache.get(timeZone);
if (formatter === undefined) {
try {
formatter = new Intl.DateTimeFormat('en-CA', {
timeZone,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
});
} catch {
formatter = null;
}
tzFormatterCache.set(timeZone, formatter);
}
return formatter;
}
/** The wall-clock reading of `instant` in the formatter's zone, re-encoded as a UTC timestamp. */
function wallClockAsUtcTimestamp(formatter: Intl.DateTimeFormat, instant: number): number {
const map: Record<string, string> = {};
for (const part of formatter.formatToParts(new Date(instant))) {
if (part.type !== 'literal') map[part.type] = part.value;
}
const hour = map.hour === '24' ? 0 : Number(map.hour);
return Date.UTC(
Number(map.year), Number(map.month) - 1, Number(map.day),
hour, Number(map.minute), Number(map.second),
);
}
/**
* Interpret the wall-clock fields of `wall` as a local time in `timeZone`
* and return the corresponding UTC instant. Two fixup iterations converge
* for all real offsets, including across DST transitions.
*/
function zonedWallTimeToUtc(wall: Date, timeZone: string): Date | null {
const formatter = getTzFormatter(timeZone);
if (!formatter) return null;
const wallMs = wallAsUtc(wall);
let guess = wallMs;
for (let i = 0; i < 2; i++) {
guess = wallMs - (wallClockAsUtcTimestamp(formatter, guess) - guess);
}
return new Date(guess);
}
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");
// The occurrence as the rule generates it, with its override applied as
// a patch: a moved or resized occurrence has its own start, time zone and
// duration from here on.
const generated = { ...master, start: startStr };
const occurrence = override
? applyOverridePatch(generated, override as Record<string, unknown>)
: generated;
const start = occurrence.start || startStr;
// Compute utcStart/utcEnd for this occurrence so that getEventStartDate()
// and getEventEndDate() (which prefer utcStart/utcEnd for timed events)
// return the correct dates instead of the master's original UTC times.
let utcStart: string | undefined;
let utcEnd: string | undefined;
const wall = parseISO(start);
if (!occurrence.showWithoutTime && !isNaN(wall.getTime())) {
// Unless the override sets its own duration, the master's length: from
// its instants when they are its own, else from its duration.
const masterInstants = plausibleInstants(master);
let durationMs: number | null = null;
const ownDuration = override && 'duration' in override ? parseDurationSeconds(occurrence.duration) : null;
if (ownDuration !== null) {
durationMs = ownDuration * 1000;
} else if (masterInstants?.end) {
durationMs = masterInstants.end.getTime() - masterInstants.start.getTime();
} else {
const seconds = parseDurationSeconds(master.duration);
if (seconds !== null) durationMs = seconds * 1000;
}
// Convert the occurrence's wall time using the event's own timezone, so
// occurrences on the other side of a DST transition in that zone keep
// their wall time. Reusing the master's fixed UTC offset (the fallback
// below) would shift them by the DST delta.
const zoned = occurrence.timeZone ? zonedWallTimeToUtc(wall, occurrence.timeZone) : null;
if (zoned) {
utcStart = zoned.toISOString();
if (durationMs !== null) {
utcEnd = new Date(zoned.getTime() + durationMs).toISOString();
}
} else if (masterInstants) {
// Floating events (or an unrecognized timezone id): keep the master's
// offset, which by definition doesn't vary.
const offsetMs = masterInstants.start.getTime() - parseISO(master.start).getTime();
utcStart = new Date(wall.getTime() + offsetMs).toISOString();
if (durationMs !== null) {
utcEnd = new Date(wall.getTime() + offsetMs + durationMs).toISOString();
}
}
}
// The master's utcStart/utcEnd place its first occurrence, not this one:
// an occurrence carries the instants worked out for it, or none.
const { utcStart: _masterStart, utcEnd: _masterEnd, ...series } = occurrence;
return {
...series,
id: `${master.id}:${recurrenceId}`,
originalId: master.originalId || master.id,
uid: master.uid,
calendarIds: master.calendarIds,
start,
...(utcStart ? { utcStart } : {}),
...(utcEnd ? { utcEnd } : {}),
recurrenceId,
recurrenceRules: master.recurrenceRules,
recurrenceOverrides: master.recurrenceOverrides,
excludedRecurrenceRules: master.excludedRecurrenceRules,
} as CalendarEvent;
}
// ---------------------------------------------------------------------------
// §3.3.3.1 - Implicit byX property addition
// ---------------------------------------------------------------------------
function addImplicitByX(
rule: CalendarRecurrenceRule,
eventStart: Date,
): CalendarRecurrenceRule {
const r = { ...rule };
const freq = r.frequency;
// bySecond
if (freq !== 'secondly' && !r.bySecond?.length) {
r.bySecond = [eventStart.getSeconds()];
}
// byMinute
if (freq !== 'secondly' && freq !== 'minutely' && !r.byMinute?.length) {
r.byMinute = [eventStart.getMinutes()];
}
// byHour
if (freq !== 'secondly' && freq !== 'minutely' && freq !== 'hourly' && !r.byHour?.length) {
r.byHour = [eventStart.getHours()];
}
// weekly: implicit byDay
if (freq === 'weekly' && !r.byDay?.length) {
r.byDay = [{ day: INDEX_TO_DAY[eventStart.getDay()] }];
}
// monthly: implicit byMonthDay
if (freq === 'monthly' && !r.byDay?.length && !r.byMonthDay?.length) {
r.byMonthDay = [eventStart.getDate()];
}
// yearly: implicit byMonth / byMonthDay / byDay
if (freq === 'yearly' && !r.byYearDay?.length) {
if (!r.byMonth?.length && !r.byWeekNo?.length && (r.byMonthDay?.length || !r.byDay?.length)) {
r.byMonth = [String(eventStart.getMonth() + 1)];
}
if (!r.byMonthDay?.length && !r.byWeekNo?.length && !r.byDay?.length) {
r.byMonthDay = [eventStart.getDate()];
}
if (r.byWeekNo?.length && !r.byMonthDay?.length && !r.byDay?.length) {
r.byDay = [{ day: INDEX_TO_DAY[eventStart.getDay()] }];
}
}
return r;
}
// ---------------------------------------------------------------------------
// §3.3.3.1 - Generate occurrence dates
// ---------------------------------------------------------------------------
function generateDates(
eventStart: Date,
rawRule: CalendarRecurrenceRule,
rangeStart: Date,
rangeEnd: Date,
includeStartDate = true,
): Date[] {
const rule = addImplicitByX(rawRule, eventStart);
const dates: Date[] = [];
const interval = rule.interval || 1;
const countLimit = rule.count || Infinity;
const until = rule.until ? parseISO(rule.until) : null;
let totalCount = 0;
// Without a `count` limit we can jump straight to the period containing
// the visible range. With one, every occurrence since the series start
// must be generated so it counts against `count`.
let current = rule.count
? new Date(eventStart)
: fastForwardToRange(eventStart, rule.frequency, interval, rangeStart);
const maxIterations = 2000;
// Cap on *emitted* (in-range) dates. This must not count occurrences
// before rangeStart, otherwise a series started long ago (e.g. a daily
// event from two years back) exhausts the budget before reaching the
// visible range and silently renders nothing.
const maxOccurrences = 500;
let iterations = 0;
while (iterations++ < maxIterations) {
if (totalCount >= countLimit || dates.length >= maxOccurrences) break;
if (until && current > until) break;
// Stop once no candidate in this or a later period can fall before
// rangeEnd. Monthly/yearly candidates may precede the period anchor
// within the same month/year, so floor the anchor before comparing.
if (rule.frequency === 'monthly') {
if (new Date(current.getFullYear(), current.getMonth(), 1) >= rangeEnd) break;
} else if (rule.frequency === 'yearly') {
if (new Date(current.getFullYear(), 0, 1) >= rangeEnd) break;
} else if (current >= rangeEnd) {
break;
}
// Generate candidates for the current period, then filter
const candidates = generateCandidatesForPeriod(current, rule, eventStart);
// Apply bySetPosition if present
const filtered = rule.bySetPosition?.length
? applyBySetPosition(candidates, rule.bySetPosition)
: candidates;
for (const d of filtered) {
if (until && d > until) break;
if (totalCount >= countLimit || dates.length >= maxOccurrences) break;
// Spec rule 4: eliminate dates before event start
if (d < eventStart) continue;
totalCount++;
if (d >= rangeStart && d < rangeEnd) {
dates.push(d);
}
if (d >= rangeEnd) break;
}
if (totalCount >= countLimit || dates.length >= maxOccurrences) break;
current = advancePeriod(current, rule.frequency, interval, rule.firstDayOfWeek || 'mo');
if (current <= eventStart && iterations === 1) {
// Safety: ensure we don't go backward
current = advancePeriod(eventStart, rule.frequency, interval, rule.firstDayOfWeek || 'mo');
}
}
// Spec rule 1: the initial start date-time is ALWAYS the first occurrence
if (includeStartDate && dates.length > 0 && dates[0].getTime() !== eventStart.getTime()) {
if (eventStart >= rangeStart && eventStart < rangeEnd) {
// Check it's not already in the list
if (!dates.some(d => d.getTime() === eventStart.getTime())) {
dates.unshift(eventStart);
}
}
}
return dates;
}
// ---------------------------------------------------------------------------
// Generate all candidates within one period, filtered by byX properties
// ---------------------------------------------------------------------------
function generateCandidatesForPeriod(
periodStart: Date,
rule: CalendarRecurrenceRule,
eventStart: Date,
): Date[] {
const freq = rule.frequency;
let candidates: Date[];
// Step 1: Generate base candidates based on frequency + expansion byX
switch (freq) {
case 'yearly':
candidates = expandYearly(periodStart, rule, eventStart);
break;
case 'monthly':
candidates = expandMonthly(periodStart, rule, eventStart);
break;
case 'weekly':
candidates = expandWeekly(periodStart, rule, eventStart);
break;
case 'daily':
case 'hourly':
case 'minutely':
case 'secondly':
candidates = [new Date(periodStart)];
break;
default:
candidates = [new Date(periodStart)];
}
// Step 2: Filter candidates by all applicable byX constraints
candidates = candidates.filter(d => matchesByX(d, rule));
candidates.sort((a, b) => a.getTime() - b.getTime());
return candidates;
}
// ---------------------------------------------------------------------------
// Yearly expansion
// ---------------------------------------------------------------------------
function expandYearly(
periodStart: Date,
rule: CalendarRecurrenceRule,
eventStart: Date,
): Date[] {
const year = periodStart.getFullYear();
const h = eventStart.getHours();
const m = eventStart.getMinutes();
const s = eventStart.getSeconds();
let dates: Date[] = [];
// Determine which months to iterate
const months = rule.byMonth?.length
? rule.byMonth.map(ms => parseInt(ms.replace('L', ''), 10) - 1)
: [eventStart.getMonth()];
if (rule.byWeekNo?.length) {
// Expand by ISO week numbers
for (const wn of rule.byWeekNo) {
const weekDates = datesInISOWeek(year, wn, rule.firstDayOfWeek || 'mo');
dates.push(...weekDates.map(d => { d.setHours(h, m, s, 0); return d; }));
}
} else if (rule.byYearDay?.length) {
// Expand by day-of-year
for (const yd of rule.byYearDay) {
const d = dayOfYear(year, yd);
if (d) { d.setHours(h, m, s, 0); dates.push(d); }
}
} else if (rule.byDay?.length && rule.byMonthDay?.length) {
// Both byDay and byMonthDay: expand byMonthDay in each month, then byDay filters later
for (const mo of months) {
for (const md of rule.byMonthDay) {
const d = resolveMonthDay(year, mo, md);
if (d) { d.setHours(h, m, s, 0); dates.push(d); }
}
}
} else if (rule.byDay?.length) {
// byDay with nthOfPeriod in yearly context = nth weekday of year or month
for (const mo of months) {
const expanded = expandByDayInMonth(year, mo, rule.byDay, h, m, s);
dates.push(...expanded);
}
} else if (rule.byMonthDay?.length) {
for (const mo of months) {
for (const md of rule.byMonthDay) {
const d = resolveMonthDay(year, mo, md);
if (d) { d.setHours(h, m, s, 0); dates.push(d); }
}
}
} else {
// Simple yearly: same date each year
for (const mo of months) {
const d = new Date(year, mo, eventStart.getDate(), h, m, s, 0);
if (d.getMonth() === mo) dates.push(d);
}
}
return dates;
}
// ---------------------------------------------------------------------------
// Monthly expansion
// ---------------------------------------------------------------------------
function expandMonthly(
periodStart: Date,
rule: CalendarRecurrenceRule,
eventStart: Date,
): Date[] {
const year = periodStart.getFullYear();
const month = periodStart.getMonth();
const h = eventStart.getHours();
const m = eventStart.getMinutes();
const s = eventStart.getSeconds();
const dates: Date[] = [];
if (rule.byDay?.length) {
const expanded = expandByDayInMonth(year, month, rule.byDay, h, m, s);
dates.push(...expanded);
} else if (rule.byMonthDay?.length) {
for (const md of rule.byMonthDay) {
const d = resolveMonthDay(year, month, md);
if (d) { d.setHours(h, m, s, 0); dates.push(d); }
}
} else {
// Implicit byMonthDay already added, but as fallback:
const d = new Date(year, month, eventStart.getDate(), h, m, s, 0);
if (d.getMonth() === month) dates.push(d);
}
return dates;
}
// ---------------------------------------------------------------------------
// Weekly expansion
// ---------------------------------------------------------------------------
function expandWeekly(
periodStart: Date,
rule: CalendarRecurrenceRule,
eventStart: Date,
): Date[] {
const dates: Date[] = [];
const baseDay = periodStart.getDay();
const byDay = rule.byDay?.length ? rule.byDay : [{ day: INDEX_TO_DAY[eventStart.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(periodStart, diff);
d.setHours(eventStart.getHours(), eventStart.getMinutes(), eventStart.getSeconds(), 0);
dates.push(d);
}
return dates.sort((a, b) => a.getTime() - b.getTime());
}
// ---------------------------------------------------------------------------
// byX matching (Step 2 of §3.3.3.1)
// ---------------------------------------------------------------------------
function matchesByX(date: Date, rule: CalendarRecurrenceRule): boolean {
if (rule.byMonth?.length) {
const month = String(date.getMonth() + 1);
if (!rule.byMonth.some(m => m.replace('L', '') === month)) return false;
}
if (rule.byWeekNo?.length) {
const wn = getISOWeekNumber(date);
const weeksInYear = getISOWeeksInYear(date.getFullYear());
if (!rule.byWeekNo.some(w => (w > 0 ? w : weeksInYear + 1 + w) === wn)) return false;
}
if (rule.byYearDay?.length) {
const yd = getDayOfYear(date);
const daysInYear = isLeapYear(date.getFullYear()) ? 366 : 365;
if (!rule.byYearDay.some(d => (d > 0 ? d : daysInYear + 1 + d) === yd)) return false;
}
if (rule.byMonthDay?.length) {
const md = date.getDate();
const daysInMonth = new Date(date.getFullYear(), date.getMonth() + 1, 0).getDate();
if (!rule.byMonthDay.some(d => (d > 0 ? d : daysInMonth + 1 + d) === md)) return false;
}
if (rule.byDay?.length) {
const dayName = INDEX_TO_DAY[date.getDay()];
const freq = rule.frequency;
if (!rule.byDay.some(nd => {
if (nd.day !== dayName) return false;
if (nd.nthOfPeriod == null) return true;
if (freq === 'monthly') {
return nd.nthOfPeriod === nthWeekdayInMonth(date, nd.nthOfPeriod);
}
if (freq === 'yearly') {
// When byMonth is present, nthOfPeriod scopes to the month (iCalendar semantics)
if (rule.byMonth?.length) {
return nd.nthOfPeriod === nthWeekdayInMonth(date, nd.nthOfPeriod);
}
return nd.nthOfPeriod === nthWeekdayInYear(date, nd.nthOfPeriod);
}
return true;
})) return false;
}
if (rule.byHour?.length) {
if (!rule.byHour.includes(date.getHours())) return false;
}
if (rule.byMinute?.length) {
if (!rule.byMinute.includes(date.getMinutes())) return false;
}
if (rule.bySecond?.length) {
if (!rule.bySecond.includes(date.getSeconds())) return false;
}
return true;
}
// ---------------------------------------------------------------------------
// bySetPosition (Step 3 of §3.3.3.1)
// ---------------------------------------------------------------------------
function applyBySetPosition(dates: Date[], positions: number[]): Date[] {
if (!dates.length) return dates;
const result: Date[] = [];
const len = dates.length;
for (const pos of positions) {
const idx = pos > 0 ? pos - 1 : len + pos;
if (idx >= 0 && idx < len) {
result.push(dates[idx]);
}
}
return result.sort((a, b) => a.getTime() - b.getTime());
}
// ---------------------------------------------------------------------------
// Advance to the start of the next period
// ---------------------------------------------------------------------------
function advancePeriod(
date: Date,
frequency: CalendarRecurrenceRule['frequency'],
interval: number,
_firstDayOfWeek: string,
): 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);
}
}
// ---------------------------------------------------------------------------
// Jump to the period just before the visible range in O(1), so long-running
// series don't burn the iteration budget on years of out-of-range periods.
// Only valid for rules without `count` (counted rules must enumerate every
// occurrence from the series start).
// ---------------------------------------------------------------------------
function fastForwardToRange(
eventStart: Date,
frequency: CalendarRecurrenceRule['frequency'],
interval: number,
rangeStart: Date,
): Date {
if (eventStart >= rangeStart) return new Date(eventStart);
let periods: number;
switch (frequency) {
case 'secondly':
periods = Math.floor((rangeStart.getTime() - eventStart.getTime()) / (1000 * interval));
break;
case 'minutely':
periods = Math.floor((rangeStart.getTime() - eventStart.getTime()) / (60000 * interval));
break;
case 'hourly':
periods = Math.floor((rangeStart.getTime() - eventStart.getTime()) / (3600000 * interval));
break;
case 'daily':
periods = Math.floor(differenceInCalendarDays(rangeStart, eventStart) / interval);
break;
case 'weekly':
periods = Math.floor(differenceInCalendarDays(rangeStart, eventStart) / (7 * interval));
break;
case 'monthly': {
const months = (rangeStart.getFullYear() - eventStart.getFullYear()) * 12
+ (rangeStart.getMonth() - eventStart.getMonth());
periods = Math.floor(months / interval);
break;
}
case 'yearly':
periods = Math.floor((rangeStart.getFullYear() - eventStart.getFullYear()) / interval);
break;
default:
return new Date(eventStart);
}
// Land one full period early so candidates inside the boundary period
// (which can precede the period anchor) are still generated.
periods = Math.max(0, periods - 1);
if (periods === 0) return new Date(eventStart);
switch (frequency) {
case 'secondly': return new Date(eventStart.getTime() + periods * interval * 1000);
case 'minutely': return new Date(eventStart.getTime() + periods * interval * 60000);
case 'hourly': return new Date(eventStart.getTime() + periods * interval * 3600000);
case 'daily': return addDays(eventStart, periods * interval);
case 'weekly': return addWeeks(eventStart, periods * interval);
case 'monthly': return addMonths(eventStart, periods * interval);
case 'yearly': return addYears(eventStart, periods * interval);
default: return new Date(eventStart);
}
}
// ---------------------------------------------------------------------------
// Helper: expand byDay within a specific month (monthly or yearly context)
// ---------------------------------------------------------------------------
function expandByDayInMonth(
year: number,
month: number,
byDay: CalendarNDay[],
h: number, m: number, s: number,
): Date[] {
const dates: Date[] = [];
for (const { day, nthOfPeriod } of byDay) {
const targetDay = DAY_INDEX[day];
if (targetDay === undefined) continue;
if (nthOfPeriod != null && nthOfPeriod !== 0) {
const d = nthWeekdayOfMonth(year, month, targetDay, nthOfPeriod);
if (d) { d.setHours(h, m, s, 0); 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(h, m, s, 0);
dates.push(occ);
d = addDays(d, 7);
}
}
}
return dates;
}
// ---------------------------------------------------------------------------
// Helper: find nth weekday of month
// ---------------------------------------------------------------------------
function nthWeekdayOfMonth(
year: number,
month: number,
weekday: number,
nth: number,
): Date | null {
if (nth > 0) {
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 {
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;
}
}
// ---------------------------------------------------------------------------
// Helper: check if date is the nth (or nth-last) weekday in its month
// ---------------------------------------------------------------------------
function nthWeekdayInMonth(date: Date, nth: number): number {
if (nth > 0) {
// Count from start: which occurrence of this weekday is it?
return Math.floor((date.getDate() - 1) / 7) + 1;
} else {
// Count from end
const daysInMonth = new Date(date.getFullYear(), date.getMonth() + 1, 0).getDate();
return -(Math.floor((daysInMonth - date.getDate()) / 7) + 1);
}
}
// ---------------------------------------------------------------------------
// Helper: check if date is the nth weekday in its year
// ---------------------------------------------------------------------------
function nthWeekdayInYear(date: Date, nth: number): number {
const yd = getDayOfYear(date);
const weekday = date.getDay();
if (nth > 0) {
// Find first occurrence of this weekday in the year
const jan1 = new Date(date.getFullYear(), 0, 1);
let first = jan1;
while (first.getDay() !== weekday) first = addDays(first, 1);
const firstYd = getDayOfYear(first);
return Math.floor((yd - firstYd) / 7) + 1;
} else {
const dec31 = new Date(date.getFullYear(), 11, 31);
let last = dec31;
while (last.getDay() !== weekday) last = addDays(last, -1);
const lastYd = getDayOfYear(last);
return -(Math.floor((lastYd - yd) / 7) + 1);
}
}
// ---------------------------------------------------------------------------
// Helper: resolve negative/positive byMonthDay to a real date
// ---------------------------------------------------------------------------
function resolveMonthDay(year: number, month: number, day: number): Date | null {
const daysInMonth = new Date(year, month + 1, 0).getDate();
const actualDay = day > 0 ? day : daysInMonth + 1 + day;
if (actualDay < 1 || actualDay > daysInMonth) return null;
return new Date(year, month, actualDay);
}
// ---------------------------------------------------------------------------
// Helper: day of year (1-indexed)
// ---------------------------------------------------------------------------
function getDayOfYear(date: Date): number {
const start = new Date(date.getFullYear(), 0, 0);
const diff = date.getTime() - start.getTime();
return Math.floor(diff / 86400000);
}
// ---------------------------------------------------------------------------
// Helper: day-of-year to Date
// ---------------------------------------------------------------------------
function dayOfYear(year: number, yd: number): Date | null {
const daysInYear = isLeapYear(year) ? 366 : 365;
const actual = yd > 0 ? yd : daysInYear + 1 + yd;
if (actual < 1 || actual > daysInYear) return null;
const d = new Date(year, 0, actual);
return d;
}
function isLeapYear(year: number): boolean {
return (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0;
}
// ---------------------------------------------------------------------------
// ISO week helpers
// ---------------------------------------------------------------------------
function getISOWeekNumber(date: Date): number {
const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()));
d.setUTCDate(d.getUTCDate() + 4 - (d.getUTCDay() || 7));
const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
return Math.ceil(((d.getTime() - yearStart.getTime()) / 86400000 + 1) / 7);
}
function getISOWeeksInYear(year: number): number {
const dec28 = new Date(Date.UTC(year, 11, 28));
return getISOWeekNumber(dec28);
}
function datesInISOWeek(year: number, weekNo: number, _firstDayOfWeek: string): Date[] {
const weeksInYear = getISOWeeksInYear(year);
const actual = weekNo > 0 ? weekNo : weeksInYear + 1 + weekNo;
if (actual < 1 || actual > weeksInYear) return [];
// Find Monday of ISO week 1
const jan4 = new Date(year, 0, 4);
const dayOfWeek = jan4.getDay() || 7; // Monday=1 ... Sunday=7
const week1Monday = addDays(jan4, 1 - dayOfWeek);
const targetMonday = addDays(week1Monday, (actual - 1) * 7);
const dates: Date[] = [];
for (let i = 0; i < 7; i++) {
dates.push(addDays(targetMonday, i));
}
return dates;
}