diff --git a/apps/web/src/components/recurrence-picker.tsx b/apps/web/src/components/recurrence-picker.tsx index e6da99c..5bade42 100644 --- a/apps/web/src/components/recurrence-picker.tsx +++ b/apps/web/src/components/recurrence-picker.tsx @@ -2,61 +2,215 @@ import { useEffect, useState } from "react"; import { DateTime } from "luxon"; -import { CheckIcon, RepeatIcon } from "lucide-react"; +import { CalendarRangeIcon, ChevronDownIcon, RepeatIcon } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; 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, - defaultFlowState, - flowFromCron, - flowToCron, - freqChoices, isoWeekdayToCron, - type FlowState, - type FreqChoice, type RecurrenceSpec, } from "@/lib/recurrence"; interface RecurrencePickerProps { - /** First fire of the reminder — drives the HH:MM in the cron output and the - * default day-of-month / month / weekday for the per-frequency configurators. */ + /** First fire — drives the HH:MM in the cron output and the default + * weekday / day-of-month / month for each recurrence type. */ firstFire: DateTime; value: RecurrenceSpec; onChange: (next: RecurrenceSpec) => void; } +// --------------------------------------------------------------------------- +// Internal draft state for the dialog +// --------------------------------------------------------------------------- + +type RecurrenceType = "none" | "daily" | "weekly" | "monthly" | "yearly"; + +interface Draft { + type: RecurrenceType; + /** "every_day" | "weekdays" — only relevant under `daily`. */ + dailyMode: "every_day" | "weekdays"; + /** Cron weekday numbers (0=Sun..6=Sat) — `weekly`. */ + weekdays: number[]; + /** Day-of-month 1-31 — `monthly` and `yearly`. */ + monthDay: number; + /** Month-of-year 1-12 — `yearly`. */ + month: number; +} + +function defaultDraft(firstFire: DateTime): Draft { + return { + type: "none", + dailyMode: "every_day", + weekdays: [isoWeekdayToCron(firstFire.weekday)], + monthDay: firstFire.day, + month: firstFire.month, + }; +} + +/** Reverse-engineer a draft from a stored cron string so re-opening the + * dialog lands on the user's previous selection. */ +function draftFromCron(rule: string | null | undefined, firstFire: DateTime): Draft { + const base = defaultDraft(firstFire); + if (!rule) return base; + const expr = rule.startsWith("CRON:") ? rule.slice(5) : rule; + if (!expr.trim()) return base; + + let m: RegExpMatchArray | null; + // daily — "MM HH * * *" + if (expr.match(/^\d+ \d+ \* \* \*$/)) { + return { ...base, type: "daily", dailyMode: "every_day" }; + } + // weekday-only — "MM HH * * 1-5" exactly + if ((m = expr.match(/^\d+ \d+ \* \* 1-5$/))) { + return { ...base, type: "daily", dailyMode: "weekdays" }; + } + // weekly — "MM HH * * " + if ((m = expr.match(/^\d+ \d+ \* \* ([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 }; + } + // monthly — "MM HH * *" + if ((m = expr.match(/^\d+ \d+ (\d+) \* \*$/))) { + return { ...base, type: "monthly", monthDay: Number(m[1]) }; + } + // yearly — "MM HH *" + if ((m = expr.match(/^\d+ \d+ (\d+) (\d+) \*$/))) { + return { + ...base, + type: "yearly", + monthDay: Number(m[1]), + month: Number(m[2]), + }; + } + // Anything else: leave as default (Don't repeat) — the user can still + // pick a new type. The previous rule keeps firing on the bot side + // until they save. + return base; +} + +function draftToCron(d: Draft, firstFire: DateTime): string | null { + const m = firstFire.minute; + const h = firstFire.hour; + switch (d.type) { + case "none": + return null; + case "daily": + return d.dailyMode === "weekdays" ? `${m} ${h} * * 1-5` : `${m} ${h} * * *`; + case "weekly": + if (!d.weekdays.length) return null; + return `${m} ${h} * * ${d.weekdays.slice().sort((a, b) => a - b).join(",")}`; + case "monthly": + return `${m} ${h} ${clamp(d.monthDay, 1, 31)} * *`; + case "yearly": + return `${m} ${h} ${clamp(d.monthDay, 1, 31)} ${clamp(d.month, 1, 12)} *`; + } +} + +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", +]; + +// Plain-language summary the dialog renders below the tabs (and the +// trigger field shows when the dialog is closed). +function describeDraft(d: Draft, firstFire: DateTime): string { + const t = firstFire.toFormat("HH:mm"); + switch (d.type) { + case "none": + return "Don't repeat"; + case "daily": + return d.dailyMode === "weekdays" + ? `Every weekday at ${t}` + : `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) => { + // cron 0=Sun..6=Sat → ISO 1=Mon..7=Sun for our label table + const iso = c === 0 ? 7 : c; + return WEEKDAY_LABELS[iso - 1]?.short ?? ""; + }) + .filter(Boolean) + .join(", "); + return `Every week on ${labels} at ${t}`; + } + case "monthly": + return `Every month on day ${clamp(d.monthDay, 1, 31)} at ${t}`; + case "yearly": + return `Every year on ${MONTH_NAMES[clamp(d.month, 1, 12) - 1]} ${clamp(d.monthDay, 1, 31)} at ${t}`; + } +} + +// --------------------------------------------------------------------------- +// The picker +// --------------------------------------------------------------------------- + /** - * Guided cron flow. + * Trigger field + dialog, modelled on the Temenos UUX date-recurrence-picker. * - * Step 1 — pick a frequency in the radio list (the chosen card stays - * highlighted; only its config panel below it expands). - * Step 2 — fill in the per-frequency inputs (a number, weekday chips, - * a day picker, etc.). Every change recompiles to a cron expression - * and pushes a `{ kind: "cron", cron: "..." }` spec up to the parent. + * The form shows a single read-only field summarising the current rule. + * Clicking it opens a dialog with a tab strip across the top: * - * "Don't repeat" is a one-click exit (no config). "Custom cron…" lets - * a power-user type any expression directly. + * [Don't repeat] [Daily] [Weekly] [Monthly] [Yearly] + * + * Each tab swaps in its own controls (a weekday-only toggle, a chip + * group, a day-of-month input, a month + day pair). A live "Fires …" + * sentence updates as values change. Save commits, Cancel discards. */ export function RecurrencePicker({ firstFire, value, onChange }: RecurrencePickerProps) { - // The flow state is reverse-engineered from the incoming `value` - // when the picker mounts so editing an existing reminder lands on - // the right radio. Subsequent edits live in local state. - const [flow, setFlow] = useState(() => - flowFromCron(value.kind === "cron" ? value.cron ?? null : null, firstFire), + const [open, setOpen] = useState(false); + const [draft, setDraft] = useState(() => + draftFromCron(value.kind === "cron" ? value.cron ?? null : null, firstFire), ); - // Re-derive the cron when either the flow or the first-fire changes - // (changing the time picker outside should refresh "at HH:MM"). + // When the dialog opens, re-sync the draft from the parent value so + // the user always starts from the current saved rule. useEffect(() => { - const cron = flowToCron(flow, firstFire); - if (!cron) { - if (value.kind !== "none") { - onChange({ kind: "none", interval: 1, weeklyDays: [], end: { kind: "never" } }); - } - return; + if (open) { + setDraft( + draftFromCron(value.kind === "cron" ? value.cron ?? null : null, firstFire), + ); } - if (value.kind !== "cron" || value.cron !== cron) { + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open]); + + const updateDraft = (k: K, v: Draft[K]) => + setDraft((prev) => ({ ...prev, [k]: v })); + + function handleSave() { + const cron = draftToCron(draft, firstFire); + if (!cron) { + onChange({ kind: "none", interval: 1, weeklyDays: [], end: { kind: "never" } }); + } else { onChange({ kind: "cron", interval: 1, @@ -65,267 +219,218 @@ export function RecurrencePicker({ firstFire, value, onChange }: RecurrencePicke end: { kind: "never" }, }); } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [flow, firstFire.minute, firstFire.hour, firstFire.day, firstFire.month, firstFire.weekday]); + setOpen(false); + } - const update = (k: K, v: FlowState[K]) => - setFlow((prev) => ({ ...prev, [k]: v })); + function handleReset() { + setDraft(defaultDraft(firstFire)); + } + + // The summary shown on the trigger reflects the *saved* value, not + // the in-flight draft. + const savedDraft = draftFromCron( + value.kind === "cron" ? value.cron ?? null : null, + firstFire, + ); + const triggerSummary = describeDraft(savedDraft, firstFire); return ( -
+
-
-
    - {freqChoices(firstFire).map((c) => { - const selected = flow.freq === c.id; - return ( -
  • - + + + + + Repeat schedule + + + updateDraft("type", v as RecurrenceType)}> + + Don't repeat + Daily + Weekly + Monthly + Yearly + + + +

    + The reminder fires once at the date and time you picked above and ends. +

    +
    + + + updateDraft("dailyMode", "every_day")} + label="Every day" + /> + updateDraft("dailyMode", "weekdays")} + label="Every weekday (Mon – Fri)" + /> + + + + +
    + {WEEKDAY_LABELS.map(({ iso, short }) => { + const cronDow = isoWeekdayToCron(iso); + const active = draft.weekdays.includes(cronDow); + return ( + + ); + })} +
    +
    + + + +
    + updateDraft("monthDay", Number(e.target.value) || 1)} + className="h-8 w-24" + /> + + Months without this day skip naturally (e.g. 31st) + +
    +
    + + +
    +
    + + +
    +
    + + updateDraft("monthDay", Number(e.target.value) || 1)} + className="h-8 w-20" /> - )} -
  • - ); - })} -
