/** * 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 = { 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(); 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; } /** * How many occurrences `rule` gives a series that starts at `start` before * `before`, both LocalDateTimes on the series' own wall clock, its start * included: how much of the rule's COUNT the part of the series before * `before` uses up (the start counts as the first occurrence, RFC 5545). */ export function occurrencesBefore(start: string, rule: CalendarRecurrenceRule, before: string): number { const from = parseISO(start); const until = parseISO(before); if (isNaN(from.getTime()) || isNaN(until.getTime()) || until <= from) return 0; return generateDates(from, rule, from, until, true, Infinity).length; } /** * Whether a series that starts at `start` and repeats by `rules` has an * occurrence at `recurrenceId`, both LocalDateTimes on its own wall clock. * Its start always is one. */ export function seriesOccursAt( start: string, rules: CalendarRecurrenceRule[] | null | undefined, recurrenceId: string, ): boolean { const from = parseISO(start); const at = parseISO(recurrenceId); if (isNaN(from.getTime()) || isNaN(at.getTime())) return false; if (at.getTime() === from.getTime()) return true; const until = new Date(at.getTime() + 1000); return (rules ?? []).some((rule) => generateDates(from, rule, at, until).some((date) => date.getTime() === at.getTime())); } 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(); // RFC 8984 §4.3.3: occurrences produced by excludedRecurrenceRules // (iCalendar EXRULE) are removed from the recurrence set. const excludedDates = new Set(); 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 & { 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 & { 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 | 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, ): { 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(); 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 = {}; 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 { 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) : 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, maxOccurrences = 500, ): 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; // `maxOccurrences` caps the *emitted* (in-range) dates. It 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. 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; }