import { RRule, type Options, type Weekday } from "rrule"; import type { RecurrenceInput } from "@/lib/validation/scheduled-todo"; const FREQUENCY_TO_RRULE: Record = { DAILY: RRule.DAILY, WEEKLY: RRule.WEEKLY, MONTHLY: RRule.MONTHLY, YEARLY: RRule.YEARLY, }; const RRULE_TO_FREQUENCY: Partial> = { [RRule.DAILY]: "DAILY", [RRule.WEEKLY]: "WEEKLY", [RRule.MONTHLY]: "MONTHLY", [RRule.YEARLY]: "YEARLY", }; // rrule's own Weekday constants, indexed here to match JS's // Date#getUTCDay() (0 = Sun .. 6 = Sat) -- rrule internally numbers MO=0 // .. SU=6 instead, which is what Weekday#getJsWeekday() converts back from. const WEEKDAYS_BY_JS_DAY = [RRule.SU, RRule.MO, RRule.TU, RRule.WE, RRule.TH, RRule.FR, RRule.SA]; /** * Rebuilds an RFC 5545 RRULE string from validated recurrence parts. This * is the only path that should ever produce a stored `ScheduledTodo.rrule` * value -- never trust a client-supplied RRULE string directly. The * to-do's own `startDate` column is the anchor/DTSTART and is deliberately * left out of this string (see expandOccurrences below). */ export function buildRRuleString(recurrence: RecurrenceInput): string { const options: Partial = { freq: FREQUENCY_TO_RRULE[recurrence.frequency], interval: recurrence.interval, }; if (recurrence.frequency === "WEEKLY" && recurrence.daysOfWeek?.length) { options.byweekday = recurrence.daysOfWeek.map((d) => WEEKDAYS_BY_JS_DAY[d]); } if ( (recurrence.frequency === "MONTHLY" || recurrence.frequency === "YEARLY") && recurrence.dayOfMonth ) { options.bymonthday = recurrence.dayOfMonth; } if (recurrence.frequency === "YEARLY" && recurrence.month) { options.bymonth = recurrence.month; } if (recurrence.end.type === "until") options.until = new Date(recurrence.end.date); if (recurrence.end.type === "count") options.count = recurrence.end.count; return RRule.optionsToString(options); } /** Reverses buildRRuleString, to pre-fill the edit dialog from a stored rule. */ export function parseRRuleString(rrule: string): RecurrenceInput { const parsed = RRule.parseString(rrule); const frequency = RRULE_TO_FREQUENCY[parsed.freq ?? RRule.DAILY] ?? "DAILY"; const daysOfWeek = Array.isArray(parsed.byweekday) ? parsed.byweekday .map((w) => (typeof w === "number" ? undefined : (w as Weekday).getJsWeekday())) .filter((d): d is number => d !== undefined) : undefined; const dayOfMonth = Array.isArray(parsed.bymonthday) ? parsed.bymonthday[0] : parsed.bymonthday; const month = Array.isArray(parsed.bymonth) ? parsed.bymonth[0] : parsed.bymonth; return { frequency, interval: parsed.interval ?? 1, daysOfWeek, dayOfMonth: dayOfMonth ?? undefined, month: month ?? undefined, end: parsed.count ? { type: "count", count: parsed.count } : parsed.until ? { type: "until", date: parsed.until.toISOString().slice(0, 10) } : { type: "never" }, }; } /** * Every occurrence date of a ScheduledTodo within [windowStart, windowEnd] * (inclusive), combining the stored `rrule` (if any) with its own * `startDate` as DTSTART. A null rrule means a one-time to-do: its only * occurrence is startDate itself. */ export function expandOccurrences( startDate: Date, rrule: string | null, windowStart: Date, windowEnd: Date ): Date[] { if (!rrule) { return startDate >= windowStart && startDate <= windowEnd ? [startDate] : []; } const parsed = RRule.parseString(rrule); const rule = new RRule({ ...parsed, dtstart: startDate }); return rule.between(windowStart, windowEnd, true); }