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 ? describeCronRule(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; } // --------------------------------------------------------------------------- // describeCronRule // // Turns the cron expressions the recurrence picker produces back into the // kind of human-readable sentence the rest of the app already uses for // RRULE-based recurrences. Used by `describeRecurrence` for cron rules // and by anywhere else the app surfaces a recurrence summary (reminder // list rows, detail page, review step). // // Shapes the picker emits, all with `MM HH` at the front: // // "MM HH * * *" → daily, every day // "MM HH * * D[,D...]" → weekly, on listed cron weekdays (0=Sun..6=Sat) // "MM HH d[,d...] * *" → monthly, on listed days // "MM HH d[,d...] m[,m...] *" → yearly, listed days × listed months // // Multi-line rules ("CRON:line1\nline2\n…") render as the joined // per-line descriptions, separated by " · " for compactness in lists. // --------------------------------------------------------------------------- const MONTH_NAMES_FULL = [ "January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December", ]; function pad2(n: number): string { return n.toString().padStart(2, "0"); } function formatDaysList(days: number[]): string { if (days.length <= 6) return days.join(", "); return `${days.slice(0, 6).join(", ")} +${days.length - 6} more`; } function describeWeekdayList(cronDays: number[]): string { // Cron weekday: 0=Sun..6=Sat. Sort + map to short labels. const labels = cronDays .slice() .sort((a, b) => a - b) .map((c) => { const iso = c === 0 ? 7 : c; return WEEKDAY_LABELS[iso - 1]?.short ?? ""; }) .filter(Boolean); return labels.join(", "); } /** Describe a single cron expression (no `CRON:` prefix). */ function describeCronExpr(expr: string): string { const head = expr.match(/^(\d+)\s+(\d+)\s+(.*)$/); if (!head) return expr; // unrecognised — fall back to raw const minute = Number(head[1]); const hour = Number(head[2]); if ( !Number.isFinite(minute) || !Number.isFinite(hour) || minute < 0 || minute > 59 || hour < 0 || hour > 23 ) { return expr; } const time = `${pad2(hour)}:${pad2(minute)}`; const rest = head[3]!.trim(); // Daily: "* * *" if (rest === "* * *") return `Every day at ${time}`; // Weekly: "* * " let m = rest.match(/^\* \* ([0-9,\-]+)$/); if (m) { const days = m[1]! .split(",") .flatMap((p) => { const r = p.match(/^(\d+)-(\d+)$/); if (r) { const out: number[] = []; for (let i = Number(r[1]); i <= Number(r[2]); i++) out.push(i); return out; } return [Number(p)]; }) .filter((n) => n >= 0 && n <= 6); const labels = describeWeekdayList(days); if (!labels) return expr; return `Every week on ${labels} at ${time}`; } // Monthly: " * *" m = rest.match(/^([0-9,]+) \* \*$/); if (m) { const days = m[1]! .split(",") .map((s) => Number(s)) .filter((n) => n >= 1 && n <= 31); if (days.length === 0) return expr; return `Every month on day${days.length > 1 ? "s" : ""} ${formatDaysList(days)} at ${time}`; } // Yearly: " *" m = rest.match(/^([0-9,]+) ([0-9,]+) \*$/); if (m) { const days = m[1]! .split(",") .map((s) => Number(s)) .filter((n) => n >= 1 && n <= 31); const months = m[2]! .split(",") .map((s) => Number(s)) .filter((n) => n >= 1 && n <= 12); if (days.length === 0 || months.length === 0) return expr; const monthLabels = months.map((mo) => MONTH_NAMES_FULL[mo - 1]?.slice(0, 3)).join(", "); return `Every year in ${monthLabels} on day${days.length > 1 ? "s" : ""} ${formatDaysList(days)} at ${time}`; } // Unrecognised shape — show the raw expression so the user can still // make sense of it; better than swallowing entirely. return expr; } /** * Render a cron rule as a sentence. Handles: * - the optional "CRON:" prefix our app stores * - multi-line rules (joined with " · ") */ export function describeCronRule(rule: string): string { const prefix = "CRON:"; const stripped = rule.startsWith(prefix) ? rule.slice(prefix.length) : rule; const lines = stripped .split("\n") .map((s) => s.trim()) .filter(Boolean); if (lines.length === 0) return "Cron (not configured)"; return lines.map(describeCronExpr).join(" · "); }