From 63b88c69b49fcaa0bb6b9535d8404f5473030a05 Mon Sep 17 00:00:00 2001 From: yiekheng Date: Sun, 10 May 2026 10:32:29 +0800 Subject: [PATCH] feat(recurrence): cron-only Repeats picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per the user's ask: drop the friendly RRULE-based shortcuts (Daily / Weekly / Custom… etc.) — every selectable preset is now a cron expression. Schedules are stored in `reminders.rrule` with the `CRON:` sentinel and dispatched via the existing cron-aware `nextOccurrence` helper. Picker - "Don't repeat" stays at the top (one-off, no cron). - 11 cron-flavoured presets, each with its underlying cron expression shown as the hint: Every minute * * * * * Every 5 minutes */5 * * * * Every 15 minutes */15 * * * * Every 30 minutes */30 * * * * Every hour at :MM MM * * * * Every day at HH:MM MM HH * * * Every weekday at HH:MM MM HH * * 1-5 Every weekend at HH:MM MM HH * * 0,6 Every at HH:MM MM HH * * Every month on day D at HH:MM MM HH D * * Every year on Mon D at HH:MM MM HH D M * - Labels are first-fire-aware: changing the time picker re-derives every "at HH:MM" label and the preset's canonical cron string. - "Custom cron expression…" reveals a free-form text input for anything not covered by the presets. - Removed: the old "Custom" RRULE detail panel (frequency dropdown, weekday picker, monthday input, end-condition picker). Storage - `presetToSpec("none")` → kind:"none". Every other preset → kind:"cron" with its canonical cron string. - `matchPreset` compares the spec's cron expression against each preset's canonical cron for the current first-fire — falls back to "cron" (custom textbox) for anything else, including legacy RRULE reminders that haven't been re-saved yet. Existing RRULE reminders keep firing on the bot side (nextOccurrence still dispatches both). - `presetCron(id, firstFire)` is a small pure helper; ISO weekday (1=Mon..7=Sun) maps to cron weekday (0=Sun..6=Sat). Tests (+8 in recurrence.test.ts; 137 web + 26 bot + 17 shared = 180) - presetToSpec emits the right cron for every recurring preset including Sunday → cron weekday 0. - matchPreset round-trips through presetToSpec for every preset. - matchPreset returns "cron" for arbitrary (non-preset) cron strings. - presetDescriptors lists exactly the cron-only items in order with first-fire-aware labels ("Every weekday at 09:00", "Every Wed at 09:00", "Every year on May 13 at 09:00", "Custom cron expression…"). - buildRrule produces CRON: prefixed strings for cron presets and null for "none". Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/web/src/components/recurrence-picker.tsx | 344 +++--------------- apps/web/src/lib/recurrence.test.ts | 167 ++++----- apps/web/src/lib/recurrence.ts | 208 ++++++----- 3 files changed, 234 insertions(+), 485 deletions(-) diff --git a/apps/web/src/components/recurrence-picker.tsx b/apps/web/src/components/recurrence-picker.tsx index 90d44cd..9ed0be3 100644 --- a/apps/web/src/components/recurrence-picker.tsx +++ b/apps/web/src/components/recurrence-picker.tsx @@ -1,82 +1,50 @@ "use client"; -import { useState } from "react"; import { DateTime } from "luxon"; -import { - CheckIcon, - ChevronDownIcon, - ChevronUpIcon, - RepeatIcon, -} from "lucide-react"; +import { CheckIcon, RepeatIcon } from "lucide-react"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { cn } from "@/lib/utils"; import { - WEEKDAY_LABELS, matchPreset, presetDescriptors, presetToSpec, - type EndKind, type PresetId, - type RecurrenceKind, type RecurrenceSpec, } from "@/lib/recurrence"; interface RecurrencePickerProps { - /** First fire of the reminder — drives preset labels (e.g. "Every week on Wed"). */ + /** First fire of the reminder — drives preset labels and the cron strings + * for "Every day at HH:MM" / "Every weekday at HH:MM" / etc. */ firstFire: DateTime; value: RecurrenceSpec; onChange: (next: RecurrenceSpec) => void; } -const FREQ_UNIT: Record, string> = { - daily: "day", - weekly: "week", - monthly: "month", - yearly: "year", -}; - -/** - * Reminder repeat picker. - * - * ┌──────────────────────────────────────────────────────┐ - * │ ○ Don't repeat (one-off) │ - * │ ○ Every day │ - * │ ○ Every weekday Mon – Fri │ - * │ ○ Every weekend Sat – Sun │ - * │ ○ Every week on Wed Same weekday as start │ - * │ ○ Every month on day 13 … │ - * │ ○ Every year on May 13 │ - * │ ○ Custom… (expands the full power-user controls) │ - * └──────────────────────────────────────────────────────┘ - * - * Selecting a preset overwrites the spec to the canonical preset value. - * "Custom…" reveals interval / weekday picker / day-of-month / end - * controls so the user can build any RRULE we support. - */ +// 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. export function RecurrencePicker({ firstFire, value, onChange }: RecurrencePickerProps) { const activePreset = matchPreset(value, firstFire); - // The user can also expand the custom panel manually even when the - // current spec matches a preset — useful for "I want every 2 weeks - // on Mon, Wed". Default to expanded whenever the spec is custom. - const [forceCustomOpen, setForceCustomOpen] = useState(false); - const customExpanded = activePreset === "custom" || forceCustomOpen; function pickPreset(id: PresetId) { - if (id === "custom") { - setForceCustomOpen(true); - // If the spec is currently a preset, seed the custom panel with - // its canonical form so editing starts from a sensible baseline. - if (activePreset !== "custom") { - onChange(presetToSpec("weekly_same", firstFire)); - } - return; - } - setForceCustomOpen(false); - if (id === "cron") { - // Preserve any cron expression the user already typed. - if (value.kind === "cron") return; - onChange(presetToSpec("cron", firstFire)); + if (id === "cron" && value.kind === "cron") { + // Already in custom mode — preserve whatever the user has typed. return; } onChange(presetToSpec(id, firstFire)); @@ -107,7 +75,6 @@ export function RecurrencePicker({ firstFire, value, onChange }: RecurrencePicke : "hover:bg-muted text-foreground", )} > - {/* Radio dot */} {p.label} {p.hint && ( - {p.hint} + + {p.id === "none" || p.id === "cron" ? p.hint : ( + {p.hint} + )} + )} - - {p.id === "custom" && ( - - {customExpanded ? ( - - ) : ( - - )} - - )} ); })} - {customExpanded && ( - - )} - {activePreset === "cron" && ( - + )} ); } -function CronPanel({ +function CronInput({ value, onChange, }: { value: RecurrenceSpec; onChange: (next: RecurrenceSpec) => void; }) { + const cron = value.kind === "cron" ? value.cron ?? "" : ""; return (
onChange({ ...value, cron: e.target.value })} + value={cron} + onChange={(e) => + 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} @@ -182,235 +148,9 @@ function CronPanel({
  • 0 9 * * 1-5 — 9 am on weekdays
  • */15 * * * * — every 15 minutes
  • -
  • 0 9,12,18 * * * — 9, 12, and 18 every day
  • +
  • 0 9,12,18 * * * — 9, 12, 18 every day
  • 0 0 1 * * — midnight on the 1st of every month
-

- The Date+Time controls above are ignored when a cron expression is set; - cron drives the schedule entirely. The first fire is the next time the - cron expression matches after now. -

-
- ); -} - -interface CustomPanelProps { - firstFire: DateTime; - value: RecurrenceSpec; - onChange: (next: RecurrenceSpec) => void; -} - -function CustomPanel({ firstFire, value, onChange }: CustomPanelProps) { - const kind = value.kind === "none" ? "weekly" : value.kind; - - function setKind(k: RecurrenceKind) { - if (k === "none") { - onChange(presetToSpec("none", firstFire)); - return; - } - onChange({ - ...value, - kind: k, - // Seed weeklyDays with the first-fire weekday when flipping into - // weekly so the spec is concrete. - weeklyDays: - k === "weekly" && value.weeklyDays.length === 0 - ? [firstFire.weekday] - : value.weeklyDays, - monthDay: - k === "monthly" && value.monthDay === undefined - ? firstFire.day - : value.monthDay, - }); - } - - function setInterval(n: number) { - const safe = Number.isFinite(n) && n >= 1 ? Math.floor(n) : 1; - onChange({ ...value, interval: safe }); - } - - function toggleWeekday(iso: number) { - const next = value.weeklyDays.includes(iso) - ? value.weeklyDays.filter((d) => d !== iso) - : [...value.weeklyDays, iso].sort((a, b) => a - b); - onChange({ ...value, weeklyDays: next }); - } - - function setMonthDay(n: number | "") { - if (n === "") { - onChange({ ...value, monthDay: undefined }); - return; - } - if (n >= 1 && n <= 31) onChange({ ...value, monthDay: n }); - } - - function setEndKind(k: EndKind) { - if (k === "never") onChange({ ...value, end: { kind: "never" } }); - else if (k === "after") - onChange({ - ...value, - end: { kind: "after", count: value.end.kind === "after" ? value.end.count : 10 }, - }); - else - onChange({ - ...value, - end: { kind: "on", until: value.end.kind === "on" ? value.end.until : "" }, - }); - } - - return ( -
- {/* Frequency */} -
- - -
- - {/* Interval */} -
- - setInterval(Number(e.target.value))} - className="h-8 w-20" - /> - - {FREQ_UNIT[kind]} - {value.interval === 1 ? "" : "s"} - -
- - {/* Weekly day picker */} - {kind === "weekly" && ( -
- -
- {WEEKDAY_LABELS.map(({ iso, short }) => { - const active = value.weeklyDays.includes(iso); - return ( - - ); - })} -
-
- )} - - {/* Monthly day-of-month */} - {kind === "monthly" && ( -
- - { - const v = e.target.value; - if (v === "") setMonthDay(""); - else setMonthDay(Number(v)); - }} - placeholder={String(firstFire.day)} - className="h-8 w-24" - /> -

- Months without this day skip naturally (e.g. 31st). -

-
- )} - - {/* End condition */} -
- -
- {(["never", "after", "on"] as const).map((v) => { - const active = value.end.kind === v; - const label = v === "never" ? "Never" : v === "after" ? "After…" : "On date…"; - return ( - - ); - })} -
- {value.end.kind === "after" && ( -
- { - const n = Number(e.target.value); - onChange({ - ...value, - end: { kind: "after", count: Number.isFinite(n) && n >= 1 ? n : 1 }, - }); - }} - className="h-8 w-24" - /> - - occurrence{value.end.count === 1 ? "" : "s"} - -
- )} - {value.end.kind === "on" && ( -
- - onChange({ ...value, end: { kind: "on", until: e.target.value } }) - } - className="h-8 w-44" - /> -
- )} -
); } diff --git a/apps/web/src/lib/recurrence.test.ts b/apps/web/src/lib/recurrence.test.ts index 1643750..cc01d46 100644 --- a/apps/web/src/lib/recurrence.test.ts +++ b/apps/web/src/lib/recurrence.test.ts @@ -163,115 +163,102 @@ describe("specFromRrule / kindFromRrule", () => { }); }); -describe("preset shortcuts (Repeats picker)", () => { - // FIRST is 2026-05-13 = Wednesday (ISO weekday 3), day 13, May. - it("presetToSpec produces the canonical RecurrenceSpec for each shortcut", () => { +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("daily", FIRST)).toMatchObject({ kind: "daily", interval: 1 }); - expect(presetToSpec("weekdays", FIRST)).toMatchObject({ - kind: "weekly", - weeklyDays: [1, 2, 3, 4, 5], + 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 *" }); + }); + + 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", }); - expect(presetToSpec("weekends", FIRST)).toMatchObject({ - kind: "weekly", - weeklyDays: [6, 7], - }); - expect(presetToSpec("weekly_same", FIRST)).toMatchObject({ - kind: "weekly", - weeklyDays: [3], // Wed - }); - expect(presetToSpec("monthly_same", FIRST)).toMatchObject({ - kind: "monthly", - monthDay: 13, - }); - expect(presetToSpec("yearly_same", FIRST)).toMatchObject({ kind: "yearly" }); }); it("matchPreset round-trips through presetToSpec for every preset", () => { - for (const id of ["none", "daily", "weekdays", "weekends", "weekly_same", "monthly_same", "yearly_same"] as const) { + const ids = [ + "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); } }); - it("matchPreset returns 'custom' for anything that doesn't fit a shortcut", () => { - // Interval > 1 doesn't match any preset. + it("matchPreset returns 'cron' for an arbitrary cron string", () => { expect( matchPreset( - { kind: "daily", interval: 2, weeklyDays: [], end: { kind: "never" } }, - FIRST, - ), - ).toBe("custom"); - // Weekly Mon/Wed/Fri isn't a known shortcut. - expect( - matchPreset( - { kind: "weekly", interval: 1, weeklyDays: [1, 3, 5], end: { kind: "never" } }, - FIRST, - ), - ).toBe("custom"); - // End=after takes us out of the preset matrix even at interval=1. - expect( - matchPreset( - { kind: "daily", interval: 1, weeklyDays: [], end: { kind: "after", count: 5 } }, - FIRST, - ), - ).toBe("custom"); - // Monthly on a different day-of-month than the first fire. - expect( - matchPreset( - { kind: "monthly", interval: 1, weeklyDays: [], monthDay: 1, end: { kind: "never" } }, - FIRST, - ), - ).toBe("custom"); - }); - - it("presetDescriptors returns the full preset list with first-fire-aware labels", () => { - const items = presetDescriptors(FIRST); - expect(items.map((d) => d.id)).toEqual([ - "none", - "daily", - "weekdays", - "weekends", - "weekly_same", - "monthly_same", - "yearly_same", - "custom", - "cron", - ]); - // Labels should be parameterised by firstFire. - expect(items.find((d) => d.id === "weekly_same")?.label).toBe("Every week on Wed"); - expect(items.find((d) => d.id === "monthly_same")?.label).toBe( - "Every month on day 13", - ); - expect(items.find((d) => d.id === "yearly_same")?.label).toBe( - "Every year on May 13", - ); - expect(items.find((d) => d.id === "cron")?.label).toBe("Cron expression…"); - }); - - it("presetToSpec('cron') seeds a daily-at-the-first-fire cron", () => { - const spec = presetToSpec("cron", FIRST); - expect(spec.kind).toBe("cron"); - // FIRST is 09:00, so default cron = "0 9 * * *" (every day at 9:00). - expect(spec.cron).toBe("0 9 * * *"); - }); - - it("matchPreset returns 'cron' for any cron-kind spec", () => { - expect( - matchPreset( - { kind: "cron", interval: 1, weeklyDays: [], cron: "0 9 * * 1-5", end: { kind: "never" } }, + { kind: "cron", interval: 1, weeklyDays: [], cron: "*/7 * * * *", end: { kind: "never" } }, FIRST, ), ).toBe("cron"); }); - it("buildRrule produces a CRON: prefixed string for cron specs", () => { + 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("buildRrule produces a CRON: prefixed string for every cron preset", () => { expect( - buildRrule( - { kind: "cron", interval: 1, weeklyDays: [], cron: "0 9 * * 1-5", end: { kind: "never" } }, - FIRST, - ), + 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", () => { diff --git a/apps/web/src/lib/recurrence.ts b/apps/web/src/lib/recurrence.ts index bc6fa74..dc38993 100644 --- a/apps/web/src/lib/recurrence.ts +++ b/apps/web/src/lib/recurrence.ts @@ -216,31 +216,79 @@ export function kindFromRrule(rrule: string | null | undefined): RecurrenceKind } // --------------------------------------------------------------------------- -// Preset shortcuts for the Repeats picker +// 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. // --------------------------------------------------------------------------- export type PresetId = | "none" - | "daily" - | "weekdays" - | "weekends" - | "weekly_same" - | "monthly_same" - | "yearly_same" - | "custom" + | "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"; +/** Map ISO weekday (1=Mon..7=Sun) → cron weekday (0=Sun..6=Sat). */ +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 { + 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": + 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} *`; + } +} + export interface PresetDescriptor { id: PresetId; /** Short label shown in the radio list. */ 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 spec depends on the chosen first fire — e.g. "every - * week on the same weekday" means whatever weekday firstFire lands on). + * 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 = { @@ -249,111 +297,85 @@ export function presetToSpec(id: PresetId, firstFire: DateTime): RecurrenceSpec weeklyDays: [], end: { kind: "never" }, }; - switch (id) { - case "none": - return base; - case "daily": - return { ...base, kind: "daily" }; - case "weekdays": - return { ...base, kind: "weekly", weeklyDays: [1, 2, 3, 4, 5] }; - case "weekends": - return { ...base, kind: "weekly", weeklyDays: [6, 7] }; - case "weekly_same": - return { ...base, kind: "weekly", weeklyDays: [firstFire.weekday] }; - case "monthly_same": - return { ...base, kind: "monthly", monthDay: firstFire.day }; - case "yearly_same": - return { ...base, kind: "yearly" }; - case "custom": - // Custom is only meaningful in the picker UI — when the user - // explicitly opts into it, callers should preserve whatever - // detailed spec the user already had. Return a sensible weekly - // default if the caller forgets to pass through. - return { ...base, kind: "weekly", weeklyDays: [firstFire.weekday] }; - case "cron": - // Default cron expression: every day at the first-fire's HH:MM. - return { - ...base, - kind: "cron", - cron: `${firstFire.minute} ${firstFire.hour} * * *`, - }; + 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? * - * Returns "custom" for anything that doesn't match a known shortcut — - * the picker uses that to flip into expanded-detail mode. + * 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"; + if (spec.kind !== "cron") return "cron"; - const sameInterval = spec.interval === 1; - const noEnd = spec.end.kind === "never"; - if (!sameInterval || !noEnd) return "custom"; + const expr = (spec.cron ?? "").trim(); + if (!expr) return "cron"; - const sortedWeeklyDays = (days: number[]) => days.slice().sort((a, b) => a - b).join(","); - - switch (spec.kind) { - case "daily": - return "daily"; - case "weekly": { - const days = spec.weeklyDays.length === 0 ? [firstFire.weekday] : spec.weeklyDays; - const key = sortedWeeklyDays(days); - if (key === "1,2,3,4,5") return "weekdays"; - if (key === "6,7") return "weekends"; - if (key === String(firstFire.weekday)) return "weekly_same"; - return "custom"; - } - case "monthly": - if ((spec.monthDay ?? firstFire.day) === firstFire.day) return "monthly_same"; - return "custom"; - case "yearly": - return "yearly_same"; - case "cron": - return "cron"; - case "none": - return "none"; + 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 (firstFire-aware) labels and hints for the radio list. - * The hint shows the concrete weekday/day-of-month/date the preset - * would imply given the user's chosen first fire. + * 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[] { + 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" }, - { id: "daily", label: "Every day" }, - { id: "weekdays", label: "Every weekday", hint: "Mon – Fri" }, - { id: "weekends", label: "Every weekend", hint: "Sat – Sun" }, - { - id: "weekly_same", - label: `Every week on ${dayShort}`, - hint: "Same weekday as the start date", - }, - { - id: "monthly_same", - label: `Every month on day ${firstFire.day}`, - hint: "Months without this day skip naturally (e.g. 31st)", - }, - { - id: "yearly_same", - label: `Every year on ${firstFire.toFormat("MMM d")}`, - }, - { - id: "custom", - label: "Custom…", - hint: "Set interval, days, and end conditions yourself", - }, + 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: "Cron expression…", - hint: "Full sec/min/hour/day/month/dow combinational power", + label: "Custom cron expression…", + hint: "Write your own — full sec/min/hour/day/month/dow combinational power", }, ]; }