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 { DateTime } from "luxon";
import { CalendarRangeIcon, ChevronDownIcon, RepeatIcon } from "lucide-react";
import { PlusIcon, RepeatIcon, Trash2Icon } 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";
@ -23,34 +15,32 @@ import {
} from "@/lib/recurrence";
interface RecurrencePickerProps {
/** First fire drives the HH:MM in the cron output and the default
* weekday / day-of-month / month for each recurrence type. */
/** First fire — drives the HH:MM in every row's cron output. */
firstFire: DateTime;
value: RecurrenceSpec;
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 {
type: RecurrenceType;
/** "every_day" | "weekdays" — only relevant under `daily`. */
type: RuleType;
dailyMode: "every_day" | "weekdays";
/** Cron weekday numbers (0=Sun..6=Sat) — `weekly`. */
/** Cron weekday list (0=Sun..6=Sat). */
weekdays: number[];
/** Day-of-month 1-31 — `monthly` and `yearly`. */
monthDay: number;
/** Month-of-year 1-12 — `yearly`. */
month: number;
}
const MAX_RULES = 8;
function defaultDraft(firstFire: DateTime): Draft {
return {
type: "none",
type: "daily",
dailyMode: "every_day",
weekdays: [isoWeekdayToCron(firstFire.weekday)],
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
* 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;
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",
];
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;
// daily — "MM HH * * *"
if (expr.match(/^\d+ \d+ \* \* 1-5$/)) {
return { ...base, type: "daily", dailyMode: "weekdays" };
}
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(",")
@ -91,83 +127,33 @@ function draftFromCron(rule: string | null | undefined, firstFire: DateTime): Dr
.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]),
};
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)} *`;
}
/** Parse a (possibly multi-line) cron rule into an array of drafts. */
function draftsFromRule(rule: string | null | undefined, firstFire: DateTime): Draft[] {
if (!rule) return [];
const expr = rule.startsWith("CRON:") ? rule.slice(5) : rule;
const lines = expr.split("\n").map((s) => s.trim()).filter(Boolean);
if (lines.length === 0) return [];
return lines.map((line) => draftFromCronExpr(line, firstFire));
}
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}`;
}
/** Compile an array of drafts to a single multi-line CRON: rule, or null
* if there are no rules (= one-off). */
function draftsToRule(drafts: Draft[], firstFire: DateTime): string | null {
if (drafts.length === 0) return null;
const exprs = drafts
.map((d) => draftToCron(d, firstFire))
.filter((s): s is string => Boolean(s));
if (exprs.length === 0) return null;
return exprs.join("\n");
}
// ---------------------------------------------------------------------------
@ -175,127 +161,181 @@ 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.
* Clicking it opens a dialog with a tab strip across the top:
* Repeats
*
* 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
* group, a day-of-month input, a month + day pair). A live "Fires …"
* sentence updates as values change. Save commits, Cancel discards.
* Zero rules = "Don't repeat" (fires once at date+time and ends).
* Adding a rule emits a `{ kind: "cron", cron: "<line1>\n<line2>" }`
* 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) {
const [open, setOpen] = useState(false);
const [draft, setDraft] = useState<Draft>(() =>
draftFromCron(value.kind === "cron" ? value.cron ?? null : null, firstFire),
const [drafts, setDrafts] = useState<Draft[]>(() =>
draftsFromRule(value.kind === "cron" ? value.cron ?? null : null, firstFire),
);
// When the dialog opens, re-sync the draft from the parent value so
// the user always starts from the current saved rule.
// Compile drafts back to the parent value whenever they change. Also
// re-runs when first-fire moves (changing the time picker reshapes
// every row's cron output).
useEffect(() => {
if (open) {
setDraft(
draftFromCron(value.kind === "cron" ? value.cron ?? null : null, firstFire),
);
}
// 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) {
const rule = draftsToRule(drafts, firstFire);
if (!rule) {
if (value.kind !== "none") {
onChange({ kind: "none", interval: 1, weeklyDays: [], end: { kind: "never" } });
} else {
}
return;
}
if (value.kind !== "cron" || value.cron !== rule) {
onChange({
kind: "cron",
interval: 1,
weeklyDays: [],
cron,
cron: rule,
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() {
setDraft(defaultDraft(firstFire));
function updateDraft(idx: number, patch: Partial<Draft>) {
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 (
<div className="space-y-1.5">
<div className="space-y-2">
<Label className="flex items-center gap-1.5">
<RepeatIcon className="size-3.5" />
Repeats
</Label>
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<button
type="button"
{drafts.length === 0 ? (
<div className="rounded-xl border border-border bg-muted/20 px-3 py-3 text-sm text-muted-foreground">
Doesn&apos;t repeat fires once at the date and time above.
</div>
) : (
<div className="overflow-hidden rounded-xl border border-border bg-card">
{drafts.map((draft, idx) => (
<div
key={idx}
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",
"p-3 space-y-3",
idx > 0 && "border-t border-border",
)}
>
<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>
<div className="flex items-center justify-between gap-2">
<span className="text-xs font-medium text-muted-foreground">
Schedule {idx + 1}
</span>
<ChevronDownIcon className="size-3.5 shrink-0 text-muted-foreground" />
</button>
</DialogTrigger>
<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>
<DialogContent>
<DialogHeader>
<DialogTitle>Repeat schedule</DialogTitle>
</DialogHeader>
<RuleEditor
draft={draft}
firstFire={firstFire}
onChange={(patch) => updateDraft(idx, patch)}
/>
<Tabs value={draft.type} onValueChange={(v) => updateDraft("type", v as RecurrenceType)}>
<p className="rounded-lg bg-primary/5 px-2.5 py-1.5 text-xs text-primary/80">
{describeDraft(draft, firstFire)}
</p>
</div>
))}
</div>
)}
<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>
);
}
// ---------------------------------------------------------------------------
// 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="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">
<TabsContent value="daily" className="space-y-2 pt-3">
<RadioRow
name="daily-mode"
value="every_day"
name={`daily-${draft.type}`}
checked={draft.dailyMode === "every_day"}
onChange={() => updateDraft("dailyMode", "every_day")}
onChange={() => onChange({ dailyMode: "every_day" })}
label="Every day"
/>
<RadioRow
name="daily-mode"
value="weekdays"
name={`daily-${draft.type}`}
checked={draft.dailyMode === "weekdays"}
onChange={() => updateDraft("dailyMode", "weekdays")}
onChange={() => onChange({ dailyMode: "weekdays" })}
label="Every weekday (Mon Fri)"
/>
</TabsContent>
<TabsContent value="weekly" className="pt-3 space-y-2">
<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 }) => {
@ -306,12 +346,11 @@ export function RecurrencePicker({ firstFire, value, onChange }: RecurrencePicke
key={iso}
type="button"
onClick={() =>
updateDraft(
"weekdays",
active
onChange({
weekdays: active
? draft.weekdays.filter((d) => d !== cronDow)
: [...draft.weekdays, cronDow].sort((a, b) => a - b),
)
})
}
aria-pressed={active}
className={cn(
@ -328,18 +367,15 @@ export function RecurrencePicker({ firstFire, value, onChange }: RecurrencePicke
</div>
</TabsContent>
<TabsContent value="monthly" className="pt-3 space-y-2">
<Label htmlFor="rp-monthly-day" className="text-sm">
Day of the month
</Label>
<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
id="rp-monthly-day"
type="number"
min={1}
max={31}
value={draft.monthDay}
onChange={(e) => updateDraft("monthDay", Number(e.target.value) || 1)}
onChange={(e) => onChange({ monthDay: Number(e.target.value) || 1 })}
className="h-8 w-24"
/>
<span className="text-xs text-muted-foreground">
@ -348,16 +384,13 @@ export function RecurrencePicker({ firstFire, value, onChange }: RecurrencePicke
</div>
</TabsContent>
<TabsContent value="yearly" className="pt-3 space-y-2">
<TabsContent value="yearly" className="pt-3">
<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>
<Label className="text-xs text-muted-foreground">Month</Label>
<select
id="rp-yearly-month"
value={draft.month}
onChange={(e) => updateDraft("month", Number(e.target.value))}
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) => (
@ -368,64 +401,35 @@ export function RecurrencePicker({ firstFire, value, onChange }: RecurrencePicke
</select>
</div>
<div className="space-y-1">
<Label htmlFor="rp-yearly-day" className="text-xs text-muted-foreground">
Day
</Label>
<Label 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)}
onChange={(e) => onChange({ 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
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>
);
}
interface RadioRowProps {
name: string;
value: string;
checked: boolean;
onChange: () => void;
label: string;
}
function RadioRow({ name, value, checked, onChange, label }: RadioRowProps) {
function RadioRow({ name, 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"

View File

@ -81,6 +81,32 @@ describe("nextOccurrence with cron rules", () => {
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", () => {
const next = nextOccurrence(
"FREQ=DAILY;BYHOUR=9;BYMINUTE=0",
@ -107,6 +133,15 @@ describe("validateMinInterval with cron rules", () => {
expect(r.ok).toBe(false);
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", () => {

View File

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