feat(recurrence): replace the long preset list with a guided cron flow

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) <noreply@anthropic.com>
This commit is contained in:
yiekheng 2026-05-10 10:54:10 +08:00
parent 63b88c69b4
commit b67d3c735e
3 changed files with 533 additions and 321 deletions

View File

@ -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 <weekday> at HH:MM │
// │ ○ Every month on day <D> at HH:MM │
// │ ○ Every year on <Mon D> at HH:MM │
// │ ○ Custom cron expression… │
// └─────────────────────────────────────────────────────┘
//
// Selecting a preset sets `value` to `{ kind: "cron", cron: "<expr>" }`.
// "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<FlowState>(() =>
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 extends keyof FlowState>(k: K, v: FlowState[K]) =>
setFlow((prev) => ({ ...prev, [k]: v }));
return (
<div className="space-y-2">
@ -59,20 +80,18 @@ export function RecurrencePicker({ firstFire, value, onChange }: RecurrencePicke
<div className="overflow-hidden rounded-xl border border-border bg-card">
<ul className="divide-y divide-border" role="radiogroup" aria-label="Repeat schedule">
{presetDescriptors(firstFire).map((p) => {
const selected = activePreset === p.id;
{freqChoices(firstFire).map((c) => {
const selected = flow.freq === c.id;
return (
<li key={p.id}>
<li key={c.id}>
<button
type="button"
role="radio"
aria-checked={selected}
onClick={() => pickPreset(p.id)}
onClick={() => update("freq", c.id)}
className={cn(
"flex w-full items-center gap-3 px-4 py-3 text-left transition-colors",
selected
? "bg-primary/5 text-foreground"
: "hover:bg-muted text-foreground",
selected ? "bg-primary/5 text-foreground" : "hover:bg-muted text-foreground",
)}
>
<span
@ -86,71 +105,227 @@ export function RecurrencePicker({ firstFire, value, onChange }: RecurrencePicke
>
{selected && <CheckIcon className="size-2.5" strokeWidth={3.5} />}
</span>
<span className="min-w-0 flex-1">
<span className="block text-sm font-medium leading-snug">{p.label}</span>
{p.hint && (
<span className="block text-xs text-muted-foreground">
{p.id === "none" || p.id === "cron" ? p.hint : (
<code className="font-mono">{p.hint}</code>
)}
</span>
<span className="block text-sm font-medium leading-snug">{c.label}</span>
{c.hint && (
<span className="block text-xs text-muted-foreground">{c.hint}</span>
)}
</span>
</button>
{selected && (
<FreqConfig
flow={flow}
firstFire={firstFire}
update={update}
/>
)}
</li>
);
})}
</ul>
{activePreset === "cron" && (
<CronInput value={value} onChange={onChange} />
)}
</div>
</div>
);
}
function CronInput({
value,
onChange,
}: {
value: RecurrenceSpec;
onChange: (next: RecurrenceSpec) => void;
}) {
const cron = value.kind === "cron" ? value.cron ?? "" : "";
return (
<div className="space-y-2 border-t border-border bg-muted/20 p-4">
<Label htmlFor="cron-expr" className="text-xs text-muted-foreground">
Cron expression
</Label>
<Input
id="cron-expr"
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}
/>
<p className="text-xs text-muted-foreground leading-relaxed">
Standard 5-field cron (<code className="font-mono">m h dom mon dow</code>) or
6-field with seconds (<code className="font-mono">s m h dom mon dow</code>).
Examples:
</p>
<ul className="text-xs text-muted-foreground font-mono space-y-0.5 pl-3">
<li><span className="text-foreground">0 9 * * 1-5</span> 9 am on weekdays</li>
<li><span className="text-foreground">*/15 * * * *</span> every 15 minutes</li>
<li><span className="text-foreground">0 9,12,18 * * *</span> 9, 12, 18 every day</li>
<li><span className="text-foreground">0 0 1 * *</span> midnight on the 1st of every month</li>
</ul>
// ---------------------------------------------------------------------------
// Per-frequency config panels
// ---------------------------------------------------------------------------
interface FreqConfigProps {
flow: FlowState;
firstFire: DateTime;
update: <K extends keyof FlowState>(k: K, v: FlowState[K]) => void;
}
function FreqConfig({ flow, firstFire, update }: FreqConfigProps) {
const cron = flowToCron(flow, firstFire);
const wrap = (children: React.ReactNode) => (
<div className="space-y-3 border-t border-border bg-muted/20 px-4 py-3">
{children}
{cron ? (
<p className="text-xs text-muted-foreground">
Cron: <code className="font-mono text-foreground">{cron}</code>
</p>
) : null}
</div>
);
switch (flow.freq) {
case "none":
return null;
case "minute":
return wrap(
<div className="flex items-center gap-2">
<Label htmlFor="rp-minute" className="text-sm">
Every
</Label>
<Input
id="rp-minute"
type="number"
min={1}
max={59}
value={flow.minuteInterval}
onChange={(e) => update("minuteInterval", Number(e.target.value) || 1)}
className="h-8 w-20"
/>
<span className="text-sm text-muted-foreground">
minute{flow.minuteInterval === 1 ? "" : "s"}
</span>
</div>,
);
case "hour":
return wrap(
<div className="flex items-center gap-2">
<Label htmlFor="rp-hour" className="text-sm">
Every
</Label>
<Input
id="rp-hour"
type="number"
min={1}
max={23}
value={flow.hourInterval}
onChange={(e) => update("hourInterval", Number(e.target.value) || 1)}
className="h-8 w-20"
/>
<span className="text-sm text-muted-foreground">
hour{flow.hourInterval === 1 ? "" : "s"} (at minute :{firstFire.toFormat("mm")})
</span>
</div>,
);
case "day":
// No extra config — outer time picker fully specifies the cron.
return wrap(
<p className="text-xs text-muted-foreground">
Uses the time picker above. Adjust the time to change when it fires each day.
</p>,
);
case "week":
return wrap(
<div className="space-y-2">
<Label className="text-sm">On these days</Label>
<div className="flex flex-wrap gap-1.5">
{WEEKDAY_LABELS.map(({ iso, short }) => {
const cronDow = isoWeekdayToCron(iso);
const active = flow.weekdays.includes(cronDow);
return (
<button
key={iso}
type="button"
onClick={() =>
update(
"weekdays",
active
? flow.weekdays.filter((d) => d !== cronDow)
: [...flow.weekdays, cronDow].sort((a, b) => a - b),
)
}
aria-pressed={active}
className={cn(
"inline-flex h-8 min-w-12 items-center justify-center rounded-lg border px-2.5 text-xs font-medium transition-colors",
active
? "border-primary bg-primary text-primary-foreground"
: "border-border bg-background hover:border-primary/50 hover:bg-primary/5 hover:text-primary",
)}
>
{short}
</button>
);
})}
</div>
</div>,
);
case "month":
return wrap(
<div className="flex items-center gap-2">
<Label htmlFor="rp-monthday" className="text-sm">
Day of the month
</Label>
<Input
id="rp-monthday"
type="number"
min={1}
max={31}
value={flow.monthDay}
onChange={(e) => update("monthDay", Number(e.target.value) || 1)}
className="h-8 w-20"
/>
<span className="text-xs text-muted-foreground">
Months without this day skip naturally (e.g. 31st)
</span>
</div>,
);
case "year":
return wrap(
<div className="space-y-2">
<div className="flex flex-wrap items-center gap-2">
<Label htmlFor="rp-month" className="text-sm">
Month
</Label>
<select
id="rp-month"
value={flow.month}
onChange={(e) => update("month", Number(e.target.value))}
className="h-8 rounded-lg border border-input bg-background px-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{[
"January", "February", "March", "April", "May", "June",
"July", "August", "September", "October", "November", "December",
].map((name, i) => (
<option key={name} value={i + 1}>
{name}
</option>
))}
</select>
<Label htmlFor="rp-year-monthday" className="text-sm">
Day
</Label>
<Input
id="rp-year-monthday"
type="number"
min={1}
max={31}
value={flow.monthDay}
onChange={(e) => update("monthDay", Number(e.target.value) || 1)}
className="h-8 w-20"
/>
</div>
</div>,
);
case "cron":
return wrap(
<div className="space-y-2">
<Label htmlFor="rp-cron" className="text-xs text-muted-foreground">
Cron expression
</Label>
<Input
id="rp-cron"
value={flow.customCron}
onChange={(e) => update("customCron", e.target.value)}
placeholder="0 9 * * 1-5"
className="h-8 font-mono text-sm"
spellCheck={false}
/>
<p className="text-xs text-muted-foreground">
5-field (<code className="font-mono">m h dom mon dow</code>) or 6-field
with seconds (<code className="font-mono">s m h dom mon dow</code>). Examples:
</p>
<ul className="text-xs text-muted-foreground font-mono space-y-0.5 pl-3">
<li><span className="text-foreground">0 9 * * 1-5</span> 9 am on weekdays</li>
<li><span className="text-foreground">*/15 * * * *</span> every 15 minutes</li>
<li><span className="text-foreground">0 9,12,18 * * *</span> 9, 12, 18 every day</li>
<li><span className="text-foreground">0 0 1 * *</span> midnight on the 1st of every month</li>
</ul>
</div>,
);
}
}

View File

@ -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> = {}): 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<FlowState>; 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<keyof FlowState>) {
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 * * * *",

View File

@ -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<PresetId, "none" | "cron">, 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<Exclude<PresetId, "none" | "cron">> = [
"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<PresetId, "none" | "cron">,
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" },
];
}