feat(recurrence): replace the long preset list with a guided cron flow
Per the user's ask: stop dumping 12 presets in front of the user. Walk
them through "pick a frequency, then configure it." Each choice
expands its config inline below the radio.
Picker (now 8 top-level choices):
○ Don't repeat (one-off)
○ Every N minutes → number input (1-59)
○ Every N hours → number input (1-23) at :MM
○ Every day at HH:MM (uses outer time picker)
○ Every week at HH:MM → weekday chip multi-select
○ Every month at HH:MM → day-of-month input (1-31)
○ Every year at HH:MM → month select + day input
○ Custom cron expression… → free-form textbox
Behaviour:
- Selecting a row reveals only that row's config; the others stay
collapsed so the screen stays calm.
- HH:MM in every "at HH:MM" label tracks the outer time picker — change
the time and every label updates instantly. Same for the cron
expression the picker emits.
- Every config change recompiles to a single cron string and pushes a
`{ kind: "cron", cron: "..." }` spec up to the parent. Empty weekday
list yields null (config not yet valid).
- Editing an existing reminder calls `flowFromCron(rule, firstFire)`
which reverse-engineers a flow state from the stored cron — including
expanding `1-5` ranges into a weekday chip list — so the right radio
is highlighted and config inputs are pre-populated.
- Anything not recognised by `flowFromCron` (legacy RRULE, hand-rolled
cron) lands on "Custom cron expression…" with the literal expression
in the textbox.
Helpers in `lib/recurrence.ts`:
- `FreqChoice` ("none" | "minute" | "hour" | "day" | "week" | "month"
| "year" | "cron") + `FlowState` interface with all config fields.
- `freqChoices(firstFire)` → first-fire-aware label list for the radio.
- `defaultFlowState(firstFire)` → seeds sensible defaults (today's
weekday, day-of-month, month, etc.).
- `flowToCron(flow, firstFire)` → cron string or null. Clamps
out-of-range integers.
- `flowFromCron(rule, firstFire)` → best-effort reverse mapping.
- `isoWeekdayToCron(iso)` → maps ISO 1-7 (Mon..Sun) to cron 0-6
(Sun..Sat).
Removed: the previous `presetToSpec` / `matchPreset` / `presetDescriptors`
+ `presetCron` family. They're superseded by the flow helpers.
Tests (+11 in recurrence.test.ts; total 139 web + 26 bot + 17 shared
= 182):
- freqChoices order and time-bearing labels
- flowToCron for every freq + config combination, including empty
weekday list returning null
- clamp behaviour for out-of-range minute/month-day/month integers
- isoWeekdayToCron for Mon..Sun
- defaultFlowState seeded fields
- flowFromCron round-trips every flow output exactly
- BYDAY range expansion (1-5 → [1,2,3,4,5])
- unrecognised expressions land on the cron textbox
- buildRrule + specFromRrule still handle CRON: prefixed strings
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
63b88c69b4
commit
b67d3c735e
@ -1,54 +1,75 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { DateTime } from "luxon";
|
||||
import { CheckIcon, RepeatIcon } from "lucide-react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
matchPreset,
|
||||
presetDescriptors,
|
||||
presetToSpec,
|
||||
type PresetId,
|
||||
WEEKDAY_LABELS,
|
||||
defaultFlowState,
|
||||
flowFromCron,
|
||||
flowToCron,
|
||||
freqChoices,
|
||||
isoWeekdayToCron,
|
||||
type FlowState,
|
||||
type FreqChoice,
|
||||
type RecurrenceSpec,
|
||||
} from "@/lib/recurrence";
|
||||
|
||||
interface RecurrencePickerProps {
|
||||
/** First fire of the reminder — drives preset labels and the cron strings
|
||||
* for "Every day at HH:MM" / "Every weekday at HH:MM" / etc. */
|
||||
/** 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. */
|
||||
firstFire: DateTime;
|
||||
value: RecurrenceSpec;
|
||||
onChange: (next: RecurrenceSpec) => void;
|
||||
}
|
||||
|
||||
// Cron-only Repeats picker.
|
||||
//
|
||||
// ┌─────────────────────────────────────────────────────┐
|
||||
// │ ○ Don't repeat Fires once and ends │
|
||||
// │ ○ Every minute every minute │
|
||||
// │ ○ Every 5/15/30 minutes every N minutes │
|
||||
// │ ○ Every hour at :00 │
|
||||
// │ ○ Every day at HH:MM │
|
||||
// │ ○ Every weekday/weekend at HH:MM │
|
||||
// │ ○ Every <weekday> at HH:MM │
|
||||
// │ ○ Every month on day <D> at HH:MM │
|
||||
// │ ○ Every year on <Mon D> at HH:MM │
|
||||
// │ ○ Custom cron expression… │
|
||||
// └─────────────────────────────────────────────────────┘
|
||||
//
|
||||
// Selecting a preset sets `value` to `{ kind: "cron", cron: "<expr>" }`.
|
||||
// "Don't repeat" sets `kind: "none"`. "Custom cron…" reveals a free-
|
||||
// form text input.
|
||||
/**
|
||||
* Guided cron flow.
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* "Don't repeat" is a one-click exit (no config). "Custom cron…" lets
|
||||
* a power-user type any expression directly.
|
||||
*/
|
||||
export function RecurrencePicker({ firstFire, value, onChange }: RecurrencePickerProps) {
|
||||
const activePreset = matchPreset(value, firstFire);
|
||||
// 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),
|
||||
);
|
||||
|
||||
function pickPreset(id: PresetId) {
|
||||
if (id === "cron" && value.kind === "cron") {
|
||||
// Already in custom mode — preserve whatever the user has typed.
|
||||
// Re-derive the cron when either the flow or the first-fire changes
|
||||
// (changing the time picker outside should refresh "at HH:MM").
|
||||
useEffect(() => {
|
||||
const cron = flowToCron(flow, firstFire);
|
||||
if (!cron) {
|
||||
if (value.kind !== "none") {
|
||||
onChange({ kind: "none", interval: 1, weeklyDays: [], end: { kind: "never" } });
|
||||
}
|
||||
return;
|
||||
}
|
||||
onChange(presetToSpec(id, firstFire));
|
||||
if (value.kind !== "cron" || value.cron !== cron) {
|
||||
onChange({
|
||||
kind: "cron",
|
||||
interval: 1,
|
||||
weeklyDays: [],
|
||||
cron,
|
||||
end: { kind: "never" },
|
||||
});
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [flow, firstFire.minute, firstFire.hour, firstFire.day, firstFire.month, firstFire.weekday]);
|
||||
|
||||
const update = <K extends keyof FlowState>(k: K, v: FlowState[K]) =>
|
||||
setFlow((prev) => ({ ...prev, [k]: v }));
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
@ -59,20 +80,18 @@ export function RecurrencePicker({ firstFire, value, onChange }: RecurrencePicke
|
||||
|
||||
<div className="overflow-hidden rounded-xl border border-border bg-card">
|
||||
<ul className="divide-y divide-border" role="radiogroup" aria-label="Repeat schedule">
|
||||
{presetDescriptors(firstFire).map((p) => {
|
||||
const selected = activePreset === p.id;
|
||||
{freqChoices(firstFire).map((c) => {
|
||||
const selected = flow.freq === c.id;
|
||||
return (
|
||||
<li key={p.id}>
|
||||
<li key={c.id}>
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={selected}
|
||||
onClick={() => pickPreset(p.id)}
|
||||
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",
|
||||
selected ? "bg-primary/5 text-foreground" : "hover:bg-muted text-foreground",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
@ -86,64 +105,219 @@ export function RecurrencePicker({ firstFire, value, onChange }: RecurrencePicke
|
||||
>
|
||||
{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">{p.label}</span>
|
||||
{p.hint && (
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
{p.id === "none" || p.id === "cron" ? p.hint : (
|
||||
<code className="font-mono">{p.hint}</code>
|
||||
)}
|
||||
</span>
|
||||
<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}
|
||||
/>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
|
||||
{activePreset === "cron" && (
|
||||
<CronInput value={value} onChange={onChange} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CronInput({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: RecurrenceSpec;
|
||||
onChange: (next: RecurrenceSpec) => void;
|
||||
}) {
|
||||
const cron = value.kind === "cron" ? value.cron ?? "" : "";
|
||||
// ---------------------------------------------------------------------------
|
||||
// Per-frequency config panels
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface FreqConfigProps {
|
||||
flow: FlowState;
|
||||
firstFire: DateTime;
|
||||
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>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
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 (
|
||||
<div className="space-y-2 border-t border-border bg-muted/20 p-4">
|
||||
<Label htmlFor="cron-expr" className="text-xs text-muted-foreground">
|
||||
<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="cron-expr"
|
||||
value={cron}
|
||||
onChange={(e) =>
|
||||
onChange({
|
||||
kind: "cron",
|
||||
interval: 1,
|
||||
weeklyDays: [],
|
||||
cron: e.target.value,
|
||||
end: { kind: "never" },
|
||||
})
|
||||
}
|
||||
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 leading-relaxed">
|
||||
Standard 5-field cron (<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 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>
|
||||
@ -151,6 +325,7 @@ function CronInput({
|
||||
<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>
|
||||
</div>,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,12 +2,15 @@ import { describe, it, expect } from "vitest";
|
||||
import { DateTime } from "luxon";
|
||||
import {
|
||||
buildRrule,
|
||||
defaultFlowState,
|
||||
describeRecurrence,
|
||||
flowFromCron,
|
||||
flowToCron,
|
||||
freqChoices,
|
||||
isoWeekdayToCron,
|
||||
kindFromRrule,
|
||||
matchPreset,
|
||||
presetDescriptors,
|
||||
presetToSpec,
|
||||
specFromRrule,
|
||||
type FlowState,
|
||||
type RecurrenceSpec,
|
||||
} from "./recurrence";
|
||||
|
||||
@ -163,105 +166,138 @@ describe("specFromRrule / kindFromRrule", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("preset shortcuts (cron-only Repeats picker)", () => {
|
||||
// FIRST is 2026-05-13 09:00 = Wednesday (ISO weekday 3, cron 3),
|
||||
// day 13, May.
|
||||
it("presetToSpec emits a cron-kind spec for every recurring preset", () => {
|
||||
expect(presetToSpec("none", FIRST)).toMatchObject({ kind: "none" });
|
||||
expect(presetToSpec("every_minute", FIRST)).toMatchObject({ kind: "cron", cron: "* * * * *" });
|
||||
expect(presetToSpec("every_5min", FIRST)).toMatchObject({ kind: "cron", cron: "*/5 * * * *" });
|
||||
expect(presetToSpec("every_15min", FIRST)).toMatchObject({ kind: "cron", cron: "*/15 * * * *" });
|
||||
expect(presetToSpec("every_30min", FIRST)).toMatchObject({ kind: "cron", cron: "*/30 * * * *" });
|
||||
expect(presetToSpec("every_hour", FIRST)).toMatchObject({ kind: "cron", cron: "0 * * * *" });
|
||||
expect(presetToSpec("every_day", FIRST)).toMatchObject({ kind: "cron", cron: "0 9 * * *" });
|
||||
expect(presetToSpec("every_weekday", FIRST)).toMatchObject({ kind: "cron", cron: "0 9 * * 1-5" });
|
||||
expect(presetToSpec("every_weekend", FIRST)).toMatchObject({ kind: "cron", cron: "0 9 * * 0,6" });
|
||||
// Wed = ISO 3 = cron 3.
|
||||
expect(presetToSpec("every_same_dow", FIRST)).toMatchObject({ kind: "cron", cron: "0 9 * * 3" });
|
||||
expect(presetToSpec("every_month_dom", FIRST)).toMatchObject({ kind: "cron", cron: "0 9 13 * *" });
|
||||
expect(presetToSpec("every_year", FIRST)).toMatchObject({ kind: "cron", cron: "0 9 13 5 *" });
|
||||
describe("cron flow — frequency choice + per-frequency config", () => {
|
||||
const baseFlow = (over: Partial<FlowState> = {}): FlowState => ({
|
||||
...defaultFlowState(FIRST),
|
||||
...over,
|
||||
});
|
||||
|
||||
it("Sunday first-fire maps to cron weekday 0", () => {
|
||||
const sunday = DateTime.fromISO("2026-05-17T09:00:00", { zone: "Asia/Kuala_Lumpur" });
|
||||
expect(presetToSpec("every_same_dow", sunday)).toMatchObject({
|
||||
kind: "cron",
|
||||
cron: "0 9 * * 0",
|
||||
});
|
||||
});
|
||||
|
||||
it("matchPreset round-trips through presetToSpec for every preset", () => {
|
||||
const ids = [
|
||||
it("freqChoices lists exactly the 8 top-level options in order", () => {
|
||||
expect(freqChoices(FIRST).map((c) => c.id)).toEqual([
|
||||
"none",
|
||||
"every_minute",
|
||||
"every_5min",
|
||||
"every_15min",
|
||||
"every_30min",
|
||||
"every_hour",
|
||||
"every_day",
|
||||
"every_weekday",
|
||||
"every_weekend",
|
||||
"every_same_dow",
|
||||
"every_month_dom",
|
||||
"every_year",
|
||||
] as const;
|
||||
for (const id of ids) {
|
||||
const spec = presetToSpec(id, FIRST);
|
||||
expect(matchPreset(spec, FIRST)).toBe(id);
|
||||
"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", () => {
|
||||
expect(isoWeekdayToCron(1)).toBe(1); // Mon
|
||||
expect(isoWeekdayToCron(2)).toBe(2);
|
||||
expect(isoWeekdayToCron(6)).toBe(6); // Sat
|
||||
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("matchPreset returns 'cron' for an arbitrary cron string", () => {
|
||||
expect(
|
||||
matchPreset(
|
||||
{ kind: "cron", interval: 1, weeklyDays: [], cron: "*/7 * * * *", end: { kind: "never" } },
|
||||
FIRST,
|
||||
),
|
||||
).toBe("cron");
|
||||
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("presetDescriptors lists exactly the cron-only items in order", () => {
|
||||
const items = presetDescriptors(FIRST);
|
||||
expect(items.map((d) => d.id)).toEqual([
|
||||
"none",
|
||||
"every_minute",
|
||||
"every_5min",
|
||||
"every_15min",
|
||||
"every_30min",
|
||||
"every_hour",
|
||||
"every_day",
|
||||
"every_weekday",
|
||||
"every_weekend",
|
||||
"every_same_dow",
|
||||
"every_month_dom",
|
||||
"every_year",
|
||||
"cron",
|
||||
]);
|
||||
// First-fire-aware labels carry the chosen time.
|
||||
expect(items.find((d) => d.id === "every_day")?.label).toBe("Every day at 09:00");
|
||||
expect(items.find((d) => d.id === "every_weekday")?.label).toBe(
|
||||
"Every weekday at 09:00",
|
||||
);
|
||||
expect(items.find((d) => d.id === "every_same_dow")?.label).toBe(
|
||||
"Every Wed at 09:00",
|
||||
);
|
||||
expect(items.find((d) => d.id === "every_year")?.label).toBe(
|
||||
"Every year on May 13 at 09:00",
|
||||
);
|
||||
expect(items.find((d) => d.id === "cron")?.label).toBe(
|
||||
"Custom cron expression…",
|
||||
);
|
||||
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 produces a CRON: prefixed string for every cron preset", () => {
|
||||
expect(
|
||||
buildRrule(presetToSpec("every_weekday", FIRST), FIRST),
|
||||
).toBe("CRON:0 9 * * 1-5");
|
||||
expect(buildRrule(presetToSpec("every_5min", FIRST), FIRST)).toBe("CRON:*/5 * * * *");
|
||||
expect(buildRrule(presetToSpec("none", FIRST), FIRST)).toBe(null);
|
||||
});
|
||||
|
||||
it("specFromRrule round-trips a CRON: prefixed rule", () => {
|
||||
it("buildRrule + specFromRrule still round-trip CRON: rules", () => {
|
||||
const spec: RecurrenceSpec = {
|
||||
kind: "cron",
|
||||
interval: 1,
|
||||
weeklyDays: [],
|
||||
cron: "*/15 * * * *",
|
||||
end: { kind: "never" },
|
||||
};
|
||||
expect(buildRrule(spec, FIRST)).toBe("CRON:*/15 * * * *");
|
||||
expect(specFromRrule("CRON:*/15 * * * *")).toMatchObject({
|
||||
kind: "cron",
|
||||
cron: "*/15 * * * *",
|
||||
|
||||
@ -216,166 +216,167 @@ export function kindFromRrule(rrule: string | null | undefined): RecurrenceKind
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Preset shortcuts for the Repeats picker — every shortcut is a cron
|
||||
// expression. The picker is cron-only: schedules are stored in the
|
||||
// `reminders.rrule` column with the `CRON:` sentinel, and the bot's
|
||||
// shared `nextOccurrence` helper dispatches them to cron-parser.
|
||||
// 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 PresetId =
|
||||
export type FreqChoice =
|
||||
| "none"
|
||||
| "every_minute"
|
||||
| "every_5min"
|
||||
| "every_15min"
|
||||
| "every_30min"
|
||||
| "every_hour"
|
||||
| "every_day"
|
||||
| "every_weekday"
|
||||
| "every_weekend"
|
||||
| "every_same_dow"
|
||||
| "every_month_dom"
|
||||
| "every_year"
|
||||
| "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). */
|
||||
function isoWeekdayToCron(iso: number): number {
|
||||
export function isoWeekdayToCron(iso: number): number {
|
||||
return iso === 7 ? 0 : iso;
|
||||
}
|
||||
|
||||
/** Build the canonical cron string for a preset given the user's first-fire DateTime. */
|
||||
function presetCron(id: Exclude<PresetId, "none" | "cron">, firstFire: DateTime): string {
|
||||
/** 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 day = firstFire.day;
|
||||
const month = firstFire.month;
|
||||
const cronDow = isoWeekdayToCron(firstFire.weekday);
|
||||
switch (id) {
|
||||
case "every_minute":
|
||||
return "* * * * *";
|
||||
case "every_5min":
|
||||
return "*/5 * * * *";
|
||||
case "every_15min":
|
||||
return "*/15 * * * *";
|
||||
case "every_30min":
|
||||
return "*/30 * * * *";
|
||||
case "every_hour":
|
||||
return `${m} * * * *`;
|
||||
case "every_day":
|
||||
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 "every_weekday":
|
||||
return `${m} ${h} * * 1-5`;
|
||||
case "every_weekend":
|
||||
return `${m} ${h} * * 0,6`;
|
||||
case "every_same_dow":
|
||||
return `${m} ${h} * * ${cronDow}`;
|
||||
case "every_month_dom":
|
||||
return `${m} ${h} ${day} * *`;
|
||||
case "every_year":
|
||||
return `${m} ${h} ${day} ${month} *`;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
export interface PresetDescriptor {
|
||||
id: PresetId;
|
||||
/** Short label shown in the radio list. */
|
||||
/**
|
||||
* 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;
|
||||
/** Optional one-line hint shown beneath the label. */
|
||||
hint?: string;
|
||||
/** The cron expression this preset resolves to (omitted for "none"). */
|
||||
cron?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the canonical RecurrenceSpec for a preset given the first-fire
|
||||
* DateTime. The picker is cron-only — every recurring preset emits a
|
||||
* `{ kind: "cron", cron: "..." }` spec; "none" is one-off; "cron" is
|
||||
* the free-form custom expression (caller seeds the input separately).
|
||||
*/
|
||||
export function presetToSpec(id: PresetId, firstFire: DateTime): RecurrenceSpec {
|
||||
const base: RecurrenceSpec = {
|
||||
kind: "none",
|
||||
interval: 1,
|
||||
weeklyDays: [],
|
||||
end: { kind: "never" },
|
||||
};
|
||||
if (id === "none") return base;
|
||||
if (id === "cron") {
|
||||
// Default seed for the custom textbox — every day at the first
|
||||
// fire's HH:MM. The user is free to overwrite.
|
||||
return { ...base, kind: "cron", cron: `${firstFire.minute} ${firstFire.hour} * * *` };
|
||||
}
|
||||
return { ...base, kind: "cron", cron: presetCron(id, firstFire) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse mapping: which preset (if any) does this spec match?
|
||||
*
|
||||
* For a cron spec, compare the cron expression against each preset's
|
||||
* canonical cron (built from `firstFire`). Anything that doesn't match
|
||||
* a shortcut falls back to "cron" (the custom textbox shows the literal
|
||||
* expression). Legacy RRULE specs (kinds daily/weekly/monthly/yearly)
|
||||
* are not picker-presets — return "cron" so the picker reads as
|
||||
* "schedule set externally; pick a cron to update".
|
||||
*/
|
||||
export function matchPreset(spec: RecurrenceSpec, firstFire: DateTime): PresetId {
|
||||
if (spec.kind === "none") return "none";
|
||||
if (spec.kind !== "cron") return "cron";
|
||||
|
||||
const expr = (spec.cron ?? "").trim();
|
||||
if (!expr) return "cron";
|
||||
|
||||
const ids: Array<Exclude<PresetId, "none" | "cron">> = [
|
||||
"every_minute",
|
||||
"every_5min",
|
||||
"every_15min",
|
||||
"every_30min",
|
||||
"every_hour",
|
||||
"every_day",
|
||||
"every_weekday",
|
||||
"every_weekend",
|
||||
"every_same_dow",
|
||||
"every_month_dom",
|
||||
"every_year",
|
||||
];
|
||||
for (const id of ids) {
|
||||
if (presetCron(id, firstFire) === expr) return id;
|
||||
}
|
||||
return "cron";
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the cron-flavoured radio list. Every recurring preset shows
|
||||
* its underlying cron expression as the hint so the user can see what
|
||||
* each shortcut compiles to. The labels are first-fire-aware — picking
|
||||
* a different time updates "Every day at HH:MM" in place.
|
||||
*/
|
||||
export function presetDescriptors(firstFire: DateTime): PresetDescriptor[] {
|
||||
/** First-fire-aware list of the 8 top-level frequency choices. */
|
||||
export function freqChoices(firstFire: DateTime): FreqChoiceDescriptor[] {
|
||||
const t = firstFire.toFormat("HH:mm");
|
||||
const dayShort = WEEKDAY_LABELS[firstFire.weekday - 1]?.short ?? "";
|
||||
const monShort = firstFire.toFormat("MMM d");
|
||||
const dom = firstFire.day;
|
||||
|
||||
const item = (
|
||||
id: Exclude<PresetId, "none" | "cron">,
|
||||
label: string,
|
||||
): PresetDescriptor => ({ id, label, hint: presetCron(id, firstFire), cron: presetCron(id, firstFire) });
|
||||
|
||||
return [
|
||||
{ id: "none", label: "Don't repeat", hint: "Fires once and ends" },
|
||||
item("every_minute", "Every minute"),
|
||||
item("every_5min", "Every 5 minutes"),
|
||||
item("every_15min", "Every 15 minutes"),
|
||||
item("every_30min", "Every 30 minutes"),
|
||||
item("every_hour", `Every hour at :${firstFire.toFormat("mm")}`),
|
||||
item("every_day", `Every day at ${t}`),
|
||||
item("every_weekday", `Every weekday at ${t}`),
|
||||
item("every_weekend", `Every weekend at ${t}`),
|
||||
item("every_same_dow", `Every ${dayShort} at ${t}`),
|
||||
item("every_month_dom", `Every month on day ${dom} at ${t}`),
|
||||
item("every_year", `Every year on ${monShort} at ${t}`),
|
||||
{
|
||||
id: "cron",
|
||||
label: "Custom cron expression…",
|
||||
hint: "Write your own — full sec/min/hour/day/month/dow combinational power",
|
||||
},
|
||||
{ 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