mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-19 00:21:22 +02:00
277 lines
7.1 KiB
TypeScript
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);
|
|
}
|
|
}
|