import { useCallback, useEffect, useMemo, useState } from "react"; import { Button } from "@/components/ui/button"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Input } from "@/components/ui/input"; import { nextCronFires, parseCronExpression } from "../lib/cron-fires"; export type SchedulePreset = "every_minute" | "every_hour" | "every_day" | "weekdays" | "weekly" | "monthly" | "custom"; const PRESETS: { value: SchedulePreset; label: string }[] = [ { value: "every_minute", label: "Every minute" }, { value: "every_hour", label: "Every hour" }, { value: "every_day", label: "Every day" }, { value: "weekdays", label: "Weekdays" }, { value: "weekly", label: "Weekly" }, { value: "monthly", label: "Monthly" }, { value: "custom", label: "Custom (cron)" }, ]; const HOURS = Array.from({ length: 24 }, (_, i) => ({ value: String(i), label: i === 0 ? "12 AM" : i < 12 ? `${i} AM` : i === 12 ? "12 PM" : `${i - 12} PM`, })); const MINUTES = Array.from({ length: 12 }, (_, i) => ({ value: String(i * 5), label: String(i * 5).padStart(2, "0"), })); const DAYS_OF_WEEK = [ { value: "1", label: "Mon" }, { value: "2", label: "Tue" }, { value: "3", label: "Wed" }, { value: "4", label: "Thu" }, { value: "5", label: "Fri" }, { value: "6", label: "Sat" }, { value: "0", label: "Sun" }, ]; const DAYS_OF_MONTH = Array.from({ length: 31 }, (_, i) => ({ value: String(i + 1), label: String(i + 1), })); function hasOption(options: Array<{ value: string }>, value: string): boolean { return options.some((option) => option.value === value); } export function parseCronToPreset(cron: string): { preset: SchedulePreset; hour: string; minute: string; dayOfWeek: string; dayOfMonth: string; } { const defaults = { hour: "10", minute: "0", dayOfWeek: "1", dayOfMonth: "1" }; if (!cron || !cron.trim()) { return { preset: "every_day", ...defaults }; } const parts = cron.trim().split(/\s+/); if (parts.length !== 5) { return { preset: "custom", ...defaults }; } const [min, hr, dom, month, dow] = parts; const selectableMinute = hasOption(MINUTES, min); const selectableHour = hasOption(HOURS, hr); // Every minute: "* * * * *" if (min === "*" && hr === "*" && dom === "*" && month === "*" && dow === "*") { return { preset: "every_minute", ...defaults }; } // Every hour: "0 * * * *" if (hr === "*" && dom === "*" && month === "*" && dow === "*" && selectableMinute) { return { preset: "every_hour", ...defaults, minute: min }; } // Every day: "M H * * *" if (dom === "*" && month === "*" && dow === "*" && selectableHour && selectableMinute) { return { preset: "every_day", ...defaults, hour: hr, minute: min }; } // Weekdays: "M H * * 1-5" if (dom === "*" && month === "*" && dow === "1-5" && selectableHour && selectableMinute) { return { preset: "weekdays", ...defaults, hour: hr, minute: min }; } // Weekly: "M H * * D" (single day) if (dom === "*" && month === "*" && hasOption(DAYS_OF_WEEK, dow) && selectableHour && selectableMinute) { return { preset: "weekly", ...defaults, hour: hr, minute: min, dayOfWeek: dow }; } // Monthly: "M H D * *" if (month === "*" && hasOption(DAYS_OF_MONTH, dom) && dow === "*" && selectableHour && selectableMinute) { return { preset: "monthly", ...defaults, hour: hr, minute: min, dayOfMonth: dom }; } return { preset: "custom", ...defaults }; } export function buildCron(preset: SchedulePreset, hour: string, minute: string, dayOfWeek: string, dayOfMonth: string): string { switch (preset) { case "every_minute": return "* * * * *"; case "every_hour": return `${minute} * * * *`; case "every_day": return `${minute} ${hour} * * *`; case "weekdays": return `${minute} ${hour} * * 1-5`; case "weekly": return `${minute} ${hour} * * ${dayOfWeek}`; case "monthly": return `${minute} ${hour} ${dayOfMonth} * *`; case "custom": return ""; } } function describeSchedule(cron: string): string { const { preset, hour, minute, dayOfWeek, dayOfMonth } = parseCronToPreset(cron); const hourLabel = HOURS.find((h) => h.value === hour)?.label ?? `${hour}`; const timeStr = `${hourLabel.replace(/ (AM|PM)$/, "")}:${minute.padStart(2, "0")} ${hourLabel.match(/(AM|PM)$/)?.[0] ?? ""}`; switch (preset) { case "every_minute": return "Every minute"; case "every_hour": return `Every hour at :${minute.padStart(2, "0")}`; case "every_day": return `Every day at ${timeStr}`; case "weekdays": return `Weekdays at ${timeStr}`; case "weekly": { const day = DAYS_OF_WEEK.find((d) => d.value === dayOfWeek)?.label ?? dayOfWeek; return `Every ${day} at ${timeStr}`; } case "monthly": return `Monthly on the ${dayOfMonth}${ordinalSuffix(Number(dayOfMonth))} at ${timeStr}`; case "custom": return cron || "No schedule set"; } } function ordinalSuffix(n: number): string { const s = ["th", "st", "nd", "rd"]; const v = n % 100; return s[(v - 20) % 10] || s[v] || s[0]; } export { describeSchedule }; export function getScheduleCronValidation(cron: string): { valid: boolean; message: string; nextFires: Date[]; } { const trimmed = cron.trim(); if (!trimmed) { return { valid: false, message: "Enter a 5-field cron expression.", nextFires: [], }; } const fields = trimmed.split(/\s+/); if (fields.length !== 5) { return { valid: false, message: `Use exactly 5 fields; this has ${fields.length}.`, nextFires: [], }; } if (!parseCronExpression(trimmed)) { return { valid: false, message: "Cron fields must use valid numbers, ranges, lists, wildcards, or steps.", nextFires: [], }; } const nextFires = nextCronFires(trimmed, 3, { timeZone: "UTC" }); return { valid: true, message: nextFires.length > 0 ? "Valid cron." : "Valid cron, but no upcoming fires were found.", nextFires, }; } export function ScheduleEditor({ value, onChange, onValidityChange, }: { value: string; onChange: (cron: string) => void; onValidityChange?: (valid: boolean) => void; }) { const parsed = useMemo(() => parseCronToPreset(value), [value]); const [preset, setPreset] = useState(parsed.preset); const [hour, setHour] = useState(parsed.hour); const [minute, setMinute] = useState(parsed.minute); const [dayOfWeek, setDayOfWeek] = useState(parsed.dayOfWeek); const [dayOfMonth, setDayOfMonth] = useState(parsed.dayOfMonth); const [customCron, setCustomCron] = useState(preset === "custom" ? value : ""); const customValidation = useMemo(() => getScheduleCronValidation(customCron), [customCron]); useEffect(() => { onValidityChange?.(preset !== "custom" || customValidation.valid); }, [customValidation.valid, onValidityChange, preset]); // Sync from external value changes useEffect(() => { const p = parseCronToPreset(value); setPreset(p.preset); setHour(p.hour); setMinute(p.minute); setDayOfWeek(p.dayOfWeek); setDayOfMonth(p.dayOfMonth); if (p.preset === "custom") setCustomCron(value); }, [value]); const emitChange = useCallback( (p: SchedulePreset, h: string, m: string, dow: string, dom: string, custom: string) => { if (p === "custom") { onChange(custom); } else { onChange(buildCron(p, h, m, dow, dom)); } }, [onChange], ); const handlePresetChange = (newPreset: SchedulePreset) => { setPreset(newPreset); if (newPreset === "custom") { setCustomCron(value); } else { emitChange(newPreset, hour, minute, dayOfWeek, dayOfMonth, customCron); } }; return (
{preset === "custom" ? (
{ const nextCron = e.target.value; setCustomCron(nextCron); // Report validity synchronously with the keystroke so consumers can gate // their submit affordance in the same render. Relying solely on the // effect below leaves a one-tick window where an invalid draft still // reads as valid to the parent. const nextValidation = getScheduleCronValidation(nextCron); onValidityChange?.(nextValidation.valid); if (nextValidation.valid) { emitChange("custom", hour, minute, dayOfWeek, dayOfMonth, nextCron); } }} placeholder="0 10 * * *" aria-label="Cron expression" aria-invalid={!customValidation.valid} className="font-mono text-sm" />

Five fields: minute hour day-of-month month day-of-week

{customValidation.message} {customValidation.valid && customValidation.nextFires.length > 0 ? ` Next: ${customValidation.nextFires.map((fire) => fire.toLocaleString()).join(", ")}.` : null}

) : (
{preset !== "every_minute" && preset !== "every_hour" && ( <> at : )} {preset === "every_hour" && ( <> at minute )} {preset === "weekly" && ( <> on
{DAYS_OF_WEEK.map((d) => ( ))}
)} {preset === "monthly" && ( <> on day )}
)}
); }