feat: add shared recurrence helpers
This commit is contained in:
238
src/lib/recurrence.ts
Normal file
238
src/lib/recurrence.ts
Normal file
@@ -0,0 +1,238 @@
|
||||
import { format, parseISO } from "date-fns";
|
||||
import { RRule, rrulestr } from "rrule";
|
||||
import type { Frequency, Weekday } from "@/lib/rfc5545-types";
|
||||
|
||||
export type SupportedRecurrenceFrequency = "NONE" | "DAILY" | "WEEKLY" | "MONTHLY";
|
||||
|
||||
export interface RecurrenceFormValue {
|
||||
freq: SupportedRecurrenceFrequency;
|
||||
interval: number;
|
||||
byDay: Weekday[];
|
||||
count?: number;
|
||||
until?: string;
|
||||
}
|
||||
|
||||
export interface RecurrenceValidationResult {
|
||||
isValid: boolean;
|
||||
errors: Partial<Record<"count" | "until" | "rule", string>>;
|
||||
}
|
||||
|
||||
const EMPTY_RECURRENCE: RecurrenceFormValue = {
|
||||
freq: "NONE",
|
||||
interval: 1,
|
||||
byDay: [],
|
||||
};
|
||||
|
||||
const RULE_FREQUENCIES: Record<Exclude<SupportedRecurrenceFrequency, "NONE">, number> = {
|
||||
DAILY: RRule.DAILY,
|
||||
WEEKLY: RRule.WEEKLY,
|
||||
MONTHLY: RRule.MONTHLY,
|
||||
};
|
||||
|
||||
const WEEKDAY_ORDER: Weekday[] = ["MO", "TU", "WE", "TH", "FR", "SA", "SU"];
|
||||
|
||||
const RULE_WEEKDAYS = {
|
||||
MO: RRule.MO,
|
||||
TU: RRule.TU,
|
||||
WE: RRule.WE,
|
||||
TH: RRule.TH,
|
||||
FR: RRule.FR,
|
||||
SA: RRule.SA,
|
||||
SU: RRule.SU,
|
||||
};
|
||||
|
||||
const FREQUENCY_LABELS: Record<number, SupportedRecurrenceFrequency> = {
|
||||
[RRule.DAILY]: "DAILY",
|
||||
[RRule.WEEKLY]: "WEEKLY",
|
||||
[RRule.MONTHLY]: "MONTHLY",
|
||||
};
|
||||
|
||||
const SUPPORTED_FREQUENCIES = new Set<SupportedRecurrenceFrequency>([
|
||||
"NONE",
|
||||
"DAILY",
|
||||
"WEEKLY",
|
||||
"MONTHLY",
|
||||
]);
|
||||
|
||||
const sortWeekdays = (days: string[] | undefined): Weekday[] => {
|
||||
if (!days?.length) return [];
|
||||
return [...days]
|
||||
.filter((day): day is Weekday => WEEKDAY_ORDER.includes(day as Weekday))
|
||||
.sort((left, right) => WEEKDAY_ORDER.indexOf(left) - WEEKDAY_ORDER.indexOf(right));
|
||||
};
|
||||
|
||||
const toUntilDate = (until: string): Date => {
|
||||
const parsed = parseISO(`${until}T23:59:59`);
|
||||
return parsed;
|
||||
};
|
||||
|
||||
const toRRuleOptions = (value: RecurrenceFormValue, dtstart?: string) => {
|
||||
if (value.freq === "NONE") return null;
|
||||
|
||||
const options: ConstructorParameters<typeof RRule>[0] = {
|
||||
freq: RULE_FREQUENCIES[value.freq],
|
||||
interval: value.interval,
|
||||
};
|
||||
|
||||
if (dtstart) {
|
||||
options.dtstart = parseISO(dtstart);
|
||||
}
|
||||
|
||||
if (value.freq === "WEEKLY" && value.byDay.length > 0) {
|
||||
options.byweekday = value.byDay.map((day) => RULE_WEEKDAYS[day]);
|
||||
}
|
||||
|
||||
if (value.count) options.count = value.count;
|
||||
if (value.until) options.until = toUntilDate(value.until);
|
||||
|
||||
return options;
|
||||
};
|
||||
|
||||
export const getDefaultRecurrence = (): RecurrenceFormValue => ({
|
||||
...EMPTY_RECURRENCE,
|
||||
});
|
||||
|
||||
export const validateRecurrence = (
|
||||
value: RecurrenceFormValue,
|
||||
): RecurrenceValidationResult => {
|
||||
const errors: RecurrenceValidationResult["errors"] = {};
|
||||
|
||||
if (!SUPPORTED_FREQUENCIES.has(value.freq)) {
|
||||
errors.rule = "Unsupported recurrence frequency.";
|
||||
}
|
||||
|
||||
if (!Number.isInteger(value.interval) || value.interval < 1) {
|
||||
errors.rule = "Interval must be at least 1.";
|
||||
}
|
||||
|
||||
if (value.count !== undefined && (!Number.isInteger(value.count) || value.count < 1)) {
|
||||
errors.count = "Count must be a whole number greater than 0.";
|
||||
}
|
||||
|
||||
if (value.until) {
|
||||
const parsed = parseISO(value.until);
|
||||
if (Number.isNaN(parsed.getTime())) {
|
||||
errors.until = "Until must be a valid date.";
|
||||
}
|
||||
}
|
||||
|
||||
if (value.count !== undefined && value.until) {
|
||||
errors.rule = "Choose either count or until, not both.";
|
||||
}
|
||||
|
||||
if (value.freq === "WEEKLY" && value.byDay.length === 0) {
|
||||
errors.rule = "Choose at least one weekday for weekly recurrence.";
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: Object.keys(errors).length === 0,
|
||||
errors,
|
||||
};
|
||||
};
|
||||
|
||||
export const parseRecurrenceRule = (rule?: string): RecurrenceFormValue => {
|
||||
if (!rule) return getDefaultRecurrence();
|
||||
|
||||
const parsed = rrulestr(`RRULE:${rule}`) as RRule;
|
||||
const options = parsed.origOptions;
|
||||
const frequency = FREQUENCY_LABELS[options.freq ?? -1] ?? "NONE";
|
||||
|
||||
if (!SUPPORTED_FREQUENCIES.has(frequency)) {
|
||||
return getDefaultRecurrence();
|
||||
}
|
||||
|
||||
const byweekday = Array.isArray(options.byweekday)
|
||||
? options.byweekday.map((weekday) => weekday.toString())
|
||||
: options.byweekday
|
||||
? [options.byweekday.toString()]
|
||||
: [];
|
||||
|
||||
return {
|
||||
freq: frequency,
|
||||
interval: options.interval ?? 1,
|
||||
byDay: sortWeekdays(byweekday),
|
||||
count: options.count ?? undefined,
|
||||
until: options.until ? format(options.until, "yyyy-MM-dd") : undefined,
|
||||
};
|
||||
};
|
||||
|
||||
export const serializeRecurrenceRule = (
|
||||
value: RecurrenceFormValue,
|
||||
dtstart?: string,
|
||||
): string | undefined => {
|
||||
if (value.freq === "NONE") return undefined;
|
||||
|
||||
const validation = validateRecurrence(value);
|
||||
if (!validation.isValid) {
|
||||
throw new Error(validation.errors.rule || validation.errors.count || validation.errors.until || "Invalid recurrence.");
|
||||
}
|
||||
|
||||
const options = toRRuleOptions(value, dtstart);
|
||||
if (!options) return undefined;
|
||||
|
||||
const rule = new RRule(options);
|
||||
const ruleLine = rule.toString().split("\n").find((line) => line.startsWith("RRULE:"));
|
||||
if (!ruleLine) return undefined;
|
||||
|
||||
const entries = ruleLine.replace(/^RRULE:/, "").split(";");
|
||||
const orderedKeys = ["FREQ", "INTERVAL", "UNTIL", "COUNT", "BYDAY"];
|
||||
const sorted = [...entries].sort((left, right) => {
|
||||
const leftKey = left.split("=")[0] ?? "";
|
||||
const rightKey = right.split("=")[0] ?? "";
|
||||
const leftIndex = orderedKeys.indexOf(leftKey);
|
||||
const rightIndex = orderedKeys.indexOf(rightKey);
|
||||
const normalizedLeftIndex = leftIndex === -1 ? Number.MAX_SAFE_INTEGER : leftIndex;
|
||||
const normalizedRightIndex = rightIndex === -1 ? Number.MAX_SAFE_INTEGER : rightIndex;
|
||||
return normalizedLeftIndex - normalizedRightIndex || leftKey.localeCompare(rightKey);
|
||||
});
|
||||
|
||||
return sorted.join(";");
|
||||
};
|
||||
|
||||
export const getRecurrencePreview = (
|
||||
value: RecurrenceFormValue,
|
||||
dtstart?: string,
|
||||
limit = 3,
|
||||
): string[] => {
|
||||
if (!dtstart || value.freq === "NONE") return [];
|
||||
|
||||
const options = toRRuleOptions(value, dtstart);
|
||||
if (!options) return [];
|
||||
|
||||
const validation = validateRecurrence(value);
|
||||
if (!validation.isValid) return [];
|
||||
|
||||
const rule = new RRule(options);
|
||||
return rule
|
||||
.all((_, index) => index < limit)
|
||||
.map((date) => date.toISOString());
|
||||
};
|
||||
|
||||
export const formatRecurrenceText = (rule?: string): string | null => {
|
||||
if (!rule) return null;
|
||||
return rrulestr(`RRULE:${rule}`).toText();
|
||||
};
|
||||
|
||||
export const getWeekdayOptions = (): Array<{ value: Weekday; label: string }> => [
|
||||
{ value: "MO", label: "Mon" },
|
||||
{ value: "TU", label: "Tue" },
|
||||
{ value: "WE", label: "Wed" },
|
||||
{ value: "TH", label: "Thu" },
|
||||
{ value: "FR", label: "Fri" },
|
||||
{ value: "SA", label: "Sat" },
|
||||
{ value: "SU", label: "Sun" },
|
||||
];
|
||||
|
||||
export const isWeekdayPreset = (value: RecurrenceFormValue): boolean =>
|
||||
value.freq === "WEEKLY" &&
|
||||
value.interval === 1 &&
|
||||
value.byDay.join(",") === ["MO", "TU", "WE", "TH", "FR"].join(",");
|
||||
|
||||
export const recurrenceFrequencyLabels: Record<SupportedRecurrenceFrequency, string> = {
|
||||
NONE: "Does not repeat",
|
||||
DAILY: "Daily",
|
||||
WEEKLY: "Weekly",
|
||||
MONTHLY: "Monthly",
|
||||
};
|
||||
|
||||
export type { Frequency, Weekday };
|
||||
Reference in New Issue
Block a user