managarten/apps/calendar/packages/shared/src/utils/recurrence.ts
2025-12-03 23:42:37 +01:00

277 lines
7.1 KiB
TypeScript

import type { RecurrencePattern, RecurrenceFrequency, Weekday } from '../types/recurrence';
import { addDays, addMonths, addYears } from './date';
/**
* Parse an RFC 5545 RRULE string to a RecurrencePattern object
*
* Example: "FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,WE,FR;UNTIL=20241231T235959Z"
*/
export function parseRRule(rrule: string): RecurrencePattern | null {
if (!rrule || !rrule.startsWith('FREQ=')) {
return null;
}
const parts = rrule.split(';');
const pattern: RecurrencePattern = {
frequency: 'DAILY',
};
for (const part of parts) {
const [key, value] = part.split('=');
switch (key) {
case 'FREQ':
if (['DAILY', 'WEEKLY', 'MONTHLY', 'YEARLY'].includes(value)) {
pattern.frequency = value as RecurrenceFrequency;
}
break;
case 'INTERVAL':
pattern.interval = parseInt(value, 10);
break;
case 'BYDAY':
pattern.byDay = value.split(',') as Weekday[];
break;
case 'BYMONTHDAY':
pattern.byMonthDay = value.split(',').map((d) => parseInt(d, 10));
break;
case 'BYMONTH':
pattern.byMonth = value.split(',').map((m) => parseInt(m, 10));
break;
case 'COUNT':
pattern.count = parseInt(value, 10);
break;
case 'UNTIL': {
// Parse UNTIL date (format: YYYYMMDD or YYYYMMDDTHHMMSSZ)
const year = parseInt(value.substring(0, 4), 10);
const month = parseInt(value.substring(4, 6), 10) - 1;
const day = parseInt(value.substring(6, 8), 10);
pattern.until = new Date(year, month, day, 23, 59, 59);
break;
}
}
}
return pattern;
}
/**
* Format a RecurrencePattern object to an RFC 5545 RRULE string
*/
export function formatRRule(pattern: RecurrencePattern): string {
const parts: string[] = [`FREQ=${pattern.frequency}`];
if (pattern.interval && pattern.interval > 1) {
parts.push(`INTERVAL=${pattern.interval}`);
}
if (pattern.byDay && pattern.byDay.length > 0) {
parts.push(`BYDAY=${pattern.byDay.join(',')}`);
}
if (pattern.byMonthDay && pattern.byMonthDay.length > 0) {
parts.push(`BYMONTHDAY=${pattern.byMonthDay.join(',')}`);
}
if (pattern.byMonth && pattern.byMonth.length > 0) {
parts.push(`BYMONTH=${pattern.byMonth.join(',')}`);
}
if (pattern.count) {
parts.push(`COUNT=${pattern.count}`);
}
if (pattern.until) {
const date = new Date(pattern.until);
const until = `${date.getFullYear()}${String(date.getMonth() + 1).padStart(2, '0')}${String(date.getDate()).padStart(2, '0')}T235959Z`;
parts.push(`UNTIL=${until}`);
}
return parts.join(';');
}
/**
* Get human-readable description of a recurrence pattern
*/
export function describeRecurrence(pattern: RecurrencePattern | null): string {
if (!pattern) {
return 'Does not repeat';
}
const interval = pattern.interval || 1;
switch (pattern.frequency) {
case 'DAILY':
if (interval === 1) return 'Daily';
return `Every ${interval} days`;
case 'WEEKLY':
if (pattern.byDay && pattern.byDay.length > 0) {
if (
pattern.byDay.length === 5 &&
!pattern.byDay.includes('SA') &&
!pattern.byDay.includes('SU')
) {
return interval === 1 ? 'Every weekday' : `Every ${interval} weeks on weekdays`;
}
const days = pattern.byDay.map(dayToLabel).join(', ');
return interval === 1 ? `Weekly on ${days}` : `Every ${interval} weeks on ${days}`;
}
return interval === 1 ? 'Weekly' : `Every ${interval} weeks`;
case 'MONTHLY':
if (pattern.byMonthDay && pattern.byMonthDay.length > 0) {
const days = pattern.byMonthDay.join(', ');
return interval === 1
? `Monthly on day ${days}`
: `Every ${interval} months on day ${days}`;
}
return interval === 1 ? 'Monthly' : `Every ${interval} months`;
case 'YEARLY':
return interval === 1 ? 'Yearly' : `Every ${interval} years`;
default:
return 'Custom repeat';
}
}
/**
* Convert weekday code to label
*/
function dayToLabel(day: Weekday): string {
const labels: Record<Weekday, string> = {
MO: 'Mon',
TU: 'Tue',
WE: 'Wed',
TH: 'Thu',
FR: 'Fri',
SA: 'Sat',
SU: 'Sun',
};
return labels[day];
}
/**
* Convert JavaScript day (0-6) to Weekday
*/
export function jsDateToWeekday(jsDay: number): Weekday {
const weekdays: Weekday[] = ['SU', 'MO', 'TU', 'WE', 'TH', 'FR', 'SA'];
return weekdays[jsDay];
}
/**
* Convert Weekday to JavaScript day (0-6)
*/
export function weekdayToJsDate(weekday: Weekday): number {
const weekdays: Weekday[] = ['SU', 'MO', 'TU', 'WE', 'TH', 'FR', 'SA'];
return weekdays.indexOf(weekday);
}
/**
* Generate occurrences of a recurring event within a date range
*
* @param startDate - The start date of the first occurrence
* @param pattern - The recurrence pattern
* @param rangeStart - Start of the date range to generate occurrences for
* @param rangeEnd - End of the date range
* @param exceptions - Dates to exclude (as ISO strings)
* @param maxOccurrences - Maximum number of occurrences to generate
*/
export function generateOccurrences(
startDate: Date,
pattern: RecurrencePattern,
rangeStart: Date,
rangeEnd: Date,
exceptions: string[] = [],
maxOccurrences = 365
): Date[] {
const occurrences: Date[] = [];
const exceptionsSet = new Set(exceptions);
const interval = pattern.interval || 1;
let currentDate = new Date(startDate);
let count = 0;
// Get the maximum end date (either pattern.until or rangeEnd)
const maxDate = pattern.until
? new Date(Math.min(new Date(pattern.until).getTime(), rangeEnd.getTime()))
: rangeEnd;
while (currentDate <= maxDate && count < maxOccurrences) {
// Check if we've exceeded COUNT
if (pattern.count && occurrences.length >= pattern.count) {
break;
}
// Check if this date matches the pattern
if (matchesPattern(currentDate, pattern)) {
// Check if date is in range and not in exceptions
if (
currentDate >= rangeStart &&
!exceptionsSet.has(currentDate.toISOString().split('T')[0])
) {
occurrences.push(new Date(currentDate));
}
}
// Move to next potential occurrence
currentDate = getNextOccurrence(currentDate, pattern.frequency, interval);
count++;
}
return occurrences;
}
/**
* Check if a date matches the recurrence pattern constraints
*/
function matchesPattern(date: Date, pattern: RecurrencePattern): boolean {
// Check BYDAY
if (pattern.byDay && pattern.byDay.length > 0) {
const dayOfWeek = jsDateToWeekday(date.getDay());
if (!pattern.byDay.includes(dayOfWeek)) {
return false;
}
}
// Check BYMONTHDAY
if (pattern.byMonthDay && pattern.byMonthDay.length > 0) {
if (!pattern.byMonthDay.includes(date.getDate())) {
return false;
}
}
// Check BYMONTH
if (pattern.byMonth && pattern.byMonth.length > 0) {
if (!pattern.byMonth.includes(date.getMonth() + 1)) {
return false;
}
}
return true;
}
/**
* Get the next occurrence date based on frequency
*/
function getNextOccurrence(date: Date, frequency: RecurrenceFrequency, interval: number): Date {
switch (frequency) {
case 'DAILY':
return addDays(date, interval);
case 'WEEKLY':
// For weekly, always move by 1 day to check each day of the week
return addDays(date, 1);
case 'MONTHLY':
return addMonths(date, interval);
case 'YEARLY':
return addYears(date, interval);
default:
return addDays(date, 1);
}
}