101 lines
3.7 KiB
TypeScript
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);
|
|
}
|