Files
calchat/apps/server/src/utils/recurrenceExpander.ts
Linus Waldowsky 489c0271c9 refactor: rename package scope from @caldav to @calchat
Rename all workspace packages to reflect the actual project name:
- @caldav/client -> @calchat/client
- @caldav/server -> @calchat/server
- @caldav/shared -> @calchat/shared
- Root package: caldav-mono -> calchat-mono

Update all import statements across client and server to use the new
package names. Update default MongoDB database name and logging service
identifier accordingly.
2026-01-12 19:46:53 +01:00

116 lines
3.3 KiB
TypeScript

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}`;
}