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:
parent
a7a5c6821b
commit
797917a4ba
@ -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,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.
|
||||
* 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),
|
||||
);
|
||||
const rule = draftsToRule(drafts, firstFire);
|
||||
if (!rule) {
|
||||
if (value.kind !== "none") {
|
||||
onChange({ kind: "none", interval: 1, weeklyDays: [], end: { kind: "never" } });
|
||||
}
|
||||
return;
|
||||
}
|
||||
// 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 {
|
||||
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"
|
||||
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'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)
|
||||
{drafts.length === 0 ? (
|
||||
<div className="rounded-xl border border-border bg-muted/20 px-3 py-3 text-sm text-muted-foreground">
|
||||
Doesn'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(
|
||||
"p-3 space-y-3",
|
||||
idx > 0 && "border-t border-border",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
Schedule {idx + 1}
|
||||
</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>
|
||||
</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"
|
||||
>
|
||||
{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>
|
||||
<RuleEditor
|
||||
draft={draft}
|
||||
firstFire={firstFire}
|
||||
onChange={(patch) => updateDraft(idx, patch)}
|
||||
/>
|
||||
|
||||
{/* 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>
|
||||
<p className="rounded-lg bg-primary/5 px-2.5 py-1.5 text-xs text-primary/80">
|
||||
{describeDraft(draft, firstFire)}
|
||||
</p>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
))}
|
||||
</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="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 {
|
||||
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"
|
||||
|
||||
@ -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", () => {
|
||||
|
||||
@ -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
|
||||
// that never use cron rules.
|
||||
const { CronExpressionParser } = require("cron-parser") as typeof import("cron-parser");
|
||||
try {
|
||||
const it = CronExpressionParser.parse(stripCronPrefix(rule), {
|
||||
currentDate: after,
|
||||
tz: timezone,
|
||||
});
|
||||
const next = it.next();
|
||||
return next.toDate();
|
||||
} catch {
|
||||
return null;
|
||||
// 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(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 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 {
|
||||
if (isCronRule(rule)) {
|
||||
const { CronExpressionParser } = require("cron-parser") as typeof import("cron-parser");
|
||||
try {
|
||||
const it = CronExpressionParser.parse(stripCronPrefix(rule), {
|
||||
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.`,
|
||||
};
|
||||
}
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
return { ok: false, reason: `Invalid cron expression: ${(err as Error).message}` };
|
||||
// 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(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 now = new Date();
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user