"use client"; import { useEffect, useState } from "react"; import { DateTime } from "luxon"; import { PlusIcon, RepeatIcon, Trash2Icon } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"; import { cn } from "@/lib/utils"; import { WEEKDAY_LABELS, isoWeekdayToCron, type RecurrenceSpec, } from "@/lib/recurrence"; interface RecurrencePickerProps { /** * First fire — only used to seed defaults for newly-added rules * (initial weekday, monthday, time). Each rule then carries its * own hour/minute, so changing the date+time inputs above no * longer reshapes existing rules. */ firstFire: DateTime; value: RecurrenceSpec; onChange: (next: RecurrenceSpec) => void; } // --------------------------------------------------------------------------- // Per-row draft (one recurring rule) // --------------------------------------------------------------------------- type RuleType = "daily" | "weekly" | "monthly" | "yearly"; interface Draft { type: RuleType; /** Cron weekday list (0=Sun..6=Sat). */ weekdays: number[]; /** Sorted unique day-of-month list (1-31). Used by monthly + yearly. */ monthDays: number[]; /** Sorted unique month list (1-12). Used by yearly only. */ months: number[]; /** Hour-of-day (0-23) for this rule's fire time. */ hour: number; /** Minute-of-hour (0-59). */ minute: number; } const MAX_RULES = 8; function defaultDraft(firstFire: DateTime): Draft { return { type: "daily", weekdays: [isoWeekdayToCron(firstFire.weekday)], monthDays: [firstFire.day], months: [firstFire.month], hour: firstFire.hour, minute: firstFire.minute, }; } function uniqSortedDays(days: number[]): number[] { return Array.from(new Set(days.filter((d) => d >= 1 && d <= 31))).sort((a, b) => a - b); } function uniqSortedMonths(months: number[]): number[] { return Array.from(new Set(months.filter((m) => m >= 1 && m <= 12))).sort((a, b) => a - b); } function pad2(n: number): string { return n.toString().padStart(2, "0"); } function parseHHMM(s: string): { hour: number; minute: number } | null { const m = s.match(/^(\d{1,2}):(\d{2})$/); if (!m) return null; const h = Number(m[1]); const min = Number(m[2]); if (h < 0 || h > 23 || min < 0 || min > 59) return null; return { hour: h, minute: min }; } function clamp(n: number, lo: number, hi: number): number { if (!Number.isFinite(n)) return lo; return Math.min(Math.max(Math.floor(n), lo), hi); } const MONTH_NAMES = [ "January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December", ]; function draftToCron(d: Draft): string | null { const m = clamp(d.minute, 0, 59); const h = clamp(d.hour, 0, 23); switch (d.type) { case "daily": return `${m} ${h} * * *`; case "weekly": if (!d.weekdays.length) return null; return `${m} ${h} * * ${d.weekdays.slice().sort((a, b) => a - b).join(",")}`; case "monthly": { const days = uniqSortedDays(d.monthDays); if (!days.length) return null; return `${m} ${h} ${days.join(",")} * *`; } case "yearly": { const days = uniqSortedDays(d.monthDays); const months = uniqSortedMonths(d.months); if (!days.length || !months.length) return null; return `${m} ${h} ${days.join(",")} ${months.join(",")} *`; } } } function describeDraft(d: Draft): string { const t = `${pad2(clamp(d.hour, 0, 23))}:${pad2(clamp(d.minute, 0, 59))}`; switch (d.type) { case "daily": return `Every day at ${t}`; case "weekly": { if (!d.weekdays.length) return "Pick at least one weekday"; const labels = d.weekdays .slice() .sort((a, b) => a - b) .map((c) => { const iso = c === 0 ? 7 : c; return WEEKDAY_LABELS[iso - 1]?.short ?? ""; }) .filter(Boolean) .join(", "); return `Every week on ${labels} at ${t}`; } case "monthly": { const days = uniqSortedDays(d.monthDays); if (!days.length) return "Pick at least one day"; const list = days.length <= 6 ? days.join(", ") : `${days.slice(0, 6).join(", ")} +${days.length - 6} more`; return `Every month on day${days.length > 1 ? "s" : ""} ${list} at ${t}`; } case "yearly": { const days = uniqSortedDays(d.monthDays); const months = uniqSortedMonths(d.months); if (!days.length) return "Pick at least one day"; if (!months.length) return "Pick at least one month"; const monthLabel = months.map((mo) => MONTH_NAMES[mo - 1]?.slice(0, 3)).join(", "); const dayLabel = days.length <= 6 ? days.join(", ") : `${days.slice(0, 6).join(", ")} +${days.length - 6} more`; return `Every year in ${monthLabel} on day${days.length > 1 ? "s" : ""} ${dayLabel} at ${t}`; } } } /** Reverse-engineer a single cron expression into a Draft. Falls back to * daily-every-day if the expression doesn't match a known shape. */ function draftFromCronExpr(expr: string, firstFire: DateTime): Draft { const base = defaultDraft(firstFire); // Pull MM HH off the front so each rule restores its own time. const head = expr.match(/^(\d+)\s+(\d+)\s+(.*)$/); if (!head) return base; const minute = clamp(Number(head[1]), 0, 59); const hour = clamp(Number(head[2]), 0, 23); const rest = head[3]!.trim(); let m: RegExpMatchArray | null; if (rest === "* * *") { return { ...base, type: "daily", hour, minute }; } // Any DOW list (including the legacy "1-5" weekday-only daily rule) // round-trips as a Weekly draft. if ((m = rest.match(/^\* \* ([0-9,\-]+)$/))) { 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); return { ...base, type: "weekly", weekdays: days, hour, minute }; } if ((m = rest.match(/^([0-9,]+) \* \*$/))) { const days = uniqSortedDays(m[1]!.split(",").map((s) => Number(s))); return { ...base, type: "monthly", monthDays: days.length ? days : [1], hour, minute, }; } if ((m = rest.match(/^([0-9,]+) ([0-9,]+) \*$/))) { const days = uniqSortedDays(m[1]!.split(",").map((s) => Number(s))); const months = uniqSortedMonths(m[2]!.split(",").map((s) => Number(s))); return { ...base, type: "yearly", monthDays: days.length ? days : [1], months: months.length ? months : [1], hour, minute, }; } return { ...base, hour, minute }; } /** Parse a (possibly multi-line) cron rule into an array of drafts. */ function draftsFromRule(rule: string | null | undefined, firstFire: DateTime): Draft[] { if (!rule) return []; const expr = rule.startsWith("CRON:") ? rule.slice(5) : rule; const lines = expr.split("\n").map((s) => s.trim()).filter(Boolean); if (lines.length === 0) return []; return lines.map((line) => draftFromCronExpr(line, firstFire)); } /** Compile an array of drafts to a single multi-line CRON: rule, or null * if there are no rules (= one-off). */ function draftsToRule(drafts: Draft[]): string | null { if (drafts.length === 0) return null; const exprs = drafts .map((d) => draftToCron(d)) .filter((s): s is string => Boolean(s)); if (exprs.length === 0) return null; return exprs.join("\n"); } // --------------------------------------------------------------------------- // The picker // --------------------------------------------------------------------------- /** * Inline recurrence picker. Lives in the When form right under the * date+time inputs. * * Repeats * ┌─────────────────────────────────────────────────────┐ * │ Schedule 1 [✕ remove] │ * │ [Daily] [Weekly] [Monthly] [Yearly] │ * │ │ * ├─────────────────────────────────────────────────────┤ * │ Schedule 2 [✕ remove] │ * │ [Daily] [Weekly] [Monthly] [Yearly] │ * │ │ * └─────────────────────────────────────────────────────┘ * [+ Add another schedule] * * Fires: * • Every weekday at 09:00 * • Every Friday at 17:00 * * Zero rules = "Don't repeat" (fires once at date+time and ends). * Adding a rule emits a `{ kind: "cron", cron: "\n" }` * spec; the bot's `nextOccurrence` already supports newline-joined * cron and returns the earliest next fire across all rules. */ export function RecurrencePicker({ firstFire, value, onChange }: RecurrencePickerProps) { const [drafts, setDrafts] = useState(() => draftsFromRule(value.kind === "cron" ? value.cron ?? null : null, firstFire), ); // Compile drafts back to the parent value whenever they change. Each // rule carries its own hour/minute now, so the date+time inputs above // no longer drive cron output — they only set the very first fire. useEffect(() => { const rule = draftsToRule(drafts); if (!rule) { if (value.kind !== "none") { onChange({ kind: "none", interval: 1, weeklyDays: [], end: { kind: "never" } }); } return; } if (value.kind !== "cron" || value.cron !== rule) { onChange({ kind: "cron", interval: 1, weeklyDays: [], cron: rule, end: { kind: "never" }, }); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [drafts]); function updateDraft(idx: number, patch: Partial) { setDrafts((prev) => prev.map((d, i) => (i === idx ? { ...d, ...patch } : d))); } function addRule() { if (drafts.length >= MAX_RULES) return; setDrafts((prev) => [...prev, defaultDraft(firstFire)]); } function removeRule(idx: number) { setDrafts((prev) => prev.filter((_, i) => i !== idx)); } return (
{drafts.length === 0 ? (
No Repeats
) : (
{drafts.map((draft, idx) => (
0 && "border-t border-border", )} >
Schedule {idx + 1}
updateDraft(idx, patch)} />

{describeDraft(draft)}

))}
)}
{drafts.length >= MAX_RULES && ( Up to {MAX_RULES} schedules per reminder )}
); } // --------------------------------------------------------------------------- // Single-rule editor (tabs + per-type config) // --------------------------------------------------------------------------- interface RuleEditorProps { draft: Draft; onChange: (patch: Partial) => void; } function RuleEditor({ draft, onChange }: RuleEditorProps) { return ( onChange({ type: v as RuleType })} > Daily Weekly Monthly Yearly

