import { RRule, rrulestr } from "rrule"; import { CalendarEvent, ExpandedEvent } from "@calchat/shared"; // Convert local time to "fake UTC" for rrule // rrule interprets all dates as UTC internally, so we need to trick it function toRRuleDate(date: Date): Date { return new Date( Date.UTC( date.getFullYear(), date.getMonth(), date.getDate(), date.getHours(), date.getMinutes(), date.getSeconds(), ), ); } // Convert rrule result back to local time function fromRRuleDate(date: Date): Date { return new Date( date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate(), date.getUTCHours(), date.getUTCMinutes(), date.getUTCSeconds(), ); } /** * Expand recurring events into individual occurrences within a date range. * Non-recurring events are returned as-is with occurrenceStart/End = startTime/endTime. */ export function expandRecurringEvents( events: CalendarEvent[], rangeStart: Date, rangeEnd: Date, ): ExpandedEvent[] { const expanded: ExpandedEvent[] = []; for (const event of events) { const startTime = new Date(event.startTime); const endTime = new Date(event.endTime); const duration = endTime.getTime() - startTime.getTime(); if (!event.isRecurring || !event.recurrenceRule) { // Non-recurring event: add as-is if within range if (startTime >= rangeStart && startTime <= rangeEnd) { expanded.push({ ...event, occurrenceStart: startTime, occurrenceEnd: endTime, }); } continue; } // Recurring event: parse RRULE and expand try { // Strip RRULE: prefix if present (AI may include it) const ruleString = event.recurrenceRule.replace(/^RRULE:/i, ""); const rule = rrulestr( `DTSTART:${formatRRuleDateString(startTime)}\nRRULE:${ruleString}`, ); // Get occurrences within the range (using fake UTC dates) const occurrences = rule.between( toRRuleDate(rangeStart), toRRuleDate(rangeEnd), true, // inclusive ); for (const occurrence of occurrences) { const occurrenceStart = fromRRuleDate(occurrence); const occurrenceEnd = new Date(occurrenceStart.getTime() + duration); expanded.push({ ...event, occurrenceStart, occurrenceEnd, }); } } catch (error) { // If RRULE parsing fails, include the event as a single occurrence console.error( `Failed to parse recurrence rule for event ${event.id}:`, error, ); expanded.push({ ...event, occurrenceStart: startTime, occurrenceEnd: endTime, }); } } // Sort by occurrence start time expanded.sort( (a, b) => a.occurrenceStart.getTime() - b.occurrenceStart.getTime(), ); return expanded; } // Format date as RRULE DTSTART string (YYYYMMDDTHHMMSS) function formatRRuleDateString(date: Date): string { const year = date.getFullYear(); const month = String(date.getMonth() + 1).padStart(2, "0"); const day = String(date.getDate()).padStart(2, "0"); const hours = String(date.getHours()).padStart(2, "0"); const minutes = String(date.getMinutes()).padStart(2, "0"); const seconds = String(date.getSeconds()).padStart(2, "0"); return `${year}${month}${day}T${hours}${minutes}${seconds}`; }