From b67d3c735efba937951ee07b501d16db134b635b Mon Sep 17 00:00:00 2001 From: yiekheng Date: Sun, 10 May 2026 10:54:10 +0800 Subject: [PATCH] feat(recurrence): replace the long preset list with a guided cron flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per the user's ask: stop dumping 12 presets in front of the user. Walk them through "pick a frequency, then configure it." Each choice expands its config inline below the radio. Picker (now 8 top-level choices): ○ Don't repeat (one-off) ○ Every N minutes → number input (1-59) ○ Every N hours → number input (1-23) at :MM ○ Every day at HH:MM (uses outer time picker) ○ Every week at HH:MM → weekday chip multi-select ○ Every month at HH:MM → day-of-month input (1-31) ○ Every year at HH:MM → month select + day input ○ Custom cron expression… → free-form textbox Behaviour: - Selecting a row reveals only that row's config; the others stay collapsed so the screen stays calm. - HH:MM in every "at HH:MM" label tracks the outer time picker — change the time and every label updates instantly. Same for the cron expression the picker emits. - Every config change recompiles to a single cron string and pushes a `{ kind: "cron", cron: "..." }` spec up to the parent. Empty weekday list yields null (config not yet valid). - Editing an existing reminder calls `flowFromCron(rule, firstFire)` which reverse-engineers a flow state from the stored cron — including expanding `1-5` ranges into a weekday chip list — so the right radio is highlighted and config inputs are pre-populated. - Anything not recognised by `flowFromCron` (legacy RRULE, hand-rolled cron) lands on "Custom cron expression…" with the literal expression in the textbox. Helpers in `lib/recurrence.ts`: - `FreqChoice` ("none" | "minute" | "hour" | "day" | "week" | "month" | "year" | "cron") + `FlowState` interface with all config fields. - `freqChoices(firstFire)` → first-fire-aware label list for the radio. - `defaultFlowState(firstFire)` → seeds sensible defaults (today's weekday, day-of-month, month, etc.). - `flowToCron(flow, firstFire)` → cron string or null. Clamps out-of-range integers. - `flowFromCron(rule, firstFire)` → best-effort reverse mapping. - `isoWeekdayToCron(iso)` → maps ISO 1-7 (Mon..Sun) to cron 0-6 (Sun..Sat). Removed: the previous `presetToSpec` / `matchPreset` / `presetDescriptors` + `presetCron` family. They're superseded by the flow helpers. Tests (+11 in recurrence.test.ts; total 139 web + 26 bot + 17 shared = 182): - freqChoices order and time-bearing labels - flowToCron for every freq + config combination, including empty weekday list returning null - clamp behaviour for out-of-range minute/month-day/month integers - isoWeekdayToCron for Mon..Sun - defaultFlowState seeded fields - flowFromCron round-trips every flow output exactly - BYDAY range expansion (1-5 → [1,2,3,4,5]) - unrecognised expressions land on the cron textbox - buildRrule + specFromRrule still handle CRON: prefixed strings Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/web/src/components/recurrence-picker.tsx | 353 +++++++++++++----- apps/web/src/lib/recurrence.test.ts | 220 ++++++----- apps/web/src/lib/recurrence.ts | 281 +++++++------- 3 files changed, 533 insertions(+), 321 deletions(-) diff --git a/apps/web/src/components/recurrence-picker.tsx b/apps/web/src/components/recurrence-picker.tsx index 9ed0be3..e6da99c 100644 --- a/apps/web/src/components/recurrence-picker.tsx +++ b/apps/web/src/components/recurrence-picker.tsx @@ -1,54 +1,75 @@ "use client"; +import { useEffect, useState } from "react"; import { DateTime } from "luxon"; import { CheckIcon, RepeatIcon } from "lucide-react"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { cn } from "@/lib/utils"; import { - matchPreset, - presetDescriptors, - presetToSpec, - type PresetId, + WEEKDAY_LABELS, + defaultFlowState, + flowFromCron, + flowToCron, + freqChoices, + isoWeekdayToCron, + type FlowState, + type FreqChoice, type RecurrenceSpec, } from "@/lib/recurrence"; interface RecurrencePickerProps { - /** First fire of the reminder — drives preset labels and the cron strings - * for "Every day at HH:MM" / "Every weekday at HH:MM" / etc. */ + /** 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. */ firstFire: DateTime; value: RecurrenceSpec; onChange: (next: RecurrenceSpec) => void; } -// Cron-only Repeats picker. -// -// ┌─────────────────────────────────────────────────────┐ -// │ ○ Don't repeat Fires once and ends │ -// │ ○ Every minute every minute │ -// │ ○ Every 5/15/30 minutes every N minutes │ -// │ ○ Every hour at :00 │ -// │ ○ Every day at HH:MM │ -// │ ○ Every weekday/weekend at HH:MM │ -// │ ○ Every at HH:MM │ -// │ ○ Every month on day at HH:MM │ -// │ ○ Every year on at HH:MM │ -// │ ○ Custom cron expression… │ -// └─────────────────────────────────────────────────────┘ -// -// Selecting a preset sets `value` to `{ kind: "cron", cron: "" }`. -// "Don't repeat" sets `kind: "none"`. "Custom cron…" reveals a free- -// form text input. +/** + * Guided cron flow. + * + * 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. + * + * "Don't repeat" is a one-click exit (no config). "Custom cron…" lets + * a power-user type any expression directly. + */ export function RecurrencePicker({ firstFire, value, onChange }: RecurrencePickerProps) { - const activePreset = matchPreset(value, firstFire); + // 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), + ); - function pickPreset(id: PresetId) { - if (id === "cron" && value.kind === "cron") { - // Already in custom mode — preserve whatever the user has typed. + // Re-derive the cron when either the flow or the first-fire changes + // (changing the time picker outside should refresh "at HH:MM"). + useEffect(() => { + const cron = flowToCron(flow, firstFire); + if (!cron) { + if (value.kind !== "none") { + onChange({ kind: "none", interval: 1, weeklyDays: [], end: { kind: "never" } }); + } return; } - onChange(presetToSpec(id, firstFire)); - } + if (value.kind !== "cron" || value.cron !== cron) { + onChange({ + kind: "cron", + interval: 1, + weeklyDays: [], + cron, + end: { kind: "never" }, + }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [flow, firstFire.minute, firstFire.hour, firstFire.day, firstFire.month, firstFire.weekday]); + + const update = (k: K, v: FlowState[K]) => + setFlow((prev) => ({ ...prev, [k]: v })); return (
@@ -59,20 +80,18 @@ export function RecurrencePicker({ firstFire, value, onChange }: RecurrencePicke
    - {presetDescriptors(firstFire).map((p) => { - const selected = activePreset === p.id; + {freqChoices(firstFire).map((c) => { + const selected = flow.freq === c.id; return ( -
  • +
  • + + {selected && ( + + )}
  • ); })}
- - {activePreset === "cron" && ( - - )}
); } -function CronInput({ - value, - onChange, -}: { - value: RecurrenceSpec; - onChange: (next: RecurrenceSpec) => void; -}) { - const cron = value.kind === "cron" ? value.cron ?? "" : ""; - return ( -
- - - onChange({ - kind: "cron", - interval: 1, - weeklyDays: [], - cron: e.target.value, - end: { kind: "never" }, - }) - } - placeholder="0 9 * * 1-5" - className="h-8 font-mono text-sm" - spellCheck={false} - /> -

- Standard 5-field cron (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
  • -
+// --------------------------------------------------------------------------- +// Per-frequency config panels +// --------------------------------------------------------------------------- + +interface FreqConfigProps { + flow: FlowState; + firstFire: DateTime; + update: (k: K, v: FlowState[K]) => void; +} + +function FreqConfig({ flow, firstFire, update }: FreqConfigProps) { + const cron = flowToCron(flow, firstFire); + const wrap = (children: React.ReactNode) => ( +
+ {children} + {cron ? ( +

+ Cron: {cron} +

+ ) : null}
); + + 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 cc01d46..eb7f1d7 100644 --- a/apps/web/src/lib/recurrence.test.ts +++ b/apps/web/src/lib/recurrence.test.ts @@ -2,12 +2,15 @@ import { describe, it, expect } from "vitest"; import { DateTime } from "luxon"; import { buildRrule, + defaultFlowState, describeRecurrence, + flowFromCron, + flowToCron, + freqChoices, + isoWeekdayToCron, kindFromRrule, - matchPreset, - presetDescriptors, - presetToSpec, specFromRrule, + type FlowState, type RecurrenceSpec, } from "./recurrence"; @@ -163,105 +166,138 @@ describe("specFromRrule / kindFromRrule", () => { }); }); -describe("preset shortcuts (cron-only Repeats picker)", () => { - // FIRST is 2026-05-13 09:00 = Wednesday (ISO weekday 3, cron 3), - // day 13, May. - it("presetToSpec emits a cron-kind spec for every recurring preset", () => { - expect(presetToSpec("none", FIRST)).toMatchObject({ kind: "none" }); - expect(presetToSpec("every_minute", FIRST)).toMatchObject({ kind: "cron", cron: "* * * * *" }); - expect(presetToSpec("every_5min", FIRST)).toMatchObject({ kind: "cron", cron: "*/5 * * * *" }); - expect(presetToSpec("every_15min", FIRST)).toMatchObject({ kind: "cron", cron: "*/15 * * * *" }); - expect(presetToSpec("every_30min", FIRST)).toMatchObject({ kind: "cron", cron: "*/30 * * * *" }); - expect(presetToSpec("every_hour", FIRST)).toMatchObject({ kind: "cron", cron: "0 * * * *" }); - expect(presetToSpec("every_day", FIRST)).toMatchObject({ kind: "cron", cron: "0 9 * * *" }); - expect(presetToSpec("every_weekday", FIRST)).toMatchObject({ kind: "cron", cron: "0 9 * * 1-5" }); - expect(presetToSpec("every_weekend", FIRST)).toMatchObject({ kind: "cron", cron: "0 9 * * 0,6" }); - // Wed = ISO 3 = cron 3. - expect(presetToSpec("every_same_dow", FIRST)).toMatchObject({ kind: "cron", cron: "0 9 * * 3" }); - expect(presetToSpec("every_month_dom", FIRST)).toMatchObject({ kind: "cron", cron: "0 9 13 * *" }); - expect(presetToSpec("every_year", FIRST)).toMatchObject({ kind: "cron", cron: "0 9 13 5 *" }); +describe("cron flow — frequency choice + per-frequency config", () => { + const baseFlow = (over: Partial = {}): FlowState => ({ + ...defaultFlowState(FIRST), + ...over, }); - it("Sunday first-fire maps to cron weekday 0", () => { - const sunday = DateTime.fromISO("2026-05-17T09:00:00", { zone: "Asia/Kuala_Lumpur" }); - expect(presetToSpec("every_same_dow", sunday)).toMatchObject({ - kind: "cron", - cron: "0 9 * * 0", - }); - }); - - it("matchPreset round-trips through presetToSpec for every preset", () => { - const ids = [ + it("freqChoices lists exactly the 8 top-level options in order", () => { + expect(freqChoices(FIRST).map((c) => c.id)).toEqual([ "none", - "every_minute", - "every_5min", - "every_15min", - "every_30min", - "every_hour", - "every_day", - "every_weekday", - "every_weekend", - "every_same_dow", - "every_month_dom", - "every_year", - ] as const; - for (const id of ids) { - const spec = presetToSpec(id, FIRST); - expect(matchPreset(spec, FIRST)).toBe(id); + "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 *"); + }); + + it("isoWeekdayToCron maps Sun (ISO 7) to cron 0 and Mon-Sat unchanged", () => { + expect(isoWeekdayToCron(1)).toBe(1); // Mon + expect(isoWeekdayToCron(2)).toBe(2); + expect(isoWeekdayToCron(6)).toBe(6); // Sat + 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("matchPreset returns 'cron' for an arbitrary cron string", () => { - expect( - matchPreset( - { kind: "cron", interval: 1, weeklyDays: [], cron: "*/7 * * * *", end: { kind: "never" } }, - FIRST, - ), - ).toBe("cron"); + 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("presetDescriptors lists exactly the cron-only items in order", () => { - const items = presetDescriptors(FIRST); - expect(items.map((d) => d.id)).toEqual([ - "none", - "every_minute", - "every_5min", - "every_15min", - "every_30min", - "every_hour", - "every_day", - "every_weekday", - "every_weekend", - "every_same_dow", - "every_month_dom", - "every_year", - "cron", - ]); - // First-fire-aware labels carry the chosen time. - expect(items.find((d) => d.id === "every_day")?.label).toBe("Every day at 09:00"); - expect(items.find((d) => d.id === "every_weekday")?.label).toBe( - "Every weekday at 09:00", - ); - expect(items.find((d) => d.id === "every_same_dow")?.label).toBe( - "Every Wed at 09:00", - ); - expect(items.find((d) => d.id === "every_year")?.label).toBe( - "Every year on May 13 at 09:00", - ); - expect(items.find((d) => d.id === "cron")?.label).toBe( - "Custom cron expression…", - ); + 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 produces a CRON: prefixed string for every cron preset", () => { - expect( - buildRrule(presetToSpec("every_weekday", FIRST), FIRST), - ).toBe("CRON:0 9 * * 1-5"); - expect(buildRrule(presetToSpec("every_5min", FIRST), FIRST)).toBe("CRON:*/5 * * * *"); - expect(buildRrule(presetToSpec("none", FIRST), FIRST)).toBe(null); - }); - - it("specFromRrule round-trips a CRON: prefixed rule", () => { + it("buildRrule + specFromRrule still round-trip CRON: rules", () => { + const spec: RecurrenceSpec = { + kind: "cron", + interval: 1, + weeklyDays: [], + cron: "*/15 * * * *", + end: { kind: "never" }, + }; + expect(buildRrule(spec, FIRST)).toBe("CRON:*/15 * * * *"); expect(specFromRrule("CRON:*/15 * * * *")).toMatchObject({ kind: "cron", cron: "*/15 * * * *", diff --git a/apps/web/src/lib/recurrence.ts b/apps/web/src/lib/recurrence.ts index dc38993..06444d2 100644 --- a/apps/web/src/lib/recurrence.ts +++ b/apps/web/src/lib/recurrence.ts @@ -216,166 +216,167 @@ export function kindFromRrule(rrule: string | null | undefined): RecurrenceKind } // --------------------------------------------------------------------------- -// Preset shortcuts for the Repeats picker — every shortcut is a cron -// expression. The picker is cron-only: schedules are stored in the -// `reminders.rrule` column with the `CRON:` sentinel, and the bot's -// shared `nextOccurrence` helper dispatches them to cron-parser. +// 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 PresetId = +export type FreqChoice = | "none" - | "every_minute" - | "every_5min" - | "every_15min" - | "every_30min" - | "every_hour" - | "every_day" - | "every_weekday" - | "every_weekend" - | "every_same_dow" - | "every_month_dom" - | "every_year" + | "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). */ -function isoWeekdayToCron(iso: number): number { +export function isoWeekdayToCron(iso: number): number { return iso === 7 ? 0 : iso; } -/** Build the canonical cron string for a preset given the user's first-fire DateTime. */ -function presetCron(id: Exclude, firstFire: DateTime): string { +/** 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 day = firstFire.day; - const month = firstFire.month; - const cronDow = isoWeekdayToCron(firstFire.weekday); - switch (id) { - case "every_minute": - return "* * * * *"; - case "every_5min": - return "*/5 * * * *"; - case "every_15min": - return "*/15 * * * *"; - case "every_30min": - return "*/30 * * * *"; - case "every_hour": - return `${m} * * * *`; - case "every_day": + 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 "every_weekday": - return `${m} ${h} * * 1-5`; - case "every_weekend": - return `${m} ${h} * * 0,6`; - case "every_same_dow": - return `${m} ${h} * * ${cronDow}`; - case "every_month_dom": - return `${m} ${h} ${day} * *`; - case "every_year": - return `${m} ${h} ${day} ${month} *`; + 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; } } -export interface PresetDescriptor { - id: PresetId; - /** Short label shown in the radio list. */ +/** + * 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; - /** Optional one-line hint shown beneath the label. */ hint?: string; - /** The cron expression this preset resolves to (omitted for "none"). */ - cron?: string; } -/** - * Build the canonical RecurrenceSpec for a preset given the first-fire - * DateTime. The picker is cron-only — every recurring preset emits a - * `{ kind: "cron", cron: "..." }` spec; "none" is one-off; "cron" is - * the free-form custom expression (caller seeds the input separately). - */ -export function presetToSpec(id: PresetId, firstFire: DateTime): RecurrenceSpec { - const base: RecurrenceSpec = { - kind: "none", - interval: 1, - weeklyDays: [], - end: { kind: "never" }, - }; - if (id === "none") return base; - if (id === "cron") { - // Default seed for the custom textbox — every day at the first - // fire's HH:MM. The user is free to overwrite. - return { ...base, kind: "cron", cron: `${firstFire.minute} ${firstFire.hour} * * *` }; - } - return { ...base, kind: "cron", cron: presetCron(id, firstFire) }; -} - -/** - * Reverse mapping: which preset (if any) does this spec match? - * - * For a cron spec, compare the cron expression against each preset's - * canonical cron (built from `firstFire`). Anything that doesn't match - * a shortcut falls back to "cron" (the custom textbox shows the literal - * expression). Legacy RRULE specs (kinds daily/weekly/monthly/yearly) - * are not picker-presets — return "cron" so the picker reads as - * "schedule set externally; pick a cron to update". - */ -export function matchPreset(spec: RecurrenceSpec, firstFire: DateTime): PresetId { - if (spec.kind === "none") return "none"; - if (spec.kind !== "cron") return "cron"; - - const expr = (spec.cron ?? "").trim(); - if (!expr) return "cron"; - - const ids: Array> = [ - "every_minute", - "every_5min", - "every_15min", - "every_30min", - "every_hour", - "every_day", - "every_weekday", - "every_weekend", - "every_same_dow", - "every_month_dom", - "every_year", - ]; - for (const id of ids) { - if (presetCron(id, firstFire) === expr) return id; - } - return "cron"; -} - -/** - * Render the cron-flavoured radio list. Every recurring preset shows - * its underlying cron expression as the hint so the user can see what - * each shortcut compiles to. The labels are first-fire-aware — picking - * a different time updates "Every day at HH:MM" in place. - */ -export function presetDescriptors(firstFire: DateTime): PresetDescriptor[] { +/** First-fire-aware list of the 8 top-level frequency choices. */ +export function freqChoices(firstFire: DateTime): FreqChoiceDescriptor[] { const t = firstFire.toFormat("HH:mm"); - const dayShort = WEEKDAY_LABELS[firstFire.weekday - 1]?.short ?? ""; - const monShort = firstFire.toFormat("MMM d"); - const dom = firstFire.day; - - const item = ( - id: Exclude, - label: string, - ): PresetDescriptor => ({ id, label, hint: presetCron(id, firstFire), cron: presetCron(id, firstFire) }); - return [ { id: "none", label: "Don't repeat", hint: "Fires once and ends" }, - item("every_minute", "Every minute"), - item("every_5min", "Every 5 minutes"), - item("every_15min", "Every 15 minutes"), - item("every_30min", "Every 30 minutes"), - item("every_hour", `Every hour at :${firstFire.toFormat("mm")}`), - item("every_day", `Every day at ${t}`), - item("every_weekday", `Every weekday at ${t}`), - item("every_weekend", `Every weekend at ${t}`), - item("every_same_dow", `Every ${dayShort} at ${t}`), - item("every_month_dom", `Every month on day ${dom} at ${t}`), - item("every_year", `Every year on ${monShort} at ${t}`), - { - id: "cron", - label: "Custom cron expression…", - hint: "Write your own — full sec/min/hour/day/month/dow combinational power", - }, + { 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" }, ]; }