feat(recurrence): inline picker + multiple recurring schedules per reminder

Two changes in one cut, both per the user's redesign asks:

1. Bring the recurrence picker INLINE into the When form section.
   The dialog is gone — the type tabs and per-type config now live
   directly under the date+time inputs:

       [ Starts on ]   [ Time ]
       Repeats
       ┌──────────────────────────────────────────────────┐
       │ Schedule 1                            [✕]        │
       │ [Daily] [Weekly] [Monthly] [Yearly]              │
       │ <per-tab config>                                 │
       │ Every weekday at 09:00                           │
       ├──────────────────────────────────────────────────┤
       │ Schedule 2                            [✕]        │
       │ [Daily] [Weekly] [Monthly] [Yearly]              │
       │ <per-tab config>                                 │
       │ Every Friday at 17:00                            │
       └──────────────────────────────────────────────────┘
       [+ Add another schedule]

2. Allow multiple recurrence rules per reminder. Each row is its own
   tab strip + config; the picker compiles them down to a single
   newline-joined CRON: rule. Empty list = "Don't repeat" (one-off).
   MAX_RULES is 8.

Storage stays the same (`reminders.rrule`, `CRON:` sentinel). The
multi-rule format is just newline-separated cron expressions:

       CRON:0 9 * * 1
       0 17 * * 5

`@cmbot/shared` updates to support that:

  - nextOccurrence: splits on newline, computes the next match for
    each rule independently, returns the earliest. Malformed lines
    are skipped (so one bad rule doesn't kill the whole schedule).
  - validateMinInterval: validates every line; any single line firing
    more often than the 5-min minimum fails the whole rule.

Removed: the standalone modal Dialog wrapper, Reset/Cancel/Save
buttons, and the saved-vs-draft synchronisation. The picker now
edits state directly and the parent form's Save commits everything
at once (consistent with the date+time inputs that have always
behaved that way).

Tests (+3 in shared rrule.test.ts; total 20 shared + 26 bot + 132 web
= 178)
- nextOccurrence on a multi-line cron picks the earliest:
  * "0 9 * * 1\n0 17 * * 5" starting Saturday → Mon 09:00 KL
  * Same rule starting Tuesday → Fri 17:00 KL
- nextOccurrence ignores malformed lines and still returns the next
  match from the valid ones.
- validateMinInterval: passes a clean two-line rule; rejects a rule
  containing a too-frequent line.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
yiekheng 2026-05-10 11:09:30 +08:00
parent a7a5c6821b
commit 797917a4ba
3 changed files with 395 additions and 330 deletions

View File

@ -2,16 +2,8 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { DateTime } from "luxon"; import { DateTime } from "luxon";
import { CalendarRangeIcon, ChevronDownIcon, RepeatIcon } from "lucide-react"; import { PlusIcon, RepeatIcon, Trash2Icon } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"; import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
@ -23,34 +15,32 @@ import {
} from "@/lib/recurrence"; } from "@/lib/recurrence";
interface RecurrencePickerProps { interface RecurrencePickerProps {
/** First fire drives the HH:MM in the cron output and the default /** First fire — drives the HH:MM in every row's cron output. */
* weekday / day-of-month / month for each recurrence type. */
firstFire: DateTime; firstFire: DateTime;
value: RecurrenceSpec; value: RecurrenceSpec;
onChange: (next: RecurrenceSpec) => void; onChange: (next: RecurrenceSpec) => void;
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Internal draft state for the dialog // Per-row draft (one recurring rule)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
type RecurrenceType = "none" | "daily" | "weekly" | "monthly" | "yearly"; type RuleType = "daily" | "weekly" | "monthly" | "yearly";
interface Draft { interface Draft {
type: RecurrenceType; type: RuleType;
/** "every_day" | "weekdays" — only relevant under `daily`. */
dailyMode: "every_day" | "weekdays"; dailyMode: "every_day" | "weekdays";
/** Cron weekday numbers (0=Sun..6=Sat) — `weekly`. */ /** Cron weekday list (0=Sun..6=Sat). */
weekdays: number[]; weekdays: number[];
/** Day-of-month 1-31 — `monthly` and `yearly`. */
monthDay: number; monthDay: number;
/** Month-of-year 1-12 — `yearly`. */
month: number; month: number;
} }
const MAX_RULES = 8;
function defaultDraft(firstFire: DateTime): Draft { function defaultDraft(firstFire: DateTime): Draft {
return { return {
type: "none", type: "daily",
dailyMode: "every_day", dailyMode: "every_day",
weekdays: [isoWeekdayToCron(firstFire.weekday)], weekdays: [isoWeekdayToCron(firstFire.weekday)],
monthDay: firstFire.day, monthDay: firstFire.day,
@ -58,24 +48,70 @@ function defaultDraft(firstFire: DateTime): Draft {
}; };
} }
/** Reverse-engineer a draft from a stored cron string so re-opening the function clamp(n: number, lo: number, hi: number): number {
* dialog lands on the user's previous selection. */ if (!Number.isFinite(n)) return lo;
function draftFromCron(rule: string | null | undefined, firstFire: DateTime): Draft { return Math.min(Math.max(Math.floor(n), lo), hi);
const base = defaultDraft(firstFire); }
if (!rule) return base;
const expr = rule.startsWith("CRON:") ? rule.slice(5) : rule;
if (!expr.trim()) return base;
const MONTH_NAMES = [
"January", "February", "March", "April", "May", "June",
"July", "August", "September", "October", "November", "December",
];
function draftToCron(d: Draft, firstFire: DateTime): string | null {
const m = firstFire.minute;
const h = firstFire.hour;
switch (d.type) {
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 describeDraft(d: Draft, firstFire: DateTime): string {
const t = firstFire.toFormat("HH:mm");
switch (d.type) {
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) => {
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}`;
}
}
/** Reverse-engineer a single cron expression into a Draft. Falls back to
* daily-every-day if the expression doesn't match a known shape. */
function draftFromCronExpr(expr: string, firstFire: DateTime): Draft {
const base = defaultDraft(firstFire);
let m: RegExpMatchArray | null; let m: RegExpMatchArray | null;
// daily — "MM HH * * *" if (expr.match(/^\d+ \d+ \* \* 1-5$/)) {
return { ...base, type: "daily", dailyMode: "weekdays" };
}
if (expr.match(/^\d+ \d+ \* \* \*$/)) { if (expr.match(/^\d+ \d+ \* \* \*$/)) {
return { ...base, type: "daily", dailyMode: "every_day" }; 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,\-]+)$/))) { if ((m = expr.match(/^\d+ \d+ \* \* ([0-9,\-]+)$/))) {
const days = m[1]! const days = m[1]!
.split(",") .split(",")
@ -91,83 +127,33 @@ function draftFromCron(rule: string | null | undefined, firstFire: DateTime): Dr
.filter((n) => n >= 0 && n <= 6); .filter((n) => n >= 0 && n <= 6);
return { ...base, type: "weekly", weekdays: days }; return { ...base, type: "weekly", weekdays: days };
} }
// monthly — "MM HH <D> * *"
if ((m = expr.match(/^\d+ \d+ (\d+) \* \*$/))) { if ((m = expr.match(/^\d+ \d+ (\d+) \* \*$/))) {
return { ...base, type: "monthly", monthDay: Number(m[1]) }; return { ...base, type: "monthly", monthDay: Number(m[1]) };
} }
// yearly — "MM HH <D> <M> *"
if ((m = expr.match(/^\d+ \d+ (\d+) (\d+) \*$/))) { if ((m = expr.match(/^\d+ \d+ (\d+) (\d+) \*$/))) {
return { return { ...base, type: "yearly", monthDay: Number(m[1]), month: Number(m[2]) };
...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; return base;
} }
function draftToCron(d: Draft, firstFire: DateTime): string | null { /** Parse a (possibly multi-line) cron rule into an array of drafts. */
const m = firstFire.minute; function draftsFromRule(rule: string | null | undefined, firstFire: DateTime): Draft[] {
const h = firstFire.hour; if (!rule) return [];
switch (d.type) { const expr = rule.startsWith("CRON:") ? rule.slice(5) : rule;
case "none": const lines = expr.split("\n").map((s) => s.trim()).filter(Boolean);
return null; if (lines.length === 0) return [];
case "daily": return lines.map((line) => draftFromCronExpr(line, firstFire));
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 { /** Compile an array of drafts to a single multi-line CRON: rule, or null
if (!Number.isFinite(n)) return lo; * if there are no rules (= one-off). */
return Math.min(Math.max(Math.floor(n), lo), hi); function draftsToRule(drafts: Draft[], firstFire: DateTime): string | null {
} if (drafts.length === 0) return null;
const exprs = drafts
const MONTH_NAMES = [ .map((d) => draftToCron(d, firstFire))
"January", "February", "March", "April", "May", "June", .filter((s): s is string => Boolean(s));
"July", "August", "September", "October", "November", "December", if (exprs.length === 0) return null;
]; return exprs.join("\n");
// 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}`;
}
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -175,257 +161,275 @@ function describeDraft(d: Draft, firstFire: DateTime): string {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/** /**
* Trigger field + dialog, modelled on the Temenos UUX date-recurrence-picker. * Inline recurrence picker. Lives in the When form right under the
* date+time inputs.
* *
* The form shows a single read-only field summarising the current rule. * Repeats
* Clicking it opens a dialog with a tab strip across the top: *
* Schedule 1 [ remove]
* [Daily] [Weekly] [Monthly] [Yearly]
* <per-tab config>
*
* Schedule 2 [ remove]
* [Daily] [Weekly] [Monthly] [Yearly]
* <per-tab config>
*
* [+ Add another schedule]
* *
* [Don't repeat] [Daily] [Weekly] [Monthly] [Yearly] * Fires:
* Every weekday at 09:00
* Every Friday at 17:00
* *
* Each tab swaps in its own controls (a weekday-only toggle, a chip * Zero rules = "Don't repeat" (fires once at date+time and ends).
* group, a day-of-month input, a month + day pair). A live "Fires …" * Adding a rule emits a `{ kind: "cron", cron: "<line1>\n<line2>" }`
* sentence updates as values change. Save commits, Cancel discards. * spec; the bot's `nextOccurrence` already supports newline-joined
* cron and returns the earliest next fire across all rules.
*/ */
export function RecurrencePicker({ firstFire, value, onChange }: RecurrencePickerProps) { export function RecurrencePicker({ firstFire, value, onChange }: RecurrencePickerProps) {
const [open, setOpen] = useState(false); const [drafts, setDrafts] = useState<Draft[]>(() =>
const [draft, setDraft] = useState<Draft>(() => draftsFromRule(value.kind === "cron" ? value.cron ?? null : null, firstFire),
draftFromCron(value.kind === "cron" ? value.cron ?? null : null, firstFire),
); );
// When the dialog opens, re-sync the draft from the parent value so // Compile drafts back to the parent value whenever they change. Also
// the user always starts from the current saved rule. // re-runs when first-fire moves (changing the time picker reshapes
// every row's cron output).
useEffect(() => { useEffect(() => {
if (open) { const rule = draftsToRule(drafts, firstFire);
setDraft( if (!rule) {
draftFromCron(value.kind === "cron" ? value.cron ?? null : null, firstFire), if (value.kind !== "none") {
); onChange({ kind: "none", interval: 1, weeklyDays: [], end: { kind: "never" } });
}
return;
} }
// eslint-disable-next-line react-hooks/exhaustive-deps if (value.kind !== "cron" || value.cron !== rule) {
}, [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({ onChange({
kind: "cron", kind: "cron",
interval: 1, interval: 1,
weeklyDays: [], weeklyDays: [],
cron, cron: rule,
end: { kind: "never" }, end: { kind: "never" },
}); });
} }
setOpen(false); // eslint-disable-next-line react-hooks/exhaustive-deps
} }, [drafts, firstFire.minute, firstFire.hour, firstFire.day, firstFire.month, firstFire.weekday]);
function handleReset() { function updateDraft(idx: number, patch: Partial<Draft>) {
setDraft(defaultDraft(firstFire)); setDrafts((prev) => prev.map((d, i) => (i === idx ? { ...d, ...patch } : d)));
}
function addRule() {
if (drafts.length >= MAX_RULES) return;
setDrafts((prev) => [...prev, defaultDraft(firstFire)]);
}
function removeRule(idx: number) {
setDrafts((prev) => prev.filter((_, i) => i !== idx));
} }
// 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 ( return (
<div className="space-y-1.5"> <div className="space-y-2">
<Label className="flex items-center gap-1.5"> <Label className="flex items-center gap-1.5">
<RepeatIcon className="size-3.5" /> <RepeatIcon className="size-3.5" />
Repeats Repeats
</Label> </Label>
<Dialog open={open} onOpenChange={setOpen}> {drafts.length === 0 ? (
<DialogTrigger asChild> <div className="rounded-xl border border-border bg-muted/20 px-3 py-3 text-sm text-muted-foreground">
<button Doesn&apos;t repeat fires once at the date and time above.
type="button" </div>
className={cn( ) : (
"flex h-9 w-full items-center justify-between rounded-lg border border-input bg-background px-3 text-left text-sm", <div className="overflow-hidden rounded-xl border border-border bg-card">
"hover:border-primary/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2", {drafts.map((draft, idx) => (
)} <div
> key={idx}
<span className="flex items-center gap-2 min-w-0"> className={cn(
<CalendarRangeIcon className="size-3.5 shrink-0 text-muted-foreground" /> "p-3 space-y-3",
<span className="truncate">{triggerSummary}</span> idx > 0 && "border-t border-border",
</span> )}
<ChevronDownIcon className="size-3.5 shrink-0 text-muted-foreground" /> >
</button> <div className="flex items-center justify-between gap-2">
</DialogTrigger> <span className="text-xs font-medium text-muted-foreground">
Schedule {idx + 1}
<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> </span>
<Button
type="button"
size="sm"
variant="ghost"
className="h-7 px-2 text-muted-foreground hover:text-destructive"
onClick={() => removeRule(idx)}
aria-label={`Remove schedule ${idx + 1}`}
>
<Trash2Icon className="size-3.5" />
</Button>
</div> </div>
</TabsContent>
<TabsContent value="yearly" className="pt-3 space-y-2"> <RuleEditor
<div className="flex flex-wrap items-end gap-3"> draft={draft}
<div className="space-y-1"> firstFire={firstFire}
<Label htmlFor="rp-yearly-month" className="text-xs text-muted-foreground"> onChange={(patch) => updateDraft(idx, patch)}
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"
>
{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"
/>
</div>
</div>
</TabsContent>
</Tabs>
{/* Live confirmation always visible so the user sees what they <p className="rounded-lg bg-primary/5 px-2.5 py-1.5 text-xs text-primary/80">
are about to save. */} {describeDraft(draft, firstFire)}
<div className="rounded-lg bg-primary/5 px-3 py-2 text-sm"> </p>
<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> </div>
</DialogFooter> ))}
</DialogContent> </div>
</Dialog> )}
<div>
<Button
type="button"
size="sm"
variant="outline"
onClick={addRule}
disabled={drafts.length >= MAX_RULES}
className="gap-1.5"
>
<PlusIcon className="size-3.5" />
{drafts.length === 0 ? "Add a recurring schedule" : "Add another schedule"}
</Button>
{drafts.length >= MAX_RULES && (
<span className="ml-2 text-xs text-muted-foreground">
Up to {MAX_RULES} schedules per reminder
</span>
)}
</div>
</div> </div>
); );
} }
// ---------------------------------------------------------------------------
// Single-rule editor (tabs + per-type config)
// ---------------------------------------------------------------------------
interface RuleEditorProps {
draft: Draft;
firstFire: DateTime;
onChange: (patch: Partial<Draft>) => void;
}
function RuleEditor({ draft, firstFire, onChange }: RuleEditorProps) {
return (
<Tabs
value={draft.type}
onValueChange={(v) => onChange({ type: v as RuleType })}
>
<TabsList className="w-full">
<TabsTrigger value="daily">Daily</TabsTrigger>
<TabsTrigger value="weekly">Weekly</TabsTrigger>
<TabsTrigger value="monthly">Monthly</TabsTrigger>
<TabsTrigger value="yearly">Yearly</TabsTrigger>
</TabsList>
<TabsContent value="daily" className="space-y-2 pt-3">
<RadioRow
name={`daily-${draft.type}`}
checked={draft.dailyMode === "every_day"}
onChange={() => onChange({ dailyMode: "every_day" })}
label="Every day"
/>
<RadioRow
name={`daily-${draft.type}`}
checked={draft.dailyMode === "weekdays"}
onChange={() => onChange({ dailyMode: "weekdays" })}
label="Every weekday (Mon Fri)"
/>
</TabsContent>
<TabsContent value="weekly" className="space-y-2 pt-3">
<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={() =>
onChange({
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="space-y-2 pt-3">
<Label className="text-sm">Day of the month</Label>
<div className="flex items-center gap-2">
<Input
type="number"
min={1}
max={31}
value={draft.monthDay}
onChange={(e) => onChange({ 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">
<div className="flex flex-wrap items-end gap-3">
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">Month</Label>
<select
value={draft.month}
onChange={(e) => onChange({ 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"
>
{MONTH_NAMES.map((name, i) => (
<option key={name} value={i + 1}>
{name}
</option>
))}
</select>
</div>
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">Day</Label>
<Input
type="number"
min={1}
max={31}
value={draft.monthDay}
onChange={(e) => onChange({ monthDay: Number(e.target.value) || 1 })}
className="h-8 w-20"
/>
</div>
</div>
</TabsContent>
</Tabs>
);
}
interface RadioRowProps { interface RadioRowProps {
name: string; name: string;
value: string;
checked: boolean; checked: boolean;
onChange: () => void; onChange: () => void;
label: string; label: string;
} }
function RadioRow({ name, value, checked, onChange, label }: RadioRowProps) { function RadioRow({ name, checked, onChange, label }: RadioRowProps) {
return ( return (
<label className="flex items-center gap-2 cursor-pointer"> <label className="flex items-center gap-2 cursor-pointer">
<input <input
type="radio" type="radio"
name={name} name={name}
value={value}
checked={checked} checked={checked}
onChange={onChange} onChange={onChange}
className="size-4 accent-primary" className="size-4 accent-primary"

View File

@ -81,6 +81,32 @@ describe("nextOccurrence with cron rules", () => {
expect(next!.toISOString()).toBe("2026-05-11T01:00:00.000Z"); expect(next!.toISOString()).toBe("2026-05-11T01:00:00.000Z");
}); });
it("multi-line CRON rules return the EARLIEST next match across all lines", () => {
// Two schedules joined: every Monday 9 AM KL + every Friday 5 PM KL.
// Starting Saturday 2026-05-09 — the next match should be the Monday.
const rule = "CRON:0 9 * * 1\n0 17 * * 5";
const next = nextOccurrence(rule, "Asia/Kuala_Lumpur", new Date("2026-05-09T08:00:00Z"));
expect(next!.toISOString()).toBe("2026-05-11T01:00:00.000Z"); // Mon 09:00 KL
// Now ask after Tuesday — Friday 5 PM KL should win.
const nextFri = nextOccurrence(
rule,
"Asia/Kuala_Lumpur",
new Date("2026-05-12T08:00:00Z"),
);
expect(nextFri!.toISOString()).toBe("2026-05-15T09:00:00.000Z"); // Fri 17:00 KL = 09:00 UTC
});
it("multi-line CRON rules ignore malformed lines and use the rest", () => {
const rule = "CRON:not a cron\n0 9 * * 1-5";
const next = nextOccurrence(
rule,
"Asia/Kuala_Lumpur",
new Date("2026-05-09T08:00:00Z"),
);
expect(next).not.toBeNull();
});
it("still handles RRULE rules unchanged", () => { it("still handles RRULE rules unchanged", () => {
const next = nextOccurrence( const next = nextOccurrence(
"FREQ=DAILY;BYHOUR=9;BYMINUTE=0", "FREQ=DAILY;BYHOUR=9;BYMINUTE=0",
@ -107,6 +133,15 @@ describe("validateMinInterval with cron rules", () => {
expect(r.ok).toBe(false); expect(r.ok).toBe(false);
if (!r.ok) expect(r.reason.toLowerCase()).toContain("invalid cron"); if (!r.ok) expect(r.reason.toLowerCase()).toContain("invalid cron");
}); });
it("validates every line of a multi-line cron rule", () => {
expect(
validateMinInterval("CRON:0 9 * * 1\n0 17 * * 5", "Asia/Kuala_Lumpur"),
).toEqual({ ok: true });
// Any single line firing too often fails the whole rule.
const r = validateMinInterval("CRON:0 9 * * 1\n* * * * *", "Asia/Kuala_Lumpur");
expect(r.ok).toBe(false);
});
}); });
describe("validateCronExpression", () => { describe("validateCronExpression", () => {

View File

@ -42,16 +42,30 @@ export function nextOccurrence(rule: string, timezone: string, after: Date): Dat
// Lazy require keeps cron-parser out of the import graph for callers // Lazy require keeps cron-parser out of the import graph for callers
// that never use cron rules. // that never use cron rules.
const { CronExpressionParser } = require("cron-parser") as typeof import("cron-parser"); const { CronExpressionParser } = require("cron-parser") as typeof import("cron-parser");
try { // The picker can store multiple cron expressions joined by newlines
const it = CronExpressionParser.parse(stripCronPrefix(rule), { // ("every Monday at 09:00\nevery Friday at 17:00"). We compute the
currentDate: after, // next match for each and return the earliest — that's the very
tz: timezone, // next time *any* of the schedules fires.
}); const lines = stripCronPrefix(rule)
const next = it.next(); .split("\n")
return next.toDate(); .map((s) => s.trim())
} catch { .filter(Boolean);
return null; if (lines.length === 0) return null;
let earliest: Date | null = null;
for (const expr of lines) {
try {
const it = CronExpressionParser.parse(expr, {
currentDate: after,
tz: timezone,
});
const next = it.next().toDate();
if (!earliest || next < earliest) earliest = next;
} catch {
// Skip the malformed line; if all lines are bad the function
// returns null below.
}
} }
return earliest;
} }
const parsed = parseRRule(rule); const parsed = parseRRule(rule);
const afterInZone = DateTime.fromJSDate(after).setZone(timezone).toJSDate(); const afterInZone = DateTime.fromJSDate(after).setZone(timezone).toJSDate();
@ -64,24 +78,36 @@ export type IntervalCheck = { ok: true } | { ok: false; reason: string };
export function validateMinInterval(rule: string, timezone: string): IntervalCheck { export function validateMinInterval(rule: string, timezone: string): IntervalCheck {
if (isCronRule(rule)) { if (isCronRule(rule)) {
const { CronExpressionParser } = require("cron-parser") as typeof import("cron-parser"); const { CronExpressionParser } = require("cron-parser") as typeof import("cron-parser");
try { // Validate every line independently — a multi-rule schedule fires
const it = CronExpressionParser.parse(stripCronPrefix(rule), { // as the union, so any single rule firing too often is enough to
currentDate: new Date(), // breach the minimum interval.
tz: timezone, const lines = stripCronPrefix(rule)
}); .split("\n")
const first = it.next().toDate(); .map((s) => s.trim())
const second = it.next().toDate(); .filter(Boolean);
const gap = second.getTime() - first.getTime(); if (lines.length === 0) {
if (gap < MIN_INTERVAL_MS) { return { ok: false, reason: "Empty cron rule" };
return {
ok: false,
reason: `Cron fires every ${Math.round(gap / 1000)}s; minimum interval is ${MIN_INTERVAL_MS / 1000}s.`,
};
}
return { ok: true };
} catch (err) {
return { ok: false, reason: `Invalid cron expression: ${(err as Error).message}` };
} }
for (const expr of lines) {
try {
const it = CronExpressionParser.parse(expr, {
currentDate: new Date(),
tz: timezone,
});
const first = it.next().toDate();
const second = it.next().toDate();
const gap = second.getTime() - first.getTime();
if (gap < MIN_INTERVAL_MS) {
return {
ok: false,
reason: `Cron fires every ${Math.round(gap / 1000)}s; minimum interval is ${MIN_INTERVAL_MS / 1000}s.`,
};
}
} catch (err) {
return { ok: false, reason: `Invalid cron expression: ${(err as Error).message}` };
}
}
return { ok: true };
} }
const parsed = parseRRule(rule); const parsed = parseRRule(rule);
const now = new Date(); const now = new Date();