-
+
+
+ + + + {/* Live confirmation — always visible so the user sees what they + are about to save. */} +
+ Fires:{" "} + {describeDraft(draft, firstFire)} +
+ + + +
+ + +
+
+ + ); } -// --------------------------------------------------------------------------- -// Per-frequency config panels -// --------------------------------------------------------------------------- - -interface FreqConfigProps { - flow: FlowState; - firstFire: DateTime; - update: (k: K, v: FlowState[K]) => void; +interface RadioRowProps { + name: string; + value: string; + checked: boolean; + onChange: () => void; + label: string; } -function FreqConfig({ flow, firstFire, update }: FreqConfigProps) { - const cron = flowToCron(flow, firstFire); - const wrap = (children: React.ReactNode) => ( -
- {children} - {cron ? ( -

- Cron: {cron} -

- ) : null} -
+function RadioRow({ name, value, checked, onChange, label }: RadioRowProps) { + return ( + ); - - switch (flow.freq) { - case "none": - return null; - - case "minute": - return wrap( -
- - update("minuteInterval", Number(e.target.value) || 1)} - className="h-8 w-20" - /> - - minute{flow.minuteInterval === 1 ? "" : "s"} - -
, - ); - - case "hour": - return wrap( -
- - update("hourInterval", Number(e.target.value) || 1)} - className="h-8 w-20" - /> - - hour{flow.hourInterval === 1 ? "" : "s"} (at minute :{firstFire.toFormat("mm")}) - -
, - ); - - case "day": - // No extra config — outer time picker fully specifies the cron. - return wrap( -

