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 { useEffect, useState } from "react";
|
||||||
import { DateTime } from "luxon";
|
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 { 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 { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import {
|
import {
|
||||||
WEEKDAY_LABELS,
|
WEEKDAY_LABELS,
|
||||||
defaultFlowState,
|
|
||||||
flowFromCron,
|
|
||||||
flowToCron,
|
|
||||||
freqChoices,
|
|
||||||
isoWeekdayToCron,
|
isoWeekdayToCron,
|
||||||
type FlowState,
|
|
||||||
type FreqChoice,
|
|
||||||
type RecurrenceSpec,
|
type RecurrenceSpec,
|
||||||
} from "@/lib/recurrence";
|
} from "@/lib/recurrence";
|
||||||
|
|
||||||
interface RecurrencePickerProps {
|
interface RecurrencePickerProps {
|
||||||
/** First fire of the reminder — drives the HH:MM in the cron output and the
|
/** First fire — drives the HH:MM in the cron output and the default
|
||||||
* default day-of-month / month / weekday for the per-frequency configurators. */
|
* 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
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
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
|
* The form shows a single read-only field summarising the current rule.
|
||||||
* highlighted; only its config panel below it expands).
|
* Clicking it opens a dialog with a tab strip across the top:
|
||||||
* 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.
|
|
||||||
*
|
*
|
||||||
* "Don't repeat" is a one-click exit (no config). "Custom cron…" lets
|
* [Don't repeat] [Daily] [Weekly] [Monthly] [Yearly]
|
||||||
* a power-user type any expression directly.
|
*
|
||||||
|
* 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) {
|
export function RecurrencePicker({ firstFire, value, onChange }: RecurrencePickerProps) {
|
||||||
// The flow state is reverse-engineered from the incoming `value`
|
const [open, setOpen] = useState(false);
|
||||||
// when the picker mounts so editing an existing reminder lands on
|
const [draft, setDraft] = useState<Draft>(() =>
|
||||||
// the right radio. Subsequent edits live in local state.
|
draftFromCron(value.kind === "cron" ? value.cron ?? null : null, firstFire),
|
||||||
const [flow, setFlow] = useState<FlowState>(() =>
|
|
||||||
flowFromCron(value.kind === "cron" ? value.cron ?? null : null, firstFire),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Re-derive the cron when either the flow or the first-fire changes
|
// When the dialog opens, re-sync the draft from the parent value so
|
||||||
// (changing the time picker outside should refresh "at HH:MM").
|
// the user always starts from the current saved rule.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const cron = flowToCron(flow, firstFire);
|
if (open) {
|
||||||
|
setDraft(
|
||||||
|
draftFromCron(value.kind === "cron" ? value.cron ?? null : null, firstFire),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const updateDraft = <K extends keyof Draft>(k: K, v: Draft[K]) =>
|
||||||
|
setDraft((prev) => ({ ...prev, [k]: v }));
|
||||||
|
|
||||||
|
function handleSave() {
|
||||||
|
const cron = draftToCron(draft, firstFire);
|
||||||
if (!cron) {
|
if (!cron) {
|
||||||
if (value.kind !== "none") {
|
|
||||||
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 !== cron) {
|
|
||||||
onChange({
|
onChange({
|
||||||
kind: "cron",
|
kind: "cron",
|
||||||
interval: 1,
|
interval: 1,
|
||||||
@ -65,165 +219,98 @@ export function RecurrencePicker({ firstFire, value, onChange }: RecurrencePicke
|
|||||||
end: { kind: "never" },
|
end: { kind: "never" },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
setOpen(false);
|
||||||
}, [flow, firstFire.minute, firstFire.hour, firstFire.day, firstFire.month, firstFire.weekday]);
|
}
|
||||||
|
|
||||||
const update = <K extends keyof FlowState>(k: K, v: FlowState[K]) =>
|
function handleReset() {
|
||||||
setFlow((prev) => ({ ...prev, [k]: v }));
|
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 (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="space-y-1.5">
|
||||||
<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>
|
||||||
|
|
||||||
<div className="overflow-hidden rounded-xl border border-border bg-card">
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
<ul className="divide-y divide-border" role="radiogroup" aria-label="Repeat schedule">
|
<DialogTrigger asChild>
|
||||||
{freqChoices(firstFire).map((c) => {
|
|
||||||
const selected = flow.freq === c.id;
|
|
||||||
return (
|
|
||||||
<li key={c.id}>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
role="radio"
|
|
||||||
aria-checked={selected}
|
|
||||||
onClick={() => update("freq", c.id)}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex w-full items-center gap-3 px-4 py-3 text-left transition-colors",
|
"flex h-9 w-full items-center justify-between rounded-lg border border-input bg-background px-3 text-left text-sm",
|
||||||
selected ? "bg-primary/5 text-foreground" : "hover:bg-muted text-foreground",
|
"hover:border-primary/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span
|
<span className="flex items-center gap-2 min-w-0">
|
||||||
aria-hidden
|
<CalendarRangeIcon className="size-3.5 shrink-0 text-muted-foreground" />
|
||||||
className={cn(
|
<span className="truncate">{triggerSummary}</span>
|
||||||
"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",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{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>
|
</span>
|
||||||
|
<ChevronDownIcon className="size-3.5 shrink-0 text-muted-foreground" />
|
||||||
</button>
|
</button>
|
||||||
|
</DialogTrigger>
|
||||||
|
|
||||||
{selected && (
|
<DialogContent>
|
||||||
<FreqConfig
|
<DialogHeader>
|
||||||
flow={flow}
|
<DialogTitle>Repeat schedule</DialogTitle>
|
||||||
firstFire={firstFire}
|
</DialogHeader>
|
||||||
update={update}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
<Tabs value={draft.type} onValueChange={(v) => updateDraft("type", v as RecurrenceType)}>
|
||||||
// Per-frequency config panels
|
<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>
|
||||||
|
|
||||||
interface FreqConfigProps {
|
<TabsContent value="none" className="pt-3">
|
||||||
flow: FlowState;
|
<p className="text-sm text-muted-foreground">
|
||||||
firstFire: DateTime;
|
The reminder fires once at the date and time you picked above and ends.
|
||||||
update: <K extends keyof FlowState>(k: K, v: FlowState[K]) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
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>
|
</p>
|
||||||
) : null}
|
</TabsContent>
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
switch (flow.freq) {
|
<TabsContent value="daily" className="pt-3 space-y-2">
|
||||||
case "none":
|
<RadioRow
|
||||||
return null;
|
name="daily-mode"
|
||||||
|
value="every_day"
|
||||||
case "minute":
|
checked={draft.dailyMode === "every_day"}
|
||||||
return wrap(
|
onChange={() => updateDraft("dailyMode", "every_day")}
|
||||||
<div className="flex items-center gap-2">
|
label="Every day"
|
||||||
<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">
|
<RadioRow
|
||||||
minute{flow.minuteInterval === 1 ? "" : "s"}
|
name="daily-mode"
|
||||||
</span>
|
value="weekdays"
|
||||||
</div>,
|
checked={draft.dailyMode === "weekdays"}
|
||||||
);
|
onChange={() => updateDraft("dailyMode", "weekdays")}
|
||||||
|
label="Every weekday (Mon – Fri)"
|
||||||
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">
|
</TabsContent>
|
||||||
hour{flow.hourInterval === 1 ? "" : "s"} (at minute :{firstFire.toFormat("mm")})
|
|
||||||
</span>
|
|
||||||
</div>,
|
|
||||||
);
|
|
||||||
|
|
||||||
case "day":
|
<TabsContent value="weekly" className="pt-3 space-y-2">
|
||||||
// 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>
|
<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 }) => {
|
||||||
const cronDow = isoWeekdayToCron(iso);
|
const cronDow = isoWeekdayToCron(iso);
|
||||||
const active = flow.weekdays.includes(cronDow);
|
const active = draft.weekdays.includes(cronDow);
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={iso}
|
key={iso}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
update(
|
updateDraft(
|
||||||
"weekdays",
|
"weekdays",
|
||||||
active
|
active
|
||||||
? flow.weekdays.filter((d) => d !== cronDow)
|
? draft.weekdays.filter((d) => d !== cronDow)
|
||||||
: [...flow.weekdays, cronDow].sort((a, b) => a - b),
|
: [...draft.weekdays, cronDow].sort((a, b) => a - b),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
aria-pressed={active}
|
aria-pressed={active}
|
||||||
@ -239,93 +326,111 @@ function FreqConfig({ flow, firstFire, update }: FreqConfigProps) {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>,
|
</TabsContent>
|
||||||
);
|
|
||||||
|
|
||||||
case "month":
|
<TabsContent value="monthly" className="pt-3 space-y-2">
|
||||||
return wrap(
|
<Label htmlFor="rp-monthly-day" className="text-sm">
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Label htmlFor="rp-monthday" className="text-sm">
|
|
||||||
Day of the month
|
Day of the month
|
||||||
</Label>
|
</Label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
<Input
|
<Input
|
||||||
id="rp-monthday"
|
id="rp-monthly-day"
|
||||||
type="number"
|
type="number"
|
||||||
min={1}
|
min={1}
|
||||||
max={31}
|
max={31}
|
||||||
value={flow.monthDay}
|
value={draft.monthDay}
|
||||||
onChange={(e) => update("monthDay", Number(e.target.value) || 1)}
|
onChange={(e) => updateDraft("monthDay", Number(e.target.value) || 1)}
|
||||||
className="h-8 w-20"
|
className="h-8 w-24"
|
||||||
/>
|
/>
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
Months without this day skip naturally (e.g. 31st)
|
Months without this day skip naturally (e.g. 31st)
|
||||||
</span>
|
</span>
|
||||||
</div>,
|
</div>
|
||||||
);
|
</TabsContent>
|
||||||
|
|
||||||
case "year":
|
<TabsContent value="yearly" className="pt-3 space-y-2">
|
||||||
return wrap(
|
<div className="flex flex-wrap items-end gap-3">
|
||||||
<div className="space-y-2">
|
<div className="space-y-1">
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<Label htmlFor="rp-yearly-month" className="text-xs text-muted-foreground">
|
||||||
<Label htmlFor="rp-month" className="text-sm">
|
|
||||||
Month
|
Month
|
||||||
</Label>
|
</Label>
|
||||||
<select
|
<select
|
||||||
id="rp-month"
|
id="rp-yearly-month"
|
||||||
value={flow.month}
|
value={draft.month}
|
||||||
onChange={(e) => update("month", Number(e.target.value))}
|
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"
|
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) => (
|
||||||
"January", "February", "March", "April", "May", "June",
|
|
||||||
"July", "August", "September", "October", "November", "December",
|
|
||||||
].map((name, i) => (
|
|
||||||
<option key={name} value={i + 1}>
|
<option key={name} value={i + 1}>
|
||||||
{name}
|
{name}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
<Label htmlFor="rp-year-monthday" className="text-sm">
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="rp-yearly-day" className="text-xs text-muted-foreground">
|
||||||
Day
|
Day
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="rp-year-monthday"
|
id="rp-yearly-day"
|
||||||
type="number"
|
type="number"
|
||||||
min={1}
|
min={1}
|
||||||
max={31}
|
max={31}
|
||||||
value={flow.monthDay}
|
value={draft.monthDay}
|
||||||
onChange={(e) => update("monthDay", Number(e.target.value) || 1)}
|
onChange={(e) => updateDraft("monthDay", Number(e.target.value) || 1)}
|
||||||
className="h-8 w-20"
|
className="h-8 w-20"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>,
|
</div>
|
||||||
);
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
case "cron":
|
{/* Live confirmation — always visible so the user sees what they
|
||||||
return wrap(
|
are about to save. */}
|
||||||
<div className="space-y-2">
|
<div className="rounded-lg bg-primary/5 px-3 py-2 text-sm">
|
||||||
<Label htmlFor="rp-cron" className="text-xs text-muted-foreground">
|
<span className="font-medium">Fires:</span>{" "}
|
||||||
Cron expression
|
<span className="text-primary/90">{describeDraft(draft, firstFire)}</span>
|
||||||
</Label>
|
</div>
|
||||||
<Input
|
|
||||||
id="rp-cron"
|
<DialogFooter className="gap-2 sm:justify-between" showCloseButton>
|
||||||
value={flow.customCron}
|
<Button type="button" variant="ghost" size="sm" onClick={handleReset}>
|
||||||
onChange={(e) => update("customCron", e.target.value)}
|
Reset
|
||||||
placeholder="0 9 * * 1-5"
|
</Button>
|
||||||
className="h-8 font-mono text-sm"
|
<div className="flex gap-2">
|
||||||
spellCheck={false}
|
<Button type="button" variant="outline" size="sm" onClick={() => setOpen(false)}>
|
||||||
/>
|
Cancel
|
||||||
<p className="text-xs text-muted-foreground">
|
</Button>
|
||||||
5-field (<code className="font-mono">m h dom mon dow</code>) or 6-field
|
<Button type="button" size="sm" onClick={handleSave}>
|
||||||
with seconds (<code className="font-mono">s m h dom mon dow</code>). Examples:
|
Save
|
||||||
</p>
|
</Button>
|
||||||
<ul className="text-xs text-muted-foreground font-mono space-y-0.5 pl-3">
|
</div>
|
||||||
<li><span className="text-foreground">0 9 * * 1-5</span> — 9 am on weekdays</li>
|
</DialogFooter>
|
||||||
<li><span className="text-foreground">*/15 * * * *</span> — every 15 minutes</li>
|
</DialogContent>
|
||||||
<li><span className="text-foreground">0 9,12,18 * * *</span> — 9, 12, 18 every day</li>
|
</Dialog>
|
||||||
<li><span className="text-foreground">0 0 1 * *</span> — midnight on the 1st of every month</li>
|
</div>
|
||||||
</ul>
|
|
||||||
</div>,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface RadioRowProps {
|
||||||
|
name: string;
|
||||||
|
value: string;
|
||||||
|
checked: boolean;
|
||||||
|
onChange: () => void;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,15 +2,10 @@ import { describe, it, expect } from "vitest";
|
|||||||
import { DateTime } from "luxon";
|
import { DateTime } from "luxon";
|
||||||
import {
|
import {
|
||||||
buildRrule,
|
buildRrule,
|
||||||
defaultFlowState,
|
|
||||||
describeRecurrence,
|
describeRecurrence,
|
||||||
flowFromCron,
|
|
||||||
flowToCron,
|
|
||||||
freqChoices,
|
|
||||||
isoWeekdayToCron,
|
isoWeekdayToCron,
|
||||||
kindFromRrule,
|
kindFromRrule,
|
||||||
specFromRrule,
|
specFromRrule,
|
||||||
type FlowState,
|
|
||||||
type RecurrenceSpec,
|
type RecurrenceSpec,
|
||||||
} from "./recurrence";
|
} from "./recurrence";
|
||||||
|
|
||||||
@ -166,77 +161,7 @@ describe("specFromRrule / kindFromRrule", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("cron flow — frequency choice + per-frequency config", () => {
|
describe("cron weekday helper + buildRrule/specFromRrule round-trip", () => {
|
||||||
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 *");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("isoWeekdayToCron maps Sun (ISO 7) to cron 0 and Mon-Sat unchanged", () => {
|
it("isoWeekdayToCron maps Sun (ISO 7) to cron 0 and Mon-Sat unchanged", () => {
|
||||||
expect(isoWeekdayToCron(1)).toBe(1); // Mon
|
expect(isoWeekdayToCron(1)).toBe(1); // Mon
|
||||||
expect(isoWeekdayToCron(2)).toBe(2);
|
expect(isoWeekdayToCron(2)).toBe(2);
|
||||||
@ -244,51 +169,6 @@ describe("cron flow — frequency choice + per-frequency config", () => {
|
|||||||
expect(isoWeekdayToCron(7)).toBe(0); // Sun
|
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", () => {
|
it("buildRrule + specFromRrule still round-trip CRON: rules", () => {
|
||||||
const spec: RecurrenceSpec = {
|
const spec: RecurrenceSpec = {
|
||||||
kind: "cron",
|
kind: "cron",
|
||||||
|
|||||||
@ -215,168 +215,7 @@ export function kindFromRrule(rrule: string | null | undefined): RecurrenceKind
|
|||||||
return specFromRrule(rrule).kind;
|
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). */
|
/** Map ISO weekday (1=Mon..7=Sun) → cron weekday (0=Sun..6=Sat). */
|
||||||
export function isoWeekdayToCron(iso: number): number {
|
export function isoWeekdayToCron(iso: number): number {
|
||||||
return iso === 7 ? 0 : iso;
|
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