Organize/lib/rrule-utils.ts

101 lines
3.7 KiB
TypeScript

import { RRule, type Options, type Weekday } from "rrule";
import type { RecurrenceInput } from "@/lib/validation/scheduled-todo";
const FREQUENCY_TO_RRULE: Record<RecurrenceInput["frequency"], Options["freq"]> = {
DAILY: RRule.DAILY,
WEEKLY: RRule.WEEKLY,
MONTHLY: RRule.MONTHLY,
YEARLY: RRule.YEARLY,
};
const RRULE_TO_FREQUENCY: Partial<Record<Options["freq"], RecurrenceInput["frequency"]>> = {
[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<Options> = {
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);
}