- Uses the time picker above. Adjust the time to change when it fires each day. -

, - ); - - case "week": - return wrap( -
- -
- {WEEKDAY_LABELS.map(({ iso, short }) => { - const cronDow = isoWeekdayToCron(iso); - const active = flow.weekdays.includes(cronDow); - return ( - - ); - })} -
-
, - ); - - case "month": - return wrap( -
- - update("monthDay", Number(e.target.value) || 1)} - className="h-8 w-20" - /> - - Months without this day skip naturally (e.g. 31st) - -
, - ); - - case "year": - return wrap( -
-
- - - - update("monthDay", Number(e.target.value) || 1)} - className="h-8 w-20" - /> -
-
, - ); - - case "cron": - return wrap( -
- - update("customCron", e.target.value)} - placeholder="0 9 * * 1-5" - className="h-8 font-mono text-sm" - spellCheck={false} - /> -

- 5-field (m h dom mon dow) or 6-field - with seconds (s m h dom mon dow). Examples: -

-
    -
  • 0 9 * * 1-5 — 9 am on weekdays
  • -
  • */15 * * * * — every 15 minutes
  • -
  • 0 9,12,18 * * * — 9, 12, 18 every day
  • -
  • 0 0 1 * * — midnight on the 1st of every month
  • -
-
, - ); - } } diff --git a/apps/web/src/lib/recurrence.test.ts b/apps/web/src/lib/recurrence.test.ts index eb7f1d7..7fc81f6 100644 --- a/apps/web/src/lib/recurrence.test.ts +++ b/apps/web/src/lib/recurrence.test.ts @@ -2,15 +2,10 @@ import { describe, it, expect } from "vitest"; import { DateTime } from "luxon"; import { buildRrule, - defaultFlowState, describeRecurrence, - flowFromCron, - flowToCron, - freqChoices, isoWeekdayToCron, kindFromRrule, specFromRrule, - type FlowState, type RecurrenceSpec, } from "./recurrence"; @@ -166,77 +161,7 @@ describe("specFromRrule / kindFromRrule", () => { }); }); -describe("cron flow — frequency choice + per-frequency config", () => { - const baseFlow = (over: Partial = {}): FlowState => ({ - ...defaultFlowState(FIRST), - ...over, - }); - - it("freqChoices lists exactly the 8 top-level options in order", () => { - expect(freqChoices(FIRST).map((c) => c.id)).toEqual([ - "none", - "minute", - "hour", - "day", - "week", - "month", - "year", - "cron", - ]); - // Time-bearing labels use the first-fire's HH:MM (09:00). - const lookup = (id: string) => freqChoices(FIRST).find((c) => c.id === id); - expect(lookup("day")?.label).toBe("Every day at 09:00"); - expect(lookup("week")?.label).toBe("Every week at 09:00"); - expect(lookup("month")?.label).toBe("Every month at 09:00"); - expect(lookup("year")?.label).toBe("Every year at 09:00"); - }); - - it("flowToCron compiles every freq + config combination correctly", () => { - expect(flowToCron(baseFlow({ freq: "none" }), FIRST)).toBe(null); - expect(flowToCron(baseFlow({ freq: "minute", minuteInterval: 1 }), FIRST)).toBe( - "* * * * *", - ); - expect(flowToCron(baseFlow({ freq: "minute", minuteInterval: 15 }), FIRST)).toBe( - "*/15 * * * *", - ); - expect(flowToCron(baseFlow({ freq: "hour", hourInterval: 1 }), FIRST)).toBe( - "0 * * * *", - ); - expect(flowToCron(baseFlow({ freq: "hour", hourInterval: 4 }), FIRST)).toBe( - "0 */4 * * *", - ); - expect(flowToCron(baseFlow({ freq: "day" }), FIRST)).toBe("0 9 * * *"); - expect( - flowToCron(baseFlow({ freq: "week", weekdays: [1, 3, 5] }), FIRST), - ).toBe("0 9 * * 1,3,5"); - // Empty weekday list yields null (config not yet valid). - expect(flowToCron(baseFlow({ freq: "week", weekdays: [] }), FIRST)).toBe(null); - expect(flowToCron(baseFlow({ freq: "month", monthDay: 13 }), FIRST)).toBe( - "0 9 13 * *", - ); - expect( - flowToCron(baseFlow({ freq: "year", monthDay: 25, month: 12 }), FIRST), - ).toBe("0 9 25 12 *"); - expect( - flowToCron(baseFlow({ freq: "cron", customCron: "0 9 * * 1-5" }), FIRST), - ).toBe("0 9 * * 1-5"); - }); - - it("flowToCron clamps out-of-range numbers", () => { - expect(flowToCron(baseFlow({ freq: "minute", minuteInterval: 0 }), FIRST)).toBe( - "* * * * *", // clamped to 1 - ); - expect(flowToCron(baseFlow({ freq: "minute", minuteInterval: 999 }), FIRST)).toBe( - "*/59 * * * *", // clamped to 59 - ); - expect(flowToCron(baseFlow({ freq: "month", monthDay: 0 }), FIRST)).toBe( - "0 9 1 * *", - ); - expect( - flowToCron(baseFlow({ freq: "year", monthDay: 99, month: 99 }), FIRST), - ).toBe("0 9 31 12 *"); - }); - +describe("cron weekday helper + buildRrule/specFromRrule round-trip", () => { it("isoWeekdayToCron maps Sun (ISO 7) to cron 0 and Mon-Sat unchanged", () => { expect(isoWeekdayToCron(1)).toBe(1); // Mon expect(isoWeekdayToCron(2)).toBe(2); @@ -244,51 +169,6 @@ describe("cron flow — frequency choice + per-frequency config", () => { expect(isoWeekdayToCron(7)).toBe(0); // Sun }); - it("defaultFlowState seeds first-fire-aware values", () => { - const s = defaultFlowState(FIRST); - expect(s.freq).toBe("none"); - expect(s.weekdays).toEqual([3]); // Wed - expect(s.monthDay).toBe(13); - expect(s.month).toBe(5); - expect(s.customCron).toBe("0 9 * * *"); - }); - - it("flowFromCron round-trips every cron flow output", () => { - const cases: Array<{ flow: Partial; cron: string }> = [ - { flow: { freq: "minute", minuteInterval: 1 }, cron: "* * * * *" }, - { flow: { freq: "minute", minuteInterval: 5 }, cron: "*/5 * * * *" }, - { flow: { freq: "hour", hourInterval: 1 }, cron: "0 * * * *" }, - { flow: { freq: "hour", hourInterval: 6 }, cron: "0 */6 * * *" }, - { flow: { freq: "day" }, cron: "0 9 * * *" }, - { flow: { freq: "week", weekdays: [1, 3, 5] }, cron: "0 9 * * 1,3,5" }, - { flow: { freq: "month", monthDay: 13 }, cron: "0 9 13 * *" }, - { flow: { freq: "year", monthDay: 13, month: 5 }, cron: "0 9 13 5 *" }, - ]; - for (const c of cases) { - const parsed = flowFromCron(`CRON:${c.cron}`, FIRST); - // We're checking the freq lands right and the relevant config field - // round-trips. Other fields are seeded from defaults. - expect(parsed.freq).toBe(c.flow.freq); - for (const k of Object.keys(c.flow) as Array) { - expect(parsed[k]).toEqual(c.flow[k]); - } - } - }); - - it("flowFromCron parses BYDAY ranges (1-5) into expanded weekday list", () => { - expect(flowFromCron("CRON:0 9 * * 1-5", FIRST)).toMatchObject({ - freq: "week", - weekdays: [1, 2, 3, 4, 5], - }); - }); - - it("flowFromCron drops unrecognised expressions into the cron textbox", () => { - expect(flowFromCron("CRON:30 0,12 * * *", FIRST)).toMatchObject({ - freq: "cron", - customCron: "30 0,12 * * *", - }); - }); - it("buildRrule + specFromRrule still round-trip CRON: rules", () => { const spec: RecurrenceSpec = { kind: "cron", diff --git a/apps/web/src/lib/recurrence.ts b/apps/web/src/lib/recurrence.ts index 06444d2..8d9933d 100644 --- a/apps/web/src/lib/recurrence.ts +++ b/apps/web/src/lib/recurrence.ts @@ -215,168 +215,7 @@ export function kindFromRrule(rrule: string | null | undefined): RecurrenceKind return specFromRrule(rrule).kind; } -// --------------------------------------------------------------------------- -// Cron flow — pick a frequency, then configure it. Every selection compiles -// down to a single cron expression that lives in `reminders.rrule` with the -// `CRON:` sentinel. The bot's shared `nextOccurrence` dispatches cron rules -// through cron-parser. -// --------------------------------------------------------------------------- -export type FreqChoice = - | "none" - | "minute" - | "hour" - | "day" - | "week" - | "month" - | "year" - | "cron"; - -export interface FlowState { - freq: FreqChoice; - /** "Every N minutes" — used by `minute`. */ - minuteInterval: number; - /** "Every N hours" — used by `hour`. */ - hourInterval: number; - /** Cron weekday list (0=Sun..6=Sat) — used by `week`. */ - weekdays: number[]; - /** Day-of-month (1-31) — used by `month` and `year`. */ - monthDay: number; - /** Month-of-year (1-12) — used by `year`. */ - month: number; - /** Free-form cron expression — used by `cron`. */ - customCron: string; -} - /** Map ISO weekday (1=Mon..7=Sun) → cron weekday (0=Sun..6=Sat). */ export function isoWeekdayToCron(iso: number): number { return iso === 7 ? 0 : iso; } - -/** Sensible default flow state seeded from the first-fire DateTime. */ -export function defaultFlowState(firstFire: DateTime): FlowState { - return { - freq: "none", - minuteInterval: 5, - hourInterval: 1, - weekdays: [isoWeekdayToCron(firstFire.weekday)], - monthDay: firstFire.day, - month: firstFire.month, - customCron: `${firstFire.minute} ${firstFire.hour} * * *`, - }; -} - -/** - * Compile a flow state to a cron expression. The HH:MM portion comes from - * the user's first-fire (the outer date+time picker), so changing the time - * also updates the cron the picker emits. - * - * Returns null when the flow has no recurrence ("none") or the chosen - * config doesn't yet make sense (e.g. weekly with no weekdays selected). - */ -export function flowToCron(s: FlowState, firstFire: DateTime): string | null { - const m = firstFire.minute; - const h = firstFire.hour; - const clamp = (n: number, lo: number, hi: number) => - Number.isFinite(n) ? Math.min(Math.max(Math.floor(n), lo), hi) : lo; - - switch (s.freq) { - case "none": - return null; - case "minute": { - const n = clamp(s.minuteInterval, 1, 59); - return n === 1 ? "* * * * *" : `*/${n} * * * *`; - } - case "hour": { - const n = clamp(s.hourInterval, 1, 23); - return n === 1 ? `${m} * * * *` : `${m} */${n} * * *`; - } - case "day": - return `${m} ${h} * * *`; - case "week": { - if (!s.weekdays.length) return null; - const dow = s.weekdays.slice().sort((a, b) => a - b).join(","); - return `${m} ${h} * * ${dow}`; - } - case "month": { - const d = clamp(s.monthDay, 1, 31); - return `${m} ${h} ${d} * *`; - } - case "year": { - const d = clamp(s.monthDay, 1, 31); - const mon = clamp(s.month, 1, 12); - return `${m} ${h} ${d} ${mon} *`; - } - case "cron": - return s.customCron.trim() || null; - } -} - -/** - * Best-effort reverse: read a stored cron expression back into a flow - * state so the picker can show the user's previous choice when editing. - * Anything that doesn't fit a recognised shape (the picker's own output) - * lands on `cron` with the raw expression in the textbox. - */ -export function flowFromCron(rule: string | null | undefined, firstFire: DateTime): FlowState { - const base = defaultFlowState(firstFire); - if (!rule) return base; - const expr = rule.startsWith(CRON_PREFIX) ? rule.slice(CRON_PREFIX.length) : rule; - if (!expr.trim()) return base; - // Recognise patterns the picker emits. - let m: RegExpMatchArray | null; - if (expr === "* * * * *") return { ...base, freq: "minute", minuteInterval: 1 }; - if ((m = expr.match(/^\*\/(\d+) \* \* \* \*$/))) { - return { ...base, freq: "minute", minuteInterval: Number(m[1]) }; - } - if ((m = expr.match(/^(\d+) \* \* \* \*$/))) { - return { ...base, freq: "hour", hourInterval: 1 }; - } - if ((m = expr.match(/^(\d+) \*\/(\d+) \* \* \*$/))) { - return { ...base, freq: "hour", hourInterval: Number(m[2]) }; - } - if ((m = expr.match(/^(\d+) (\d+) \* \* \*$/))) { - return { ...base, freq: "day" }; - } - if ((m = expr.match(/^(\d+) (\d+) \* \* ([0-9,\-]+)$/))) { - const days = m[3]!.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)]; - }); - return { ...base, freq: "week", weekdays: days }; - } - if ((m = expr.match(/^(\d+) (\d+) (\d+) \* \*$/))) { - return { ...base, freq: "month", monthDay: Number(m[3]) }; - } - if ((m = expr.match(/^(\d+) (\d+) (\d+) (\d+) \*$/))) { - return { ...base, freq: "year", monthDay: Number(m[3]), month: Number(m[4]) }; - } - // Anything else: park it in the custom cron box. - return { ...base, freq: "cron", customCron: expr }; -} - -/** Frequency-choice descriptor for the radio list. */ -export interface FreqChoiceDescriptor { - id: FreqChoice; - label: string; - hint?: string; -} - -/** First-fire-aware list of the 8 top-level frequency choices. */ -export function freqChoices(firstFire: DateTime): FreqChoiceDescriptor[] { - const t = firstFire.toFormat("HH:mm"); - return [ - { id: "none", label: "Don't repeat", hint: "Fires once and ends" }, - { id: "minute", label: "Every N minutes", hint: "Sub-hour cadence" }, - { id: "hour", label: "Every N hours", hint: `At minute :${firstFire.toFormat("mm")}` }, - { id: "day", label: `Every day at ${t}` }, - { id: "week", label: `Every week at ${t}`, hint: "Choose which weekdays" }, - { id: "month", label: `Every month at ${t}`, hint: "Choose which day of the month" }, - { id: "year", label: `Every year at ${t}`, hint: "Choose which month and day" }, - { id: "cron", label: "Custom cron expression…", hint: "Power-user — full combinational control" }, - ]; -}