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";
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
import { DateTime } from "luxon";
|
import { DateTime } from "luxon";
|
||||||
import { CheckIcon, RepeatIcon } from "lucide-react";
|
import { CheckIcon, RepeatIcon } from "lucide-react";
|
||||||
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 { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import {
|
import {
|
||||||
matchPreset,
|
WEEKDAY_LABELS,
|
||||||
presetDescriptors,
|
defaultFlowState,
|
||||||
presetToSpec,
|
flowFromCron,
|
||||||
type PresetId,
|
flowToCron,
|
||||||
|
freqChoices,
|
||||||
|
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 preset labels and the cron strings
|
/** First fire of the reminder — drives the HH:MM in the cron output and the
|
||||||
* for "Every day at HH:MM" / "Every weekday at HH:MM" / etc. */
|
* default day-of-month / month / weekday for the per-frequency configurators. */
|
||||||
firstFire: DateTime;
|
firstFire: DateTime;
|
||||||
value: RecurrenceSpec;
|
value: RecurrenceSpec;
|
||||||
onChange: (next: RecurrenceSpec) => void;
|
onChange: (next: RecurrenceSpec) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cron-only Repeats picker.
|
/**
|
||||||
//
|
* Guided cron flow.
|
||||||
// ┌─────────────────────────────────────────────────────┐
|
*
|
||||||
// │ ○ Don't repeat Fires once and ends │
|
* Step 1 — pick a frequency in the radio list (the chosen card stays
|
||||||
// │ ○ Every minute every minute │
|
* highlighted; only its config panel below it expands).
|
||||||
// │ ○ Every 5/15/30 minutes every N minutes │
|
* Step 2 — fill in the per-frequency inputs (a number, weekday chips,
|
||||||
// │ ○ Every hour at :00 │
|
* a day picker, etc.). Every change recompiles to a cron expression
|
||||||
// │ ○ Every day at HH:MM │
|
* and pushes a `{ kind: "cron", cron: "..." }` spec up to the parent.
|
||||||
// │ ○ Every weekday/weekend at HH:MM │
|
*
|
||||||
// │ ○ Every <weekday> at HH:MM │
|
* "Don't repeat" is a one-click exit (no config). "Custom cron…" lets
|
||||||
// │ ○ Every month on day <D> at HH:MM │
|
* a power-user type any expression directly.
|
||||||
// │ ○ 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.
|
|
||||||
export function RecurrencePicker({ firstFire, value, onChange }: RecurrencePickerProps) {
|
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) {
|
// Re-derive the cron when either the flow or the first-fire changes
|
||||||
if (id === "cron" && value.kind === "cron") {
|
// (changing the time picker outside should refresh "at HH:MM").
|
||||||
// Already in custom mode — preserve whatever the user has typed.
|
useEffect(() => {
|
||||||
|
const cron = flowToCron(flow, firstFire);
|
||||||
|
if (!cron) {
|
||||||
|
if (value.kind !== "none") {
|
||||||
|
onChange({ kind: "none", interval: 1, weeklyDays: [], end: { kind: "never" } });
|
||||||
|
}
|
||||||
return;
|
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 (
|
return (
|
||||||
<div className="space-y-2">
|
<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">
|
<div className="overflow-hidden rounded-xl border border-border bg-card">
|
||||||
<ul className="divide-y divide-border" role="radiogroup" aria-label="Repeat schedule">
|
<ul className="divide-y divide-border" role="radiogroup" aria-label="Repeat schedule">
|
||||||
{presetDescriptors(firstFire).map((p) => {
|
{freqChoices(firstFire).map((c) => {
|
||||||
const selected = activePreset === p.id;
|
const selected = flow.freq === c.id;
|
||||||
return (
|
return (
|
||||||
<li key={p.id}>
|
<li key={c.id}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
role="radio"
|
role="radio"
|
||||||
aria-checked={selected}
|
aria-checked={selected}
|
||||||
onClick={() => pickPreset(p.id)}
|
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 w-full items-center gap-3 px-4 py-3 text-left transition-colors",
|
||||||
selected
|
selected ? "bg-primary/5 text-foreground" : "hover:bg-muted text-foreground",
|
||||||
? "bg-primary/5 text-foreground"
|
|
||||||
: "hover:bg-muted text-foreground",
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
@ -86,64 +105,219 @@ export function RecurrencePicker({ firstFire, value, onChange }: RecurrencePicke
|
|||||||
>
|
>
|
||||||
{selected && <CheckIcon className="size-2.5" strokeWidth={3.5} />}
|
{selected && <CheckIcon className="size-2.5" strokeWidth={3.5} />}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<span className="min-w-0 flex-1">
|
<span className="min-w-0 flex-1">
|
||||||
<span className="block text-sm font-medium leading-snug">{p.label}</span>
|
<span className="block text-sm font-medium leading-snug">{c.label}</span>
|
||||||
{p.hint && (
|
{c.hint && (
|
||||||
<span className="block text-xs text-muted-foreground">
|
<span className="block text-xs text-muted-foreground">{c.hint}</span>
|
||||||
{p.id === "none" || p.id === "cron" ? p.hint : (
|
|
||||||
<code className="font-mono">{p.hint}</code>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{selected && (
|
||||||
|
<FreqConfig
|
||||||
|
flow={flow}
|
||||||
|
firstFire={firstFire}
|
||||||
|
update={update}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
{activePreset === "cron" && (
|
|
||||||
<CronInput value={value} onChange={onChange} />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CronInput({
|
// ---------------------------------------------------------------------------
|
||||||
value,
|
// Per-frequency config panels
|
||||||
onChange,
|
// ---------------------------------------------------------------------------
|
||||||
}: {
|
|
||||||
value: RecurrenceSpec;
|
interface FreqConfigProps {
|
||||||
onChange: (next: RecurrenceSpec) => void;
|
flow: FlowState;
|
||||||
}) {
|
firstFire: DateTime;
|
||||||
const cron = value.kind === "cron" ? value.cron ?? "" : "";
|
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 (
|
return (
|
||||||
<div className="space-y-2 border-t border-border bg-muted/20 p-4">
|
<button
|
||||||
<Label htmlFor="cron-expr" className="text-xs text-muted-foreground">
|
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
|
Cron expression
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="cron-expr"
|
id="rp-cron"
|
||||||
value={cron}
|
value={flow.customCron}
|
||||||
onChange={(e) =>
|
onChange={(e) => update("customCron", e.target.value)}
|
||||||
onChange({
|
|
||||||
kind: "cron",
|
|
||||||
interval: 1,
|
|
||||||
weeklyDays: [],
|
|
||||||
cron: e.target.value,
|
|
||||||
end: { kind: "never" },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
placeholder="0 9 * * 1-5"
|
placeholder="0 9 * * 1-5"
|
||||||
className="h-8 font-mono text-sm"
|
className="h-8 font-mono text-sm"
|
||||||
spellCheck={false}
|
spellCheck={false}
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
<p className="text-xs text-muted-foreground">
|
||||||
Standard 5-field cron (<code className="font-mono">m h dom mon dow</code>) or
|
5-field (<code className="font-mono">m h dom mon dow</code>) or 6-field
|
||||||
6-field with seconds (<code className="font-mono">s m h dom mon dow</code>).
|
with seconds (<code className="font-mono">s m h dom mon dow</code>). Examples:
|
||||||
Examples:
|
|
||||||
</p>
|
</p>
|
||||||
<ul className="text-xs text-muted-foreground font-mono space-y-0.5 pl-3">
|
<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">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 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>
|
<li><span className="text-foreground">0 0 1 * *</span> — midnight on the 1st of every month</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>,
|
||||||
);
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,12 +2,15 @@ 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,
|
||||||
kindFromRrule,
|
kindFromRrule,
|
||||||
matchPreset,
|
|
||||||
presetDescriptors,
|
|
||||||
presetToSpec,
|
|
||||||
specFromRrule,
|
specFromRrule,
|
||||||
|
type FlowState,
|
||||||
type RecurrenceSpec,
|
type RecurrenceSpec,
|
||||||
} from "./recurrence";
|
} from "./recurrence";
|
||||||
|
|
||||||
@ -163,105 +166,138 @@ describe("specFromRrule / kindFromRrule", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("preset shortcuts (cron-only Repeats picker)", () => {
|
describe("cron flow — frequency choice + per-frequency config", () => {
|
||||||
// FIRST is 2026-05-13 09:00 = Wednesday (ISO weekday 3, cron 3),
|
const baseFlow = (over: Partial<FlowState> = {}): FlowState => ({
|
||||||
// day 13, May.
|
...defaultFlowState(FIRST),
|
||||||
it("presetToSpec emits a cron-kind spec for every recurring preset", () => {
|
...over,
|
||||||
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 *" });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("Sunday first-fire maps to cron weekday 0", () => {
|
it("freqChoices lists exactly the 8 top-level options in order", () => {
|
||||||
const sunday = DateTime.fromISO("2026-05-17T09:00:00", { zone: "Asia/Kuala_Lumpur" });
|
expect(freqChoices(FIRST).map((c) => c.id)).toEqual([
|
||||||
expect(presetToSpec("every_same_dow", sunday)).toMatchObject({
|
|
||||||
kind: "cron",
|
|
||||||
cron: "0 9 * * 0",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("matchPreset round-trips through presetToSpec for every preset", () => {
|
|
||||||
const ids = [
|
|
||||||
"none",
|
"none",
|
||||||
"every_minute",
|
"minute",
|
||||||
"every_5min",
|
"hour",
|
||||||
"every_15min",
|
"day",
|
||||||
"every_30min",
|
"week",
|
||||||
"every_hour",
|
"month",
|
||||||
"every_day",
|
"year",
|
||||||
"every_weekday",
|
"cron",
|
||||||
"every_weekend",
|
]);
|
||||||
"every_same_dow",
|
// Time-bearing labels use the first-fire's HH:MM (09:00).
|
||||||
"every_month_dom",
|
const lookup = (id: string) => freqChoices(FIRST).find((c) => c.id === id);
|
||||||
"every_year",
|
expect(lookup("day")?.label).toBe("Every day at 09:00");
|
||||||
] as const;
|
expect(lookup("week")?.label).toBe("Every week at 09:00");
|
||||||
for (const id of ids) {
|
expect(lookup("month")?.label).toBe("Every month at 09:00");
|
||||||
const spec = presetToSpec(id, FIRST);
|
expect(lookup("year")?.label).toBe("Every year at 09:00");
|
||||||
expect(matchPreset(spec, FIRST)).toBe(id);
|
});
|
||||||
|
|
||||||
|
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", () => {
|
it("flowFromCron parses BYDAY ranges (1-5) into expanded weekday list", () => {
|
||||||
expect(
|
expect(flowFromCron("CRON:0 9 * * 1-5", FIRST)).toMatchObject({
|
||||||
matchPreset(
|
freq: "week",
|
||||||
{ kind: "cron", interval: 1, weeklyDays: [], cron: "*/7 * * * *", end: { kind: "never" } },
|
weekdays: [1, 2, 3, 4, 5],
|
||||||
FIRST,
|
});
|
||||||
),
|
|
||||||
).toBe("cron");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("presetDescriptors lists exactly the cron-only items in order", () => {
|
it("flowFromCron drops unrecognised expressions into the cron textbox", () => {
|
||||||
const items = presetDescriptors(FIRST);
|
expect(flowFromCron("CRON:30 0,12 * * *", FIRST)).toMatchObject({
|
||||||
expect(items.map((d) => d.id)).toEqual([
|
freq: "cron",
|
||||||
"none",
|
customCron: "30 0,12 * * *",
|
||||||
"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("buildRrule produces a CRON: prefixed string for every cron preset", () => {
|
it("buildRrule + specFromRrule still round-trip CRON: rules", () => {
|
||||||
expect(
|
const spec: RecurrenceSpec = {
|
||||||
buildRrule(presetToSpec("every_weekday", FIRST), FIRST),
|
kind: "cron",
|
||||||
).toBe("CRON:0 9 * * 1-5");
|
interval: 1,
|
||||||
expect(buildRrule(presetToSpec("every_5min", FIRST), FIRST)).toBe("CRON:*/5 * * * *");
|
weeklyDays: [],
|
||||||
expect(buildRrule(presetToSpec("none", FIRST), FIRST)).toBe(null);
|
cron: "*/15 * * * *",
|
||||||
});
|
end: { kind: "never" },
|
||||||
|
};
|
||||||
it("specFromRrule round-trips a CRON: prefixed rule", () => {
|
expect(buildRrule(spec, FIRST)).toBe("CRON:*/15 * * * *");
|
||||||
expect(specFromRrule("CRON:*/15 * * * *")).toMatchObject({
|
expect(specFromRrule("CRON:*/15 * * * *")).toMatchObject({
|
||||||
kind: "cron",
|
kind: "cron",
|
||||||
cron: "*/15 * * * *",
|
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
|
// Cron flow — pick a frequency, then configure it. Every selection compiles
|
||||||
// expression. The picker is cron-only: schedules are stored in the
|
// down to a single cron expression that lives in `reminders.rrule` with the
|
||||||
// `reminders.rrule` column with the `CRON:` sentinel, and the bot's
|
// `CRON:` sentinel. The bot's shared `nextOccurrence` dispatches cron rules
|
||||||
// shared `nextOccurrence` helper dispatches them to cron-parser.
|
// through cron-parser.
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
export type PresetId =
|
export type FreqChoice =
|
||||||
| "none"
|
| "none"
|
||||||
| "every_minute"
|
| "minute"
|
||||||
| "every_5min"
|
| "hour"
|
||||||
| "every_15min"
|
| "day"
|
||||||
| "every_30min"
|
| "week"
|
||||||
| "every_hour"
|
| "month"
|
||||||
| "every_day"
|
| "year"
|
||||||
| "every_weekday"
|
|
||||||
| "every_weekend"
|
|
||||||
| "every_same_dow"
|
|
||||||
| "every_month_dom"
|
|
||||||
| "every_year"
|
|
||||||
| "cron";
|
| "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). */
|
||||||
function isoWeekdayToCron(iso: number): number {
|
export function isoWeekdayToCron(iso: number): number {
|
||||||
return iso === 7 ? 0 : iso;
|
return iso === 7 ? 0 : iso;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Build the canonical cron string for a preset given the user's first-fire DateTime. */
|
/** Sensible default flow state seeded from the first-fire DateTime. */
|
||||||
function presetCron(id: Exclude<PresetId, "none" | "cron">, firstFire: DateTime): string {
|
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 m = firstFire.minute;
|
||||||
const h = firstFire.hour;
|
const h = firstFire.hour;
|
||||||
const day = firstFire.day;
|
const clamp = (n: number, lo: number, hi: number) =>
|
||||||
const month = firstFire.month;
|
Number.isFinite(n) ? Math.min(Math.max(Math.floor(n), lo), hi) : lo;
|
||||||
const cronDow = isoWeekdayToCron(firstFire.weekday);
|
|
||||||
switch (id) {
|
switch (s.freq) {
|
||||||
case "every_minute":
|
case "none":
|
||||||
return "* * * * *";
|
return null;
|
||||||
case "every_5min":
|
case "minute": {
|
||||||
return "*/5 * * * *";
|
const n = clamp(s.minuteInterval, 1, 59);
|
||||||
case "every_15min":
|
return n === 1 ? "* * * * *" : `*/${n} * * * *`;
|
||||||
return "*/15 * * * *";
|
}
|
||||||
case "every_30min":
|
case "hour": {
|
||||||
return "*/30 * * * *";
|
const n = clamp(s.hourInterval, 1, 23);
|
||||||
case "every_hour":
|
return n === 1 ? `${m} * * * *` : `${m} */${n} * * *`;
|
||||||
return `${m} * * * *`;
|
}
|
||||||
case "every_day":
|
case "day":
|
||||||
return `${m} ${h} * * *`;
|
return `${m} ${h} * * *`;
|
||||||
case "every_weekday":
|
case "week": {
|
||||||
return `${m} ${h} * * 1-5`;
|
if (!s.weekdays.length) return null;
|
||||||
case "every_weekend":
|
const dow = s.weekdays.slice().sort((a, b) => a - b).join(",");
|
||||||
return `${m} ${h} * * 0,6`;
|
return `${m} ${h} * * ${dow}`;
|
||||||
case "every_same_dow":
|
}
|
||||||
return `${m} ${h} * * ${cronDow}`;
|
case "month": {
|
||||||
case "every_month_dom":
|
const d = clamp(s.monthDay, 1, 31);
|
||||||
return `${m} ${h} ${day} * *`;
|
return `${m} ${h} ${d} * *`;
|
||||||
case "every_year":
|
}
|
||||||
return `${m} ${h} ${day} ${month} *`;
|
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;
|
* Best-effort reverse: read a stored cron expression back into a flow
|
||||||
/** Short label shown in the radio list. */
|
* 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;
|
label: string;
|
||||||
/** Optional one-line hint shown beneath the label. */
|
|
||||||
hint?: string;
|
hint?: string;
|
||||||
/** The cron expression this preset resolves to (omitted for "none"). */
|
|
||||||
cron?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** First-fire-aware list of the 8 top-level frequency choices. */
|
||||||
* Build the canonical RecurrenceSpec for a preset given the first-fire
|
export function freqChoices(firstFire: DateTime): FreqChoiceDescriptor[] {
|
||||||
* 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[] {
|
|
||||||
const t = firstFire.toFormat("HH:mm");
|
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 [
|
return [
|
||||||
{ id: "none", label: "Don't repeat", hint: "Fires once and ends" },
|
{ id: "none", label: "Don't repeat", hint: "Fires once and ends" },
|
||||||
item("every_minute", "Every minute"),
|
{ id: "minute", label: "Every N minutes", hint: "Sub-hour cadence" },
|
||||||
item("every_5min", "Every 5 minutes"),
|
{ id: "hour", label: "Every N hours", hint: `At minute :${firstFire.toFormat("mm")}` },
|
||||||
item("every_15min", "Every 15 minutes"),
|
{ id: "day", label: `Every day at ${t}` },
|
||||||
item("every_30min", "Every 30 minutes"),
|
{ id: "week", label: `Every week at ${t}`, hint: "Choose which weekdays" },
|
||||||
item("every_hour", `Every hour at :${firstFire.toFormat("mm")}`),
|
{ id: "month", label: `Every month at ${t}`, hint: "Choose which day of the month" },
|
||||||
item("every_day", `Every day at ${t}`),
|
{ id: "year", label: `Every year at ${t}`, hint: "Choose which month and day" },
|
||||||
item("every_weekday", `Every weekday at ${t}`),
|
{ id: "cron", label: "Custom cron expression…", hint: "Power-user — full combinational control" },
|
||||||
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",
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user