Files
webmail/lib/recurrence-expansion.ts
T
Linus Rath 34fb9d247a fix: keep daily recurring events going past a DST gap
Daily expansion advanced its period anchor day by day and kept the
time a DST gap pushed it to: 02:30 became 03:30 on the spring-forward
night, and an all-day event's midnight became 01:00 where the change
happens at midnight (Chile, Brazil). Every later day then failed the
implicit byHour, so the series vanished from that night on.

Each daily candidate is now built from the start's wall-clock time on
its day and matched against that time, so the gap day still shows its
occurrence (moved past the gap) and the days after it are back at the
normal time.
2026-09-26 15:45:59 +02:00

881 lines
33 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 { isServerRecurrenceInstance, mergeOverrideParticipants } 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;
occurrences.push(createOccurrence(master, date, recurrenceId, override));
}
}
// Add overrides that define new dates not generated by rules (RDATE equivalent)
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;
// 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;
seenDates.add(dateKey);
occurrences.push(createOccurrence(master, overrideDate, recurrenceId, override));
}
return occurrences;
}
// 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 wallAsUtc = Date.UTC(
wall.getFullYear(), wall.getMonth(), wall.getDate(),
wall.getHours(), wall.getMinutes(), wall.getSeconds(),
);
let guess = wallAsUtc;
for (let i = 0; i < 2; i++) {
guess = wallAsUtc - (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");
// 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;
if (!master.showWithoutTime) {
let durationMs: number | null = null;
if (master.utcStart && master.utcEnd) {
const ms = parseISO(master.utcEnd).getTime() - parseISO(master.utcStart).getTime();
if (!isNaN(ms)) durationMs = ms;
}
// 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 = master.timeZone ? zonedWallTimeToUtc(date, master.timeZone) : null;
if (zoned) {
utcStart = zoned.toISOString();
if (durationMs !== null) {
utcEnd = new Date(zoned.getTime() + durationMs).toISOString();
}
} else if (master.utcStart && master.start) {
// Floating events (or an unrecognized timezone id): keep the master's
// offset, which by definition doesn't vary.
const masterLocal = parseISO(master.start);
const masterUtc = parseISO(master.utcStart);
const offsetMs = masterUtc.getTime() - masterLocal.getTime();
utcStart = new Date(date.getTime() + offsetMs).toISOString();
if (durationMs !== null) {
utcEnd = new Date(date.getTime() + offsetMs + durationMs).toISOString();
}
}
}
return {
...master,
...(override || {}),
...(override?.participants ? { participants: mergeOverrideParticipants(master.participants, override.participants) } : {}),
id: `${master.id}:${recurrenceId}`,
originalId: master.originalId || master.id,
uid: master.uid,
calendarIds: master.calendarIds,
start: (override?.start) || startStr,
...(utcStart && !override?.utcStart ? { utcStart } : {}),
...(utcEnd && !override?.utcEnd ? { utcEnd } : {}),
recurrenceId,
recurrenceRules: master.recurrenceRules,
recurrenceOverrides: master.recurrenceOverrides,
excludedRecurrenceRules: master.excludedRecurrenceRules,
};
}
// ---------------------------------------------------------------------------
// §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':
// The start's wall-clock time on this day. The period anchor is
// advanced day by day and keeps whatever time a DST gap pushed it to
// (02:30 -> 03:30, or midnight -> 01:00 for an all-day event), so using
// it made every later day fail the implicit byHour and the series
// stopped at the gap.
candidates = [new Date(
periodStart.getFullYear(), periodStart.getMonth(), periodStart.getDate(),
eventStart.getHours(), eventStart.getMinutes(), eventStart.getSeconds(), eventStart.getMilliseconds(),
)];
break;
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. A daily
// candidate is judged by the time it was built for: on the day of a DST
// gap that time does not exist and the occurrence moves past the gap
// (RFC 5545 3.3.5), which is still this occurrence.
candidates = candidates.filter(d => matchesByX(d, rule, freq === 'daily' ? eventStart : undefined));
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, timeOf: Date = date): 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(timeOf.getHours())) return false;
}
if (rule.byMinute?.length) {
if (!rule.byMinute.includes(timeOf.getMinutes())) return false;
}
if (rule.bySecond?.length) {
if (!rule.bySecond.includes(timeOf.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;
}