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; } // --------------------------------------------------------------------------- // Preset shortcuts for the Repeats picker // --------------------------------------------------------------------------- export type PresetId = | "none" | "daily" | "weekdays" | "weekends" | "weekly_same" | "monthly_same" | "yearly_same" | "custom" | "cron"; export interface PresetDescriptor { id: PresetId; /** Short label shown in the radio list. */ label: string; /** Optional one-line hint shown beneath the label. */ hint?: string; } /** * Build the canonical RecurrenceSpec for a preset given the first-fire * DateTime (the spec depends on the chosen first fire — e.g. "every * week on the same weekday" means whatever weekday firstFire lands on). */ export function presetToSpec(id: PresetId, firstFire: DateTime): RecurrenceSpec { const base: RecurrenceSpec = { kind: "none", interval: 1, weeklyDays: [], end: { kind: "never" }, }; switch (id) { case "none": return base; case "daily": return { ...base, kind: "daily" }; case "weekdays": return { ...base, kind: "weekly", weeklyDays: [1, 2, 3, 4, 5] }; case "weekends": return { ...base, kind: "weekly", weeklyDays: [6, 7] }; case "weekly_same": return { ...base, kind: "weekly", weeklyDays: [firstFire.weekday] }; case "monthly_same": return { ...base, kind: "monthly", monthDay: firstFire.day }; case "yearly_same": return { ...base, kind: "yearly" }; case "custom": // Custom is only meaningful in the picker UI — when the user // explicitly opts into it, callers should preserve whatever // detailed spec the user already had. Return a sensible weekly // default if the caller forgets to pass through. return { ...base, kind: "weekly", weeklyDays: [firstFire.weekday] }; case "cron": // Default cron expression: every day at the first-fire's HH:MM. return { ...base, kind: "cron", cron: `${firstFire.minute} ${firstFire.hour} * * *`, }; } } /** * Reverse mapping: which preset (if any) does this spec match? * * Returns "custom" for anything that doesn't match a known shortcut — * the picker uses that to flip into expanded-detail mode. */ export function matchPreset(spec: RecurrenceSpec, firstFire: DateTime): PresetId { if (spec.kind === "none") return "none"; if (spec.kind === "cron") return "cron"; const sameInterval = spec.interval === 1; const noEnd = spec.end.kind === "never"; if (!sameInterval || !noEnd) return "custom"; const sortedWeeklyDays = (days: number[]) => days.slice().sort((a, b) => a - b).join(","); switch (spec.kind) { case "daily": return "daily"; case "weekly": { const days = spec.weeklyDays.length === 0 ? [firstFire.weekday] : spec.weeklyDays; const key = sortedWeeklyDays(days); if (key === "1,2,3,4,5") return "weekdays"; if (key === "6,7") return "weekends"; if (key === String(firstFire.weekday)) return "weekly_same"; return "custom"; } case "monthly": if ((spec.monthDay ?? firstFire.day) === firstFire.day) return "monthly_same"; return "custom"; case "yearly": return "yearly_same"; case "cron": return "cron"; case "none": return "none"; } } /** * Render the (firstFire-aware) labels and hints for the radio list. * The hint shows the concrete weekday/day-of-month/date the preset * would imply given the user's chosen first fire. */ export function presetDescriptors(firstFire: DateTime): PresetDescriptor[] { const dayShort = WEEKDAY_LABELS[firstFire.weekday - 1]?.short ?? ""; return [ { id: "none", label: "Don't repeat", hint: "Fires once and ends" }, { id: "daily", label: "Every day" }, { id: "weekdays", label: "Every weekday", hint: "Mon – Fri" }, { id: "weekends", label: "Every weekend", hint: "Sat – Sun" }, { id: "weekly_same", label: `Every week on ${dayShort}`, hint: "Same weekday as the start date", }, { id: "monthly_same", label: `Every month on day ${firstFire.day}`, hint: "Months without this day skip naturally (e.g. 31st)", }, { id: "yearly_same", label: `Every year on ${firstFire.toFormat("MMM d")}`, }, { id: "custom", label: "Custom…", hint: "Set interval, days, and end conditions yourself", }, { id: "cron", label: "Cron expression…", hint: "Full sec/min/hour/day/month/dow combinational power", }, ]; }