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 { 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,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.
|
* 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") {
|
||||||
);
|
|
||||||
}
|
|
||||||
// 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" } });
|
onChange({ kind: "none", interval: 1, weeklyDays: [], end: { kind: "never" } });
|
||||||
} else {
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (value.kind !== "cron" || value.cron !== rule) {
|
||||||
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't repeat — fires once at the date and time above.
|
||||||
type="button"
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-hidden rounded-xl border border-border bg-card">
|
||||||
|
{drafts.map((draft, idx) => (
|
||||||
|
<div
|
||||||
|
key={idx}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex h-9 w-full items-center justify-between rounded-lg border border-input bg-background px-3 text-left text-sm",
|
"p-3 space-y-3",
|
||||||
"hover:border-primary/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
idx > 0 && "border-t border-border",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span className="flex items-center gap-2 min-w-0">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<CalendarRangeIcon className="size-3.5 shrink-0 text-muted-foreground" />
|
<span className="text-xs font-medium text-muted-foreground">
|
||||||
<span className="truncate">{triggerSummary}</span>
|
Schedule {idx + 1}
|
||||||
</span>
|
</span>
|
||||||
<ChevronDownIcon className="size-3.5 shrink-0 text-muted-foreground" />
|
<Button
|
||||||
</button>
|
type="button"
|
||||||
</DialogTrigger>
|
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>
|
<RuleEditor
|
||||||
<DialogHeader>
|
draft={draft}
|
||||||
<DialogTitle>Repeat schedule</DialogTitle>
|
firstFire={firstFire}
|
||||||
</DialogHeader>
|
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">
|
<TabsList className="w-full">
|
||||||
<TabsTrigger value="none">Don't repeat</TabsTrigger>
|
|
||||||
<TabsTrigger value="daily">Daily</TabsTrigger>
|
<TabsTrigger value="daily">Daily</TabsTrigger>
|
||||||
<TabsTrigger value="weekly">Weekly</TabsTrigger>
|
<TabsTrigger value="weekly">Weekly</TabsTrigger>
|
||||||
<TabsTrigger value="monthly">Monthly</TabsTrigger>
|
<TabsTrigger value="monthly">Monthly</TabsTrigger>
|
||||||
<TabsTrigger value="yearly">Yearly</TabsTrigger>
|
<TabsTrigger value="yearly">Yearly</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent value="none" className="pt-3">
|
<TabsContent value="daily" className="space-y-2 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
|
<RadioRow
|
||||||
name="daily-mode"
|
name={`daily-${draft.type}`}
|
||||||
value="every_day"
|
|
||||||
checked={draft.dailyMode === "every_day"}
|
checked={draft.dailyMode === "every_day"}
|
||||||
onChange={() => updateDraft("dailyMode", "every_day")}
|
onChange={() => onChange({ dailyMode: "every_day" })}
|
||||||
label="Every day"
|
label="Every day"
|
||||||
/>
|
/>
|
||||||
<RadioRow
|
<RadioRow
|
||||||
name="daily-mode"
|
name={`daily-${draft.type}`}
|
||||||
value="weekdays"
|
|
||||||
checked={draft.dailyMode === "weekdays"}
|
checked={draft.dailyMode === "weekdays"}
|
||||||
onChange={() => updateDraft("dailyMode", "weekdays")}
|
onChange={() => onChange({ dailyMode: "weekdays" })}
|
||||||
label="Every weekday (Mon – Fri)"
|
label="Every weekday (Mon – Fri)"
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</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>
|
<Label className="text-sm">On these days</Label>
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
{WEEKDAY_LABELS.map(({ iso, short }) => {
|
{WEEKDAY_LABELS.map(({ iso, short }) => {
|
||||||
@ -306,12 +346,11 @@ export function RecurrencePicker({ firstFire, value, onChange }: RecurrencePicke
|
|||||||
key={iso}
|
key={iso}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
updateDraft(
|
onChange({
|
||||||
"weekdays",
|
weekdays: active
|
||||||
active
|
|
||||||
? draft.weekdays.filter((d) => d !== cronDow)
|
? draft.weekdays.filter((d) => d !== cronDow)
|
||||||
: [...draft.weekdays, cronDow].sort((a, b) => a - b),
|
: [...draft.weekdays, cronDow].sort((a, b) => a - b),
|
||||||
)
|
})
|
||||||
}
|
}
|
||||||
aria-pressed={active}
|
aria-pressed={active}
|
||||||
className={cn(
|
className={cn(
|
||||||
@ -328,18 +367,15 @@ export function RecurrencePicker({ firstFire, value, onChange }: RecurrencePicke
|
|||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="monthly" className="pt-3 space-y-2">
|
<TabsContent value="monthly" className="space-y-2 pt-3">
|
||||||
<Label htmlFor="rp-monthly-day" className="text-sm">
|
<Label className="text-sm">Day of the month</Label>
|
||||||
Day of the month
|
|
||||||
</Label>
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Input
|
<Input
|
||||||
id="rp-monthly-day"
|
|
||||||
type="number"
|
type="number"
|
||||||
min={1}
|
min={1}
|
||||||
max={31}
|
max={31}
|
||||||
value={draft.monthDay}
|
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"
|
className="h-8 w-24"
|
||||||
/>
|
/>
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
@ -348,16 +384,13 @@ export function RecurrencePicker({ firstFire, value, onChange }: RecurrencePicke
|
|||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</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="flex flex-wrap items-end gap-3">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label htmlFor="rp-yearly-month" className="text-xs text-muted-foreground">
|
<Label className="text-xs text-muted-foreground">Month</Label>
|
||||||
Month
|
|
||||||
</Label>
|
|
||||||
<select
|
<select
|
||||||
id="rp-yearly-month"
|
|
||||||
value={draft.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"
|
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) => (
|
{MONTH_NAMES.map((name, i) => (
|
||||||
@ -368,64 +401,35 @@ export function RecurrencePicker({ firstFire, value, onChange }: RecurrencePicke
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label htmlFor="rp-yearly-day" className="text-xs text-muted-foreground">
|
<Label className="text-xs text-muted-foreground">Day</Label>
|
||||||
Day
|
|
||||||
</Label>
|
|
||||||
<Input
|
<Input
|
||||||
id="rp-yearly-day"
|
|
||||||
type="number"
|
type="number"
|
||||||
min={1}
|
min={1}
|
||||||
max={31}
|
max={31}
|
||||||
value={draft.monthDay}
|
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"
|
className="h-8 w-20"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</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 {
|
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"
|
||||||
|
|||||||
@ -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", () => {
|
||||||
|
|||||||
@ -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
|
// 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");
|
||||||
|
// 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 {
|
try {
|
||||||
const it = CronExpressionParser.parse(stripCronPrefix(rule), {
|
const it = CronExpressionParser.parse(expr, {
|
||||||
currentDate: after,
|
currentDate: after,
|
||||||
tz: timezone,
|
tz: timezone,
|
||||||
});
|
});
|
||||||
const next = it.next();
|
const next = it.next().toDate();
|
||||||
return next.toDate();
|
if (!earliest || next < earliest) earliest = next;
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
// 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();
|
||||||
const next = parsed.after(afterInZone, false);
|
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 {
|
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");
|
||||||
|
// 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 {
|
try {
|
||||||
const it = CronExpressionParser.parse(stripCronPrefix(rule), {
|
const it = CronExpressionParser.parse(expr, {
|
||||||
currentDate: new Date(),
|
currentDate: new Date(),
|
||||||
tz: timezone,
|
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.`,
|
reason: `Cron fires every ${Math.round(gap / 1000)}s; minimum interval is ${MIN_INTERVAL_MS / 1000}s.`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return { ok: true };
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return { ok: false, reason: `Invalid cron expression: ${(err as Error).message}` };
|
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();
|
||||||
const first = parsed.after(now, false);
|
const first = parsed.after(now, false);
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user