import { DateTime } from "luxon"; export type RecurrenceKind = "none" | "daily" | "weekly" | "monthly" | "yearly" | "cron"; export type EndKind = "never" | "after" | "on"; const CRON_PREFIX = "CRON:"; const WEEKDAY_CODES = ["MO", "TU", "WE", "TH", "FR", "SA", "SU"] as const; export const WEEKDAY_LABELS: Array<{ iso: number; code: string; short: string; long: string }> = [ { iso: 1, code: "MO", short: "Mon", long: "Monday" }, { iso: 2, code: "TU", short: "Tue", long: "Tuesday" }, { iso: 3, code: "WE", short: "Wed", long: "Wednesday" }, { iso: 4, code: "TH", short: "Thu", long: "Thursday" }, { iso: 5, code: "FR", short: "Fri", long: "Friday" }, { iso: 6, code: "SA", short: "Sat", long: "Saturday" }, { iso: 7, code: "SU", short: "Sun", long: "Sunday" }, ]; export interface RecurrenceSpec { kind: RecurrenceKind; /** Every N units. Defaults to 1. Ignored for `none` and `cron`. */ interval: number; /** ISO weekday numbers (1=Mon..7=Sun). Used for `weekly`. */ weeklyDays: number[]; /** Day-of-month for `monthly` (1-31). If omitted, falls back to firstFire.day. */ monthDay?: number; /** Cron expression — only meaningful when kind === "cron". */ cron?: string; /** End condition. */ end: | { kind: "never" } | { kind: "after"; count: number } | { kind: "on"; until: string /* ISO date YYYY-MM-DD */ }; } export const DEFAULT_RECURRENCE: RecurrenceSpec = { kind: "none", interval: 1, weeklyDays: [], end: { kind: "never" }, }; function clampInterval(n: number): number { if (!Number.isFinite(n) || n < 1) return 1; return Math.floor(n); } /** * Build an RRULE string. Supports interval, weekday list, monthday, and the * end condition (COUNT or UNTIL). Returns null for one-off reminders. */ export function buildRrule(spec: RecurrenceSpec, firstFire: DateTime): string | null { if (spec.kind === "none") return null; if (spec.kind === "cron") { return spec.cron ? `${CRON_PREFIX}${spec.cron.trim()}` : null; } const parts: string[] = []; switch (spec.kind) { case "daily": parts.push("FREQ=DAILY"); break; case "weekly": { parts.push("FREQ=WEEKLY"); const days = spec.weeklyDays.length > 0 ? spec.weeklyDays : [firstFire.weekday]; const codes = days .slice() .sort((a, b) => a - b) .map((d) => WEEKDAY_CODES[d - 1]) .filter(Boolean); parts.push(`BYDAY=${codes.join(",")}`); break; } case "monthly": parts.push("FREQ=MONTHLY"); parts.push(`BYMONTHDAY=${spec.monthDay ?? firstFire.day}`); break; case "yearly": parts.push("FREQ=YEARLY"); parts.push(`BYMONTH=${firstFire.month}`); parts.push(`BYMONTHDAY=${firstFire.day}`); break; } const interval = clampInterval(spec.interval); if (interval !== 1) parts.push(`INTERVAL=${interval}`); if (spec.end.kind === "after" && spec.end.count > 0) { parts.push(`COUNT=${Math.floor(spec.end.count)}`); } else if (spec.end.kind === "on" && spec.end.until) { // RRULE UNTIL is a UTC timestamp. Translate the user's "on this date" // into 23:59:59 UTC of that day so the last occurrence is included. const dt = DateTime.fromISO(`${spec.end.until}T23:59:59`, { zone: "utc" }); if (dt.isValid) { parts.push(`UNTIL=${dt.toFormat("yyyyMMdd'T'HHmmss'Z'")}`); } } return parts.join(";"); } const FREQ_UNIT: Record = { daily: "day", weekly: "week", monthly: "month", yearly: "year", }; /** * Render the spec as a human sentence, e.g. * "Every day" * "Every 2 weeks on Mon, Wed, Fri" * "Every month on day 14, 12 times" * "Every year on May 13, until 2027-05-13" */ export function describeRecurrence(spec: RecurrenceSpec, firstFire: DateTime): string { if (spec.kind === "none") return "One-off"; if (spec.kind === "cron") { return spec.cron ? `Cron: ${spec.cron}` : "Cron (not configured)"; } const interval = clampInterval(spec.interval); const unit = FREQ_UNIT[spec.kind]!; const head = interval === 1 ? `Every ${unit}` : `Every ${interval} ${unit}s`; let body = ""; if (spec.kind === "weekly") { const days = spec.weeklyDays.length > 0 ? spec.weeklyDays : [firstFire.weekday]; const labels = days .slice() .sort((a, b) => a - b) .map((d) => WEEKDAY_LABELS[d - 1]?.short) .filter(Boolean) .join(", "); body = ` on ${labels}`; } else if (spec.kind === "monthly") { body = ` on day ${spec.monthDay ?? firstFire.day}`; } else if (spec.kind === "yearly") { body = ` on ${firstFire.toFormat("MMM d")}`; } let tail = ""; if (spec.end.kind === "after" && spec.end.count > 0) { tail = `, ${Math.floor(spec.end.count)} time${spec.end.count === 1 ? "" : "s"}`; } else if (spec.end.kind === "on" && spec.end.until) { tail = `, until ${spec.end.until}`; } return head + body + tail; } /** Parse a stored RRULE back into a spec for resuming the wizard / editing. */ export function specFromRrule(rrule: string | null | undefined): RecurrenceSpec { if (!rrule) return { ...DEFAULT_RECURRENCE }; if (rrule.startsWith(CRON_PREFIX)) { return { kind: "cron", interval: 1, weeklyDays: [], cron: rrule.slice(CRON_PREFIX.length), end: { kind: "never" }, }; } const tokens = rrule .split(";") .map((t) => t.trim()) .filter(Boolean) .reduce>((acc, t) => { const [k, v] = t.split("="); if (k && v !== undefined) acc[k.toUpperCase()] = v; return acc; }, {}); const freq = (tokens.FREQ ?? "").toUpperCase(); let kind: RecurrenceKind = "none"; if (freq === "DAILY") kind = "daily"; else if (freq === "WEEKLY") kind = "weekly"; else if (freq === "MONTHLY") kind = "monthly"; else if (freq === "YEARLY") kind = "yearly"; const interval = tokens.INTERVAL ? clampInterval(Number(tokens.INTERVAL)) : 1; const weeklyDays: number[] = []; if (tokens.BYDAY) { for (const code of tokens.BYDAY.split(",")) { const idx = WEEKDAY_CODES.indexOf(code.toUpperCase() as (typeof WEEKDAY_CODES)[number]); if (idx >= 0) weeklyDays.push(idx + 1); } } const monthDay = tokens.BYMONTHDAY ? Number(tokens.BYMONTHDAY) || undefined : undefined; let end: RecurrenceSpec["end"] = { kind: "never" }; if (tokens.COUNT) { const n = Number(tokens.COUNT); if (Number.isFinite(n) && n > 0) end = { kind: "after", count: Math.floor(n) }; } else if (tokens.UNTIL) { // UNTIL is `YYYYMMDDTHHMMSSZ` per RFC. Pull the date. const m = tokens.UNTIL.match(/^(\d{4})(\d{2})(\d{2})/); if (m) end = { kind: "on", until: `${m[1]}-${m[2]}-${m[3]}` }; } return { kind, interval, weeklyDays, monthDay, end }; } /** Backwards-compatible helper for callers that only need the kind. */ export function kindFromRrule(rrule: string | null | undefined): RecurrenceKind { return specFromRrule(rrule).kind; } /** Map ISO weekday (1=Mon..7=Sun) → cron weekday (0=Sun..6=Sat). */ export function isoWeekdayToCron(iso: number): number { return iso === 7 ? 0 : iso; }