feat(recurrence): redesign as a Temenos-style trigger + dialog picker
The previous flat radio list with N-minutes / N-hours / Custom-cron
options is gone. Per the Temenos UUX `date-recurrence-picker` pattern
(developer.temenos.com/uux/docs/components/date-recurrence-picker), the
form now shows a single read-only trigger field summarising the saved
rule:
┌────────────────────────────────────────────────────┐
│ 📅 Don't repeat ▾ │
└────────────────────────────────────────────────────┘
Clicking the trigger opens a modal with the recurrence types as a
tab strip and per-type config swapped in below:
┌──────────────────────────────────────────────────┐
│ Repeat schedule ✕ │
├──────────────────────────────────────────────────┤
│ [Don't repeat] [Daily] [Weekly] [Monthly] [Yearly] │
│ │
│ <per-type config> │
│ │
│ Fires: <plain-text confirmation> │
│ │
│ [Reset] [Cancel] [Save] │
└──────────────────────────────────────────────────┘
Per-tab config:
- Don't repeat — informational text only
- Daily — radio: "Every day" / "Every weekday (Mon–Fri)"
- Weekly — Mon..Sun chip multi-select
- Monthly — day-of-month input (1-31)
- Yearly — month select + day input
The "Fires: …" sentence updates live as the user edits and reflects
the outer time-picker's HH:MM. Save commits, Cancel discards.
Removed:
- Every N minutes
- Every N hours
- Custom cron expression…
- The standalone helpers `flowToCron` / `flowFromCron` /
`freqChoices` / `defaultFlowState` / `FlowState` / `FreqChoice`
in `lib/recurrence.ts`. Their job (compile a UI state to a cron
string and parse one back) now lives privately inside the picker.
Storage / runtime
- Output is still a `CRON:` prefixed rule in `reminders.rrule`. The
bot's `nextOccurrence` already dispatches cron rules through
cron-parser, so no schema or scheduler changes were needed.
Tests (132 web)
- recurrence.test.ts trimmed to keep only what survives: CRON-rule
round-trip via buildRrule + specFromRrule, and the ISO→cron
weekday helper.
- Existing wizard / edit-when-form integration tests are unaffected
because the picker exports the same `<RecurrencePicker>` props.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
b67d3c735e
commit
a7a5c6821b
@ -2,61 +2,215 @@
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { DateTime } from "luxon";
|
||||
import { CheckIcon, RepeatIcon } from "lucide-react";
|
||||
import { CalendarRangeIcon, ChevronDownIcon, RepeatIcon } 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";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
WEEKDAY_LABELS,
|
||||
defaultFlowState,
|
||||
flowFromCron,
|
||||
flowToCron,
|
||||
freqChoices,
|
||||
isoWeekdayToCron,
|
||||
type FlowState,
|
||||
type FreqChoice,
|
||||
type RecurrenceSpec,
|
||||
} from "@/lib/recurrence";
|
||||
|
||||
interface RecurrencePickerProps {
|
||||
/** First fire of the reminder — drives the HH:MM in the cron output and the
|
||||
* default day-of-month / month / weekday for the per-frequency configurators. */
|
||||
/** First fire — drives the HH:MM in the cron output and the default
|
||||
* weekday / day-of-month / month for each recurrence type. */
|
||||
firstFire: DateTime;
|
||||
value: RecurrenceSpec;
|
||||
onChange: (next: RecurrenceSpec) => void;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal draft state for the dialog
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type RecurrenceType = "none" | "daily" | "weekly" | "monthly" | "yearly";
|
||||
|
||||
interface Draft {
|
||||
type: RecurrenceType;
|
||||
/** "every_day" | "weekdays" — only relevant under `daily`. */
|
||||
dailyMode: "every_day" | "weekdays";
|
||||
/** Cron weekday numbers (0=Sun..6=Sat) — `weekly`. */
|
||||
weekdays: number[];
|
||||
/** Day-of-month 1-31 — `monthly` and `yearly`. */
|
||||
monthDay: number;
|
||||
/** Month-of-year 1-12 — `yearly`. */
|
||||
month: number;
|
||||
}
|
||||
|
||||
function defaultDraft(firstFire: DateTime): Draft {
|
||||
return {
|
||||
type: "none",
|
||||
dailyMode: "every_day",
|
||||
weekdays: [isoWeekdayToCron(firstFire.weekday)],
|
||||
monthDay: firstFire.day,
|
||||
month: firstFire.month,
|
||||
};
|
||||
}
|
||||
|
||||
/** 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;
|
||||
|
||||
let m: RegExpMatchArray | null;
|
||||
// daily — "MM HH * * *"
|
||||
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(",")
|
||||
.flatMap((p) => {
|
||||
const r = p.match(/^(\d+)-(\d+)$/);
|
||||
if (r) {
|
||||
const out: number[] = [];
|
||||
for (let i = Number(r[1]); i <= Number(r[2]); i++) out.push(i);
|
||||
return out;
|
||||
}
|
||||
return [Number(p)];
|
||||
})
|
||||
.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]),
|
||||
};
|
||||
}
|
||||
// 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)} *`;
|
||||
}
|
||||
}
|
||||
|
||||
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}`;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The picker
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Guided cron flow.
|
||||
* Trigger field + dialog, modelled on the Temenos UUX date-recurrence-picker.
|
||||
*
|
||||
* Step 1 — pick a frequency in the radio list (the chosen card stays
|
||||
* highlighted; only its config panel below it expands).
|
||||
* Step 2 — fill in the per-frequency inputs (a number, weekday chips,
|
||||
* a day picker, etc.). Every change recompiles to a cron expression
|
||||
* and pushes a `{ kind: "cron", cron: "..." }` spec up to the parent.
|
||||
* The form shows a single read-only field summarising the current rule.
|
||||
* Clicking it opens a dialog with a tab strip across the top:
|
||||
*
|
||||
* "Don't repeat" is a one-click exit (no config). "Custom cron…" lets
|
||||
* a power-user type any expression directly.
|
||||
* [Don't repeat] [Daily] [Weekly] [Monthly] [Yearly]
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
export function RecurrencePicker({ firstFire, value, onChange }: RecurrencePickerProps) {
|
||||
// The flow state is reverse-engineered from the incoming `value`
|
||||
// when the picker mounts so editing an existing reminder lands on
|
||||
// the right radio. Subsequent edits live in local state.
|
||||
const [flow, setFlow] = useState<FlowState>(() =>
|
||||
flowFromCron(value.kind === "cron" ? value.cron ?? null : null, firstFire),
|
||||
const [open, setOpen] = useState(false);
|
||||
const [draft, setDraft] = useState<Draft>(() =>
|
||||
draftFromCron(value.kind === "cron" ? value.cron ?? null : null, firstFire),
|
||||
);
|
||||
|
||||
// Re-derive the cron when either the flow or the first-fire changes
|
||||
// (changing the time picker outside should refresh "at HH:MM").
|
||||
// When the dialog opens, re-sync the draft from the parent value so
|
||||
// the user always starts from the current saved rule.
|
||||
useEffect(() => {
|
||||
const cron = flowToCron(flow, firstFire);
|
||||
if (!cron) {
|
||||
if (value.kind !== "none") {
|
||||
onChange({ kind: "none", interval: 1, weeklyDays: [], end: { kind: "never" } });
|
||||
}
|
||||
return;
|
||||
if (open) {
|
||||
setDraft(
|
||||
draftFromCron(value.kind === "cron" ? value.cron ?? null : null, firstFire),
|
||||
);
|
||||
}
|
||||
if (value.kind !== "cron" || value.cron !== cron) {
|
||||
// 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 {
|
||||
onChange({
|
||||
kind: "cron",
|
||||
interval: 1,
|
||||
@ -65,267 +219,218 @@ export function RecurrencePicker({ firstFire, value, onChange }: RecurrencePicke
|
||||
end: { kind: "never" },
|
||||
});
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [flow, firstFire.minute, firstFire.hour, firstFire.day, firstFire.month, firstFire.weekday]);
|
||||
setOpen(false);
|
||||
}
|
||||
|
||||
const update = <K extends keyof FlowState>(k: K, v: FlowState[K]) =>
|
||||
setFlow((prev) => ({ ...prev, [k]: v }));
|
||||
function handleReset() {
|
||||
setDraft(defaultDraft(firstFire));
|
||||
}
|
||||
|
||||
// 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-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="flex items-center gap-1.5">
|
||||
<RepeatIcon className="size-3.5" />
|
||||
Repeats
|
||||
</Label>
|
||||
|
||||
<div className="overflow-hidden rounded-xl border border-border bg-card">
|
||||
<ul className="divide-y divide-border" role="radiogroup" aria-label="Repeat schedule">
|
||||
{freqChoices(firstFire).map((c) => {
|
||||
const selected = flow.freq === c.id;
|
||||
return (
|
||||
<li key={c.id}>
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={selected}
|
||||
onClick={() => update("freq", c.id)}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-3 px-4 py-3 text-left transition-colors",
|
||||
selected ? "bg-primary/5 text-foreground" : "hover:bg-muted text-foreground",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
"flex size-4 shrink-0 items-center justify-center rounded-full border transition-colors",
|
||||
selected
|
||||
? "border-primary bg-primary text-primary-foreground"
|
||||
: "border-input bg-background",
|
||||
)}
|
||||
<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)
|
||||
</span>
|
||||
</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"
|
||||
>
|
||||
{selected && <CheckIcon className="size-2.5" strokeWidth={3.5} />}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block text-sm font-medium leading-snug">{c.label}</span>
|
||||
{c.hint && (
|
||||
<span className="block text-xs text-muted-foreground">{c.hint}</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{selected && (
|
||||
<FreqConfig
|
||||
flow={flow}
|
||||
firstFire={firstFire}
|
||||
update={update}
|
||||
{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"
|
||||
/>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Per-frequency config panels
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface FreqConfigProps {
|
||||
flow: FlowState;
|
||||
firstFire: DateTime;
|
||||
update: <K extends keyof FlowState>(k: K, v: FlowState[K]) => void;
|
||||
interface RadioRowProps {
|
||||
name: string;
|
||||
value: string;
|
||||
checked: boolean;
|
||||
onChange: () => void;
|
||||
label: string;
|
||||
}
|
||||
|
||||
function FreqConfig({ flow, firstFire, update }: FreqConfigProps) {
|
||||
const cron = flowToCron(flow, firstFire);
|
||||
const wrap = (children: React.ReactNode) => (
|
||||
<div className="space-y-3 border-t border-border bg-muted/20 px-4 py-3">
|
||||
{children}
|
||||
{cron ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Cron: <code className="font-mono text-foreground">{cron}</code>
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
function RadioRow({ name, value, 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"
|
||||
/>
|
||||
<span className="text-sm">{label}</span>
|
||||
</label>
|
||||
);
|
||||
|
||||
switch (flow.freq) {
|
||||
case "none":
|
||||
return null;
|
||||
|
||||
case "minute":
|
||||
return wrap(
|
||||
<div className="flex items-center gap-2">
|
||||
<Label htmlFor="rp-minute" className="text-sm">
|
||||
Every
|
||||
</Label>
|
||||
<Input
|
||||
id="rp-minute"
|
||||
type="number"
|
||||
min={1}
|
||||
max={59}
|
||||
value={flow.minuteInterval}
|
||||
onChange={(e) => update("minuteInterval", Number(e.target.value) || 1)}
|
||||
className="h-8 w-20"
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
minute{flow.minuteInterval === 1 ? "" : "s"}
|
||||
</span>
|
||||
</div>,
|
||||
);
|
||||
|
||||
case "hour":
|
||||
return wrap(
|
||||
<div className="flex items-center gap-2">
|
||||
<Label htmlFor="rp-hour" className="text-sm">
|
||||
Every
|
||||
</Label>
|
||||
<Input
|
||||
id="rp-hour"
|
||||
type="number"
|
||||
min={1}
|
||||
max={23}
|
||||
value={flow.hourInterval}
|
||||
onChange={(e) => update("hourInterval", Number(e.target.value) || 1)}
|
||||
className="h-8 w-20"
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
hour{flow.hourInterval === 1 ? "" : "s"} (at minute :{firstFire.toFormat("mm")})
|
||||
</span>
|
||||
</div>,
|
||||
);
|
||||
|
||||
case "day":
|
||||
// No extra config — outer time picker fully specifies the cron.
|
||||
return wrap(
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Uses the time picker above. Adjust the time to change when it fires each day.
|
||||
</p>,
|
||||
);
|
||||
|
||||
case "week":
|
||||
return wrap(
|
||||
<div className="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 = flow.weekdays.includes(cronDow);
|
||||
return (
|
||||
<button
|
||||
key={iso}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
update(
|
||||
"weekdays",
|
||||
active
|
||||
? flow.weekdays.filter((d) => d !== cronDow)
|
||||
: [...flow.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>
|
||||
</div>,
|
||||
);
|
||||
|
||||
case "month":
|
||||
return wrap(
|
||||
<div className="flex items-center gap-2">
|
||||
<Label htmlFor="rp-monthday" className="text-sm">
|
||||
Day of the month
|
||||
</Label>
|
||||
<Input
|
||||
id="rp-monthday"
|
||||
type="number"
|
||||
min={1}
|
||||
max={31}
|
||||
value={flow.monthDay}
|
||||
onChange={(e) => update("monthDay", Number(e.target.value) || 1)}
|
||||
className="h-8 w-20"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Months without this day skip naturally (e.g. 31st)
|
||||
</span>
|
||||
</div>,
|
||||
);
|
||||
|
||||
case "year":
|
||||
return wrap(
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Label htmlFor="rp-month" className="text-sm">
|
||||
Month
|
||||
</Label>
|
||||
<select
|
||||
id="rp-month"
|
||||
value={flow.month}
|
||||
onChange={(e) => update("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"
|
||||
>
|
||||
{[
|
||||
"January", "February", "March", "April", "May", "June",
|
||||
"July", "August", "September", "October", "November", "December",
|
||||
].map((name, i) => (
|
||||
<option key={name} value={i + 1}>
|
||||
{name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Label htmlFor="rp-year-monthday" className="text-sm">
|
||||
Day
|
||||
</Label>
|
||||
<Input
|
||||
id="rp-year-monthday"
|
||||
type="number"
|
||||
min={1}
|
||||
max={31}
|
||||
value={flow.monthDay}
|
||||
onChange={(e) => update("monthDay", Number(e.target.value) || 1)}
|
||||
className="h-8 w-20"
|
||||
/>
|
||||
</div>
|
||||
</div>,
|
||||
);
|
||||
|
||||
case "cron":
|
||||
return wrap(
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="rp-cron" className="text-xs text-muted-foreground">
|
||||
Cron expression
|
||||
</Label>
|
||||
<Input
|
||||
id="rp-cron"
|
||||
value={flow.customCron}
|
||||
onChange={(e) => update("customCron", e.target.value)}
|
||||
placeholder="0 9 * * 1-5"
|
||||
className="h-8 font-mono text-sm"
|
||||
spellCheck={false}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
5-field (<code className="font-mono">m h dom mon dow</code>) or 6-field
|
||||
with seconds (<code className="font-mono">s m h dom mon dow</code>). Examples:
|
||||
</p>
|
||||
<ul className="text-xs text-muted-foreground font-mono space-y-0.5 pl-3">
|
||||
<li><span className="text-foreground">0 9 * * 1-5</span> — 9 am on weekdays</li>
|
||||
<li><span className="text-foreground">*/15 * * * *</span> — every 15 minutes</li>
|
||||
<li><span className="text-foreground">0 9,12,18 * * *</span> — 9, 12, 18 every day</li>
|
||||
<li><span className="text-foreground">0 0 1 * *</span> — midnight on the 1st of every month</li>
|
||||
</ul>
|
||||
</div>,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,15 +2,10 @@ import { describe, it, expect } from "vitest";
|
||||
import { DateTime } from "luxon";
|
||||
import {
|
||||
buildRrule,
|
||||
defaultFlowState,
|
||||
describeRecurrence,
|
||||
flowFromCron,
|
||||
flowToCron,
|
||||
freqChoices,
|
||||
isoWeekdayToCron,
|
||||
kindFromRrule,
|
||||
specFromRrule,
|
||||
type FlowState,
|
||||
type RecurrenceSpec,
|
||||
} from "./recurrence";
|
||||
|
||||
@ -166,77 +161,7 @@ describe("specFromRrule / kindFromRrule", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("cron flow — frequency choice + per-frequency config", () => {
|
||||
const baseFlow = (over: Partial<FlowState> = {}): FlowState => ({
|
||||
...defaultFlowState(FIRST),
|
||||
...over,
|
||||
});
|
||||
|
||||
it("freqChoices lists exactly the 8 top-level options in order", () => {
|
||||
expect(freqChoices(FIRST).map((c) => c.id)).toEqual([
|
||||
"none",
|
||||
"minute",
|
||||
"hour",
|
||||
"day",
|
||||
"week",
|
||||
"month",
|
||||
"year",
|
||||
"cron",
|
||||
]);
|
||||
// Time-bearing labels use the first-fire's HH:MM (09:00).
|
||||
const lookup = (id: string) => freqChoices(FIRST).find((c) => c.id === id);
|
||||
expect(lookup("day")?.label).toBe("Every day at 09:00");
|
||||
expect(lookup("week")?.label).toBe("Every week at 09:00");
|
||||
expect(lookup("month")?.label).toBe("Every month at 09:00");
|
||||
expect(lookup("year")?.label).toBe("Every year at 09:00");
|
||||
});
|
||||
|
||||
it("flowToCron compiles every freq + config combination correctly", () => {
|
||||
expect(flowToCron(baseFlow({ freq: "none" }), FIRST)).toBe(null);
|
||||
expect(flowToCron(baseFlow({ freq: "minute", minuteInterval: 1 }), FIRST)).toBe(
|
||||
"* * * * *",
|
||||
);
|
||||
expect(flowToCron(baseFlow({ freq: "minute", minuteInterval: 15 }), FIRST)).toBe(
|
||||
"*/15 * * * *",
|
||||
);
|
||||
expect(flowToCron(baseFlow({ freq: "hour", hourInterval: 1 }), FIRST)).toBe(
|
||||
"0 * * * *",
|
||||
);
|
||||
expect(flowToCron(baseFlow({ freq: "hour", hourInterval: 4 }), FIRST)).toBe(
|
||||
"0 */4 * * *",
|
||||
);
|
||||
expect(flowToCron(baseFlow({ freq: "day" }), FIRST)).toBe("0 9 * * *");
|
||||
expect(
|
||||
flowToCron(baseFlow({ freq: "week", weekdays: [1, 3, 5] }), FIRST),
|
||||
).toBe("0 9 * * 1,3,5");
|
||||
// Empty weekday list yields null (config not yet valid).
|
||||
expect(flowToCron(baseFlow({ freq: "week", weekdays: [] }), FIRST)).toBe(null);
|
||||
expect(flowToCron(baseFlow({ freq: "month", monthDay: 13 }), FIRST)).toBe(
|
||||
"0 9 13 * *",
|
||||
);
|
||||
expect(
|
||||
flowToCron(baseFlow({ freq: "year", monthDay: 25, month: 12 }), FIRST),
|
||||
).toBe("0 9 25 12 *");
|
||||
expect(
|
||||
flowToCron(baseFlow({ freq: "cron", customCron: "0 9 * * 1-5" }), FIRST),
|
||||
).toBe("0 9 * * 1-5");
|
||||
});
|
||||
|
||||
it("flowToCron clamps out-of-range numbers", () => {
|
||||
expect(flowToCron(baseFlow({ freq: "minute", minuteInterval: 0 }), FIRST)).toBe(
|
||||
"* * * * *", // clamped to 1
|
||||
);
|
||||
expect(flowToCron(baseFlow({ freq: "minute", minuteInterval: 999 }), FIRST)).toBe(
|
||||
"*/59 * * * *", // clamped to 59
|
||||
);
|
||||
expect(flowToCron(baseFlow({ freq: "month", monthDay: 0 }), FIRST)).toBe(
|
||||
"0 9 1 * *",
|
||||
);
|
||||
expect(
|
||||
flowToCron(baseFlow({ freq: "year", monthDay: 99, month: 99 }), FIRST),
|
||||
).toBe("0 9 31 12 *");
|
||||
});
|
||||
|
||||
describe("cron weekday helper + buildRrule/specFromRrule round-trip", () => {
|
||||
it("isoWeekdayToCron maps Sun (ISO 7) to cron 0 and Mon-Sat unchanged", () => {
|
||||
expect(isoWeekdayToCron(1)).toBe(1); // Mon
|
||||
expect(isoWeekdayToCron(2)).toBe(2);
|
||||
@ -244,51 +169,6 @@ describe("cron flow — frequency choice + per-frequency config", () => {
|
||||
expect(isoWeekdayToCron(7)).toBe(0); // Sun
|
||||
});
|
||||
|
||||
it("defaultFlowState seeds first-fire-aware values", () => {
|
||||
const s = defaultFlowState(FIRST);
|
||||
expect(s.freq).toBe("none");
|
||||
expect(s.weekdays).toEqual([3]); // Wed
|
||||
expect(s.monthDay).toBe(13);
|
||||
expect(s.month).toBe(5);
|
||||
expect(s.customCron).toBe("0 9 * * *");
|
||||
});
|
||||
|
||||
it("flowFromCron round-trips every cron flow output", () => {
|
||||
const cases: Array<{ flow: Partial<FlowState>; cron: string }> = [
|
||||
{ flow: { freq: "minute", minuteInterval: 1 }, cron: "* * * * *" },
|
||||
{ flow: { freq: "minute", minuteInterval: 5 }, cron: "*/5 * * * *" },
|
||||
{ flow: { freq: "hour", hourInterval: 1 }, cron: "0 * * * *" },
|
||||
{ flow: { freq: "hour", hourInterval: 6 }, cron: "0 */6 * * *" },
|
||||
{ flow: { freq: "day" }, cron: "0 9 * * *" },
|
||||
{ flow: { freq: "week", weekdays: [1, 3, 5] }, cron: "0 9 * * 1,3,5" },
|
||||
{ flow: { freq: "month", monthDay: 13 }, cron: "0 9 13 * *" },
|
||||
{ flow: { freq: "year", monthDay: 13, month: 5 }, cron: "0 9 13 5 *" },
|
||||
];
|
||||
for (const c of cases) {
|
||||
const parsed = flowFromCron(`CRON:${c.cron}`, FIRST);
|
||||
// We're checking the freq lands right and the relevant config field
|
||||
// round-trips. Other fields are seeded from defaults.
|
||||
expect(parsed.freq).toBe(c.flow.freq);
|
||||
for (const k of Object.keys(c.flow) as Array<keyof FlowState>) {
|
||||
expect(parsed[k]).toEqual(c.flow[k]);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("flowFromCron parses BYDAY ranges (1-5) into expanded weekday list", () => {
|
||||
expect(flowFromCron("CRON:0 9 * * 1-5", FIRST)).toMatchObject({
|
||||
freq: "week",
|
||||
weekdays: [1, 2, 3, 4, 5],
|
||||
});
|
||||
});
|
||||
|
||||
it("flowFromCron drops unrecognised expressions into the cron textbox", () => {
|
||||
expect(flowFromCron("CRON:30 0,12 * * *", FIRST)).toMatchObject({
|
||||
freq: "cron",
|
||||
customCron: "30 0,12 * * *",
|
||||
});
|
||||
});
|
||||
|
||||
it("buildRrule + specFromRrule still round-trip CRON: rules", () => {
|
||||
const spec: RecurrenceSpec = {
|
||||
kind: "cron",
|
||||
|
||||
@ -215,168 +215,7 @@ export function kindFromRrule(rrule: string | null | undefined): RecurrenceKind
|
||||
return specFromRrule(rrule).kind;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cron flow — pick a frequency, then configure it. Every selection compiles
|
||||
// down to a single cron expression that lives in `reminders.rrule` with the
|
||||
// `CRON:` sentinel. The bot's shared `nextOccurrence` dispatches cron rules
|
||||
// through cron-parser.
|
||||
// ---------------------------------------------------------------------------
|
||||
export type FreqChoice =
|
||||
| "none"
|
||||
| "minute"
|
||||
| "hour"
|
||||
| "day"
|
||||
| "week"
|
||||
| "month"
|
||||
| "year"
|
||||
| "cron";
|
||||
|
||||
export interface FlowState {
|
||||
freq: FreqChoice;
|
||||
/** "Every N minutes" — used by `minute`. */
|
||||
minuteInterval: number;
|
||||
/** "Every N hours" — used by `hour`. */
|
||||
hourInterval: number;
|
||||
/** Cron weekday list (0=Sun..6=Sat) — used by `week`. */
|
||||
weekdays: number[];
|
||||
/** Day-of-month (1-31) — used by `month` and `year`. */
|
||||
monthDay: number;
|
||||
/** Month-of-year (1-12) — used by `year`. */
|
||||
month: number;
|
||||
/** Free-form cron expression — used by `cron`. */
|
||||
customCron: string;
|
||||
}
|
||||
|
||||
/** Map ISO weekday (1=Mon..7=Sun) → cron weekday (0=Sun..6=Sat). */
|
||||
export function isoWeekdayToCron(iso: number): number {
|
||||
return iso === 7 ? 0 : iso;
|
||||
}
|
||||
|
||||
/** Sensible default flow state seeded from the first-fire DateTime. */
|
||||
export function defaultFlowState(firstFire: DateTime): FlowState {
|
||||
return {
|
||||
freq: "none",
|
||||
minuteInterval: 5,
|
||||
hourInterval: 1,
|
||||
weekdays: [isoWeekdayToCron(firstFire.weekday)],
|
||||
monthDay: firstFire.day,
|
||||
month: firstFire.month,
|
||||
customCron: `${firstFire.minute} ${firstFire.hour} * * *`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Compile a flow state to a cron expression. The HH:MM portion comes from
|
||||
* the user's first-fire (the outer date+time picker), so changing the time
|
||||
* also updates the cron the picker emits.
|
||||
*
|
||||
* Returns null when the flow has no recurrence ("none") or the chosen
|
||||
* config doesn't yet make sense (e.g. weekly with no weekdays selected).
|
||||
*/
|
||||
export function flowToCron(s: FlowState, firstFire: DateTime): string | null {
|
||||
const m = firstFire.minute;
|
||||
const h = firstFire.hour;
|
||||
const clamp = (n: number, lo: number, hi: number) =>
|
||||
Number.isFinite(n) ? Math.min(Math.max(Math.floor(n), lo), hi) : lo;
|
||||
|
||||
switch (s.freq) {
|
||||
case "none":
|
||||
return null;
|
||||
case "minute": {
|
||||
const n = clamp(s.minuteInterval, 1, 59);
|
||||
return n === 1 ? "* * * * *" : `*/${n} * * * *`;
|
||||
}
|
||||
case "hour": {
|
||||
const n = clamp(s.hourInterval, 1, 23);
|
||||
return n === 1 ? `${m} * * * *` : `${m} */${n} * * *`;
|
||||
}
|
||||
case "day":
|
||||
return `${m} ${h} * * *`;
|
||||
case "week": {
|
||||
if (!s.weekdays.length) return null;
|
||||
const dow = s.weekdays.slice().sort((a, b) => a - b).join(",");
|
||||
return `${m} ${h} * * ${dow}`;
|
||||
}
|
||||
case "month": {
|
||||
const d = clamp(s.monthDay, 1, 31);
|
||||
return `${m} ${h} ${d} * *`;
|
||||
}
|
||||
case "year": {
|
||||
const d = clamp(s.monthDay, 1, 31);
|
||||
const mon = clamp(s.month, 1, 12);
|
||||
return `${m} ${h} ${d} ${mon} *`;
|
||||
}
|
||||
case "cron":
|
||||
return s.customCron.trim() || null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort reverse: read a stored cron expression back into a flow
|
||||
* state so the picker can show the user's previous choice when editing.
|
||||
* Anything that doesn't fit a recognised shape (the picker's own output)
|
||||
* lands on `cron` with the raw expression in the textbox.
|
||||
*/
|
||||
export function flowFromCron(rule: string | null | undefined, firstFire: DateTime): FlowState {
|
||||
const base = defaultFlowState(firstFire);
|
||||
if (!rule) return base;
|
||||
const expr = rule.startsWith(CRON_PREFIX) ? rule.slice(CRON_PREFIX.length) : rule;
|
||||
if (!expr.trim()) return base;
|
||||
// Recognise patterns the picker emits.
|
||||
let m: RegExpMatchArray | null;
|
||||
if (expr === "* * * * *") return { ...base, freq: "minute", minuteInterval: 1 };
|
||||
if ((m = expr.match(/^\*\/(\d+) \* \* \* \*$/))) {
|
||||
return { ...base, freq: "minute", minuteInterval: Number(m[1]) };
|
||||
}
|
||||
if ((m = expr.match(/^(\d+) \* \* \* \*$/))) {
|
||||
return { ...base, freq: "hour", hourInterval: 1 };
|
||||
}
|
||||
if ((m = expr.match(/^(\d+) \*\/(\d+) \* \* \*$/))) {
|
||||
return { ...base, freq: "hour", hourInterval: Number(m[2]) };
|
||||
}
|
||||
if ((m = expr.match(/^(\d+) (\d+) \* \* \*$/))) {
|
||||
return { ...base, freq: "day" };
|
||||
}
|
||||
if ((m = expr.match(/^(\d+) (\d+) \* \* ([0-9,\-]+)$/))) {
|
||||
const days = m[3]!.split(",").flatMap((p) => {
|
||||
const r = p.match(/^(\d+)-(\d+)$/);
|
||||
if (r) {
|
||||
const out: number[] = [];
|
||||
for (let i = Number(r[1]); i <= Number(r[2]); i++) out.push(i);
|
||||
return out;
|
||||
}
|
||||
return [Number(p)];
|
||||
});
|
||||
return { ...base, freq: "week", weekdays: days };
|
||||
}
|
||||
if ((m = expr.match(/^(\d+) (\d+) (\d+) \* \*$/))) {
|
||||
return { ...base, freq: "month", monthDay: Number(m[3]) };
|
||||
}
|
||||
if ((m = expr.match(/^(\d+) (\d+) (\d+) (\d+) \*$/))) {
|
||||
return { ...base, freq: "year", monthDay: Number(m[3]), month: Number(m[4]) };
|
||||
}
|
||||
// Anything else: park it in the custom cron box.
|
||||
return { ...base, freq: "cron", customCron: expr };
|
||||
}
|
||||
|
||||
/** Frequency-choice descriptor for the radio list. */
|
||||
export interface FreqChoiceDescriptor {
|
||||
id: FreqChoice;
|
||||
label: string;
|
||||
hint?: string;
|
||||
}
|
||||
|
||||
/** First-fire-aware list of the 8 top-level frequency choices. */
|
||||
export function freqChoices(firstFire: DateTime): FreqChoiceDescriptor[] {
|
||||
const t = firstFire.toFormat("HH:mm");
|
||||
return [
|
||||
{ id: "none", label: "Don't repeat", hint: "Fires once and ends" },
|
||||
{ id: "minute", label: "Every N minutes", hint: "Sub-hour cadence" },
|
||||
{ id: "hour", label: "Every N hours", hint: `At minute :${firstFire.toFormat("mm")}` },
|
||||
{ id: "day", label: `Every day at ${t}` },
|
||||
{ id: "week", label: `Every week at ${t}`, hint: "Choose which weekdays" },
|
||||
{ id: "month", label: `Every month at ${t}`, hint: "Choose which day of the month" },
|
||||
{ id: "year", label: `Every year at ${t}`, hint: "Choose which month and day" },
|
||||
{ id: "cron", label: "Custom cron expression…", hint: "Power-user — full combinational control" },
|
||||
];
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user