Fires once a day at the time below.

{WEEKDAY_LABELS.map(({ iso, short }) => { const cronDow = isoWeekdayToCron(iso); const active = draft.weekdays.includes(cronDow); return ( ); })}
{draft.monthDays.length} selected
{Array.from({ length: 31 }, (_, i) => i + 1).map((day) => { const active = draft.monthDays.includes(day); return ( ); })}

Months without a selected day skip naturally (e.g. day 31 in February).

{draft.months.length} selected
{MONTH_NAMES.map((name, i) => { const month = i + 1; const active = draft.months.includes(month); return ( ); })}
{draft.monthDays.length} selected
{Array.from({ length: 31 }, (_, i) => i + 1).map((day) => { const active = draft.monthDays.includes(day); return ( ); })}

Fires on every selected day of every selected month.

); } interface TimeFieldProps { draft: Draft; onChange: (patch: Partial) => void; } /** Per-rule time picker (HH:MM). Drives the cron expression's MM HH columns. */ function TimeField({ draft, onChange }: TimeFieldProps) { const value = `${pad2(clamp(draft.hour, 0, 23))}:${pad2(clamp(draft.minute, 0, 59))}`; return (
{ const parsed = parseHHMM(e.target.value); if (parsed) onChange(parsed); }} className="h-8 w-32" />
); }