From 797917a4ba786135c039ae3b48de53d78e2311a0 Mon Sep 17 00:00:00 2001 From: yiekheng Date: Sun, 10 May 2026 11:09:30 +0800 Subject: [PATCH] feat(recurrence): inline picker + multiple recurring schedules per reminder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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] │ │ │ │ Every weekday at 09:00 │ ├──────────────────────────────────────────────────┤ │ Schedule 2 [✕] │ │ [Daily] [Weekly] [Monthly] [Yearly] │ │ │ │ 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) --- apps/web/src/components/recurrence-picker.tsx | 612 +++++++++--------- packages/shared/src/rrule.test.ts | 35 + packages/shared/src/rrule.ts | 78 ++- 3 files changed, 395 insertions(+), 330 deletions(-) diff --git a/apps/web/src/components/recurrence-picker.tsx b/apps/web/src/components/recurrence-picker.tsx index 5bade42..dcf67d5 100644 --- a/apps/web/src/components/recurrence-picker.tsx +++ b/apps/web/src/components/recurrence-picker.tsx @@ -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 * * " 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 * *" if ((m = expr.match(/^\d+ \d+ (\d+) \* \*$/))) { return { ...base, type: "monthly", monthDay: Number(m[1]) }; } - // yearly — "MM HH *" 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] │ + * │ │ + * ├─────────────────────────────────────────────────────┤ + * │ Schedule 2 [✕ remove] │ + * │ [Daily] [Weekly] [Monthly] [Yearly] │ + * │ │ + * └─────────────────────────────────────────────────────┘ + * [+ 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: "\n" }` + * 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(() => - draftFromCron(value.kind === "cron" ? value.cron ?? null : null, firstFire), + const [drafts, setDrafts] = useState(() => + 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: 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) { + 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 ( -
+
- - - - - - - - Repeat schedule - - - updateDraft("type", v as RecurrenceType)}> - - Don't repeat - Daily - Weekly - Monthly - Yearly - - - -

- The reminder fires once at the date and time you picked above and ends. -

-
- - - updateDraft("dailyMode", "every_day")} - label="Every day" - /> - updateDraft("dailyMode", "weekdays")} - label="Every weekday (Mon – Fri)" - /> - - - - -
- {WEEKDAY_LABELS.map(({ iso, short }) => { - const cronDow = isoWeekdayToCron(iso); - const active = draft.weekdays.includes(cronDow); - return ( - - ); - })} -
-
- - - -
- updateDraft("monthDay", Number(e.target.value) || 1)} - className="h-8 w-24" - /> - - Months without this day skip naturally (e.g. 31st) + {drafts.length === 0 ? ( +
+ Doesn't repeat — fires once at the date and time above. +
+ ) : ( +
+ {drafts.map((draft, idx) => ( +
0 && "border-t border-border", + )} + > +
+ + Schedule {idx + 1} +
- - -
-
- - -
-
- - updateDraft("monthDay", Number(e.target.value) || 1)} - className="h-8 w-20" - /> -
-
-
- + updateDraft(idx, patch)} + /> - {/* Live confirmation — always visible so the user sees what they - are about to save. */} -
- Fires:{" "} - {describeDraft(draft, firstFire)} -
- - - -
- - +

+ {describeDraft(draft, firstFire)} +

-
- -
+ ))} +
+ )} + +
+ + {drafts.length >= MAX_RULES && ( + + Up to {MAX_RULES} schedules per reminder + + )} +
); } +// --------------------------------------------------------------------------- +// Single-rule editor (tabs + per-type config) +// --------------------------------------------------------------------------- + +interface RuleEditorProps { + draft: Draft; + firstFire: DateTime; + onChange: (patch: Partial) => void; +} + +function RuleEditor({ draft, firstFire, onChange }: RuleEditorProps) { + return ( + onChange({ type: v as RuleType })} + > + + Daily + Weekly + Monthly + Yearly + + + + onChange({ dailyMode: "every_day" })} + label="Every day" + /> + onChange({ dailyMode: "weekdays" })} + label="Every weekday (Mon – Fri)" + /> + + + + +
+ {WEEKDAY_LABELS.map(({ iso, short }) => { + const cronDow = isoWeekdayToCron(iso); + const active = draft.weekdays.includes(cronDow); + return ( + + ); + })} +
+
+ + + +
+ onChange({ monthDay: Number(e.target.value) || 1 })} + className="h-8 w-24" + /> + + Months without this day skip naturally (e.g. 31st) + +
+
+ + +
+
+ + +
+
+ + onChange({ monthDay: Number(e.target.value) || 1 })} + className="h-8 w-20" + /> +
+
+
+
+ ); +} + 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 (