feat(recurrence): redesign as a Temenos-style trigger + dialog picker

The previous flat radio list with N-minutes / N-hours / Custom-cron
options is gone. Per the Temenos UUX `date-recurrence-picker` pattern
(developer.temenos.com/uux/docs/components/date-recurrence-picker), the
form now shows a single read-only trigger field summarising the saved
rule:

  ┌────────────────────────────────────────────────────┐
  │ 📅  Don't repeat                                ▾  │
  └────────────────────────────────────────────────────┘

Clicking the trigger opens a modal with the recurrence types as a
tab strip and per-type config swapped in below:

  ┌──────────────────────────────────────────────────┐
  │  Repeat schedule                            ✕    │
  ├──────────────────────────────────────────────────┤
  │  [Don't repeat] [Daily] [Weekly] [Monthly] [Yearly] │
  │                                                  │
  │  <per-type config>                               │
  │                                                  │
  │  Fires: <plain-text confirmation>                │
  │                                                  │
  │  [Reset]                    [Cancel] [Save]      │
  └──────────────────────────────────────────────────┘

Per-tab config:
  - Don't repeat — informational text only
  - Daily       — radio: "Every day" / "Every weekday (Mon–Fri)"
  - Weekly      — Mon..Sun chip multi-select
  - Monthly     — day-of-month input (1-31)
  - Yearly      — month select + day input

The "Fires: …" sentence updates live as the user edits and reflects
the outer time-picker's HH:MM. Save commits, Cancel discards.

Removed:
  - Every N minutes
  - Every N hours
  - Custom cron expression…
  - The standalone helpers `flowToCron` / `flowFromCron` /
    `freqChoices` / `defaultFlowState` / `FlowState` / `FreqChoice`
    in `lib/recurrence.ts`. Their job (compile a UI state to a cron
    string and parse one back) now lives privately inside the picker.

Storage / runtime
- Output is still a `CRON:` prefixed rule in `reminders.rrule`. The
  bot's `nextOccurrence` already dispatches cron rules through
  cron-parser, so no schema or scheduler changes were needed.

Tests (132 web)
- recurrence.test.ts trimmed to keep only what survives: CRON-rule
  round-trip via buildRrule + specFromRrule, and the ISO→cron
  weekday helper.
- Existing wizard / edit-when-form integration tests are unaffected
  because the picker exports the same `<RecurrencePicker>` props.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
yiekheng 2026-05-10 11:02:16 +08:00
parent b67d3c735e
commit a7a5c6821b
3 changed files with 383 additions and 559 deletions

View File

@ -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 * * <list>"
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 <D> * *"
if ((m = expr.match(/^\d+ \d+ (\d+) \* \*$/))) {
return { ...base, type: "monthly", monthDay: Number(m[1]) };
}
// yearly — "MM HH <D> <M> *"
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<FlowState>(() =>
flowFromCron(value.kind === "cron" ? value.cron ?? null : null, firstFire),
const [open, setOpen] = useState(false);
const [draft, setDraft] = useState<Draft>(() =>
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 extends keyof Draft>(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 extends keyof FlowState>(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 (
<div className="space-y-2">
<div className="space-y-1.5">
<Label className="flex items-center gap-1.5">
<RepeatIcon className="size-3.5" />
Repeats
</Label>
<div className="overflow-hidden rounded-xl border border-border bg-card">
<ul className="divide-y divide-border" role="radiogroup" aria-label="Repeat schedule">
{freqChoices(firstFire).map((c) => {
const selected = flow.freq === c.id;
return (
<li key={c.id}>
<button
type="button"
role="radio"
aria-checked={selected}
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",
)}
>
<span
aria-hidden
className={cn(
"flex size-4 shrink-0 items-center justify-center rounded-full border transition-colors",
selected
? "border-primary bg-primary text-primary-foreground"
: "border-input bg-background",
)}
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<button
type="button"
className={cn(
"flex h-9 w-full items-center justify-between rounded-lg border border-input bg-background px-3 text-left text-sm",
"hover:border-primary/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
)}
>
<span className="flex items-center gap-2 min-w-0">
<CalendarRangeIcon className="size-3.5 shrink-0 text-muted-foreground" />
<span className="truncate">{triggerSummary}</span>
</span>
<ChevronDownIcon className="size-3.5 shrink-0 text-muted-foreground" />
</button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Repeat schedule</DialogTitle>
</DialogHeader>
<Tabs value={draft.type} onValueChange={(v) => updateDraft("type", v as RecurrenceType)}>
<TabsList className="w-full">
<TabsTrigger value="none">Don&apos;t repeat</TabsTrigger>
<TabsTrigger value="daily">Daily</TabsTrigger>
<TabsTrigger value="weekly">Weekly</TabsTrigger>
<TabsTrigger value="monthly">Monthly</TabsTrigger>
<TabsTrigger value="yearly">Yearly</TabsTrigger>
</TabsList>
<TabsContent value="none" className="pt-3">
<p className="text-sm text-muted-foreground">
The reminder fires once at the date and time you picked above and ends.
</p>
</TabsContent>
<TabsContent value="daily" className="pt-3 space-y-2">
<RadioRow
name="daily-mode"
value="every_day"
checked={draft.dailyMode === "every_day"}
onChange={() => updateDraft("dailyMode", "every_day")}
label="Every day"
/>
<RadioRow
name="daily-mode"
value="weekdays"
checked={draft.dailyMode === "weekdays"}
onChange={() => updateDraft("dailyMode", "weekdays")}
label="Every weekday (Mon Fri)"
/>
</TabsContent>
<TabsContent value="weekly" className="pt-3 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 = draft.weekdays.includes(cronDow);
return (
<button
key={iso}
type="button"
onClick={() =>
updateDraft(
"weekdays",
active
? draft.weekdays.filter((d) => d !== cronDow)
: [...draft.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>
</TabsContent>
<TabsContent value="monthly" className="pt-3 space-y-2">
<Label htmlFor="rp-monthly-day" className="text-sm">
Day of the month
</Label>
<div className="flex items-center gap-2">
<Input
id="rp-monthly-day"
type="number"
min={1}
max={31}
value={draft.monthDay}
onChange={(e) => updateDraft("monthDay", Number(e.target.value) || 1)}
className="h-8 w-24"
/>
<span className="text-xs text-muted-foreground">
Months without this day skip naturally (e.g. 31st)
</span>
</div>
</TabsContent>
<TabsContent value="yearly" className="pt-3 space-y-2">
<div className="flex flex-wrap items-end gap-3">
<div className="space-y-1">
<Label htmlFor="rp-yearly-month" className="text-xs text-muted-foreground">
Month
</Label>
<select
id="rp-yearly-month"
value={draft.month}
onChange={(e) => updateDraft("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"
>
{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">{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}
{MONTH_NAMES.map((name, i) => (
<option key={name} value={i + 1}>
{name}
</option>
))}
</select>
</div>
<div className="space-y-1">
<Label htmlFor="rp-yearly-day" className="text-xs text-muted-foreground">
Day
</Label>
<Input
id="rp-yearly-day"
type="number"
min={1}
max={31}
value={draft.monthDay}
onChange={(e) => updateDraft("monthDay", Number(e.target.value) || 1)}
className="h-8 w-20"
/>
)}
</li>
);
})}
</ul>
</div>
</div>
</div>
</TabsContent>
</Tabs>
{/* Live confirmation always visible so the user sees what they
are about to save. */}
<div className="rounded-lg bg-primary/5 px-3 py-2 text-sm">
<span className="font-medium">Fires:</span>{" "}
<span className="text-primary/90">{describeDraft(draft, firstFire)}</span>
</div>
<DialogFooter className="gap-2 sm:justify-between" showCloseButton>
<Button type="button" variant="ghost" size="sm" onClick={handleReset}>
Reset
</Button>
<div className="flex gap-2">
<Button type="button" variant="outline" size="sm" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button type="button" size="sm" onClick={handleSave}>
Save
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
// ---------------------------------------------------------------------------
// Per-frequency config panels
// ---------------------------------------------------------------------------
interface FreqConfigProps {
flow: FlowState;
firstFire: DateTime;
update: <K extends keyof FlowState>(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) => (
<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>
function RadioRow({ name, value, checked, onChange, label }: RadioRowProps) {
return (
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name={name}
value={value}
checked={checked}
onChange={onChange}
className="size-4 accent-primary"
/>
<span className="text-sm">{label}</span>
</label>
);
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,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> = {}): 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<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("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",

View File

@ -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" },
];
}