feat(recurrence): cron-only Repeats picker
Per the user's ask: drop the friendly RRULE-based shortcuts (Daily /
Weekly / Custom… etc.) — every selectable preset is now a cron
expression. Schedules are stored in `reminders.rrule` with the
`CRON:` sentinel and dispatched via the existing cron-aware
`nextOccurrence` helper.
Picker
- "Don't repeat" stays at the top (one-off, no cron).
- 11 cron-flavoured presets, each with its underlying cron expression
shown as the hint:
Every minute * * * * *
Every 5 minutes */5 * * * *
Every 15 minutes */15 * * * *
Every 30 minutes */30 * * * *
Every hour at :MM MM * * * *
Every day at HH:MM MM HH * * *
Every weekday at HH:MM MM HH * * 1-5
Every weekend at HH:MM MM HH * * 0,6
Every <DOW> at HH:MM MM HH * * <cron-dow>
Every month on day D at HH:MM MM HH D * *
Every year on Mon D at HH:MM MM HH D M *
- Labels are first-fire-aware: changing the time picker re-derives
every "at HH:MM" label and the preset's canonical cron string.
- "Custom cron expression…" reveals a free-form text input for
anything not covered by the presets.
- Removed: the old "Custom" RRULE detail panel (frequency dropdown,
weekday picker, monthday input, end-condition picker).
Storage
- `presetToSpec("none")` → kind:"none". Every other preset →
kind:"cron" with its canonical cron string.
- `matchPreset` compares the spec's cron expression against each
preset's canonical cron for the current first-fire — falls back to
"cron" (custom textbox) for anything else, including legacy RRULE
reminders that haven't been re-saved yet. Existing RRULE reminders
keep firing on the bot side (nextOccurrence still dispatches both).
- `presetCron(id, firstFire)` is a small pure helper; ISO weekday
(1=Mon..7=Sun) maps to cron weekday (0=Sun..6=Sat).
Tests (+8 in recurrence.test.ts; 137 web + 26 bot + 17 shared = 180)
- presetToSpec emits the right cron for every recurring preset
including Sunday → cron weekday 0.
- matchPreset round-trips through presetToSpec for every preset.
- matchPreset returns "cron" for arbitrary (non-preset) cron strings.
- presetDescriptors lists exactly the cron-only items in order with
first-fire-aware labels ("Every weekday at 09:00", "Every Wed at
09:00", "Every year on May 13 at 09:00", "Custom cron expression…").
- buildRrule produces CRON: prefixed strings for cron presets and
null for "none".
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
5f1897daa5
commit
63b88c69b4
@ -1,82 +1,50 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { DateTime } from "luxon";
|
||||
import {
|
||||
CheckIcon,
|
||||
ChevronDownIcon,
|
||||
ChevronUpIcon,
|
||||
RepeatIcon,
|
||||
} from "lucide-react";
|
||||
import { CheckIcon, RepeatIcon } from "lucide-react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
WEEKDAY_LABELS,
|
||||
matchPreset,
|
||||
presetDescriptors,
|
||||
presetToSpec,
|
||||
type EndKind,
|
||||
type PresetId,
|
||||
type RecurrenceKind,
|
||||
type RecurrenceSpec,
|
||||
} from "@/lib/recurrence";
|
||||
|
||||
interface RecurrencePickerProps {
|
||||
/** First fire of the reminder — drives preset labels (e.g. "Every week on Wed"). */
|
||||
/** First fire of the reminder — drives preset labels and the cron strings
|
||||
* for "Every day at HH:MM" / "Every weekday at HH:MM" / etc. */
|
||||
firstFire: DateTime;
|
||||
value: RecurrenceSpec;
|
||||
onChange: (next: RecurrenceSpec) => void;
|
||||
}
|
||||
|
||||
const FREQ_UNIT: Record<Exclude<RecurrenceKind, "none">, string> = {
|
||||
daily: "day",
|
||||
weekly: "week",
|
||||
monthly: "month",
|
||||
yearly: "year",
|
||||
};
|
||||
|
||||
/**
|
||||
* Reminder repeat picker.
|
||||
*
|
||||
* ┌──────────────────────────────────────────────────────┐
|
||||
* │ ○ Don't repeat (one-off) │
|
||||
* │ ○ Every day │
|
||||
* │ ○ Every weekday Mon – Fri │
|
||||
* │ ○ Every weekend Sat – Sun │
|
||||
* │ ○ Every week on Wed Same weekday as start │
|
||||
* │ ○ Every month on day 13 … │
|
||||
* │ ○ Every year on May 13 │
|
||||
* │ ○ Custom… (expands the full power-user controls) │
|
||||
* └──────────────────────────────────────────────────────┘
|
||||
*
|
||||
* Selecting a preset overwrites the spec to the canonical preset value.
|
||||
* "Custom…" reveals interval / weekday picker / day-of-month / end
|
||||
* controls so the user can build any RRULE we support.
|
||||
*/
|
||||
// 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.
|
||||
export function RecurrencePicker({ firstFire, value, onChange }: RecurrencePickerProps) {
|
||||
const activePreset = matchPreset(value, firstFire);
|
||||
// The user can also expand the custom panel manually even when the
|
||||
// current spec matches a preset — useful for "I want every 2 weeks
|
||||
// on Mon, Wed". Default to expanded whenever the spec is custom.
|
||||
const [forceCustomOpen, setForceCustomOpen] = useState(false);
|
||||
const customExpanded = activePreset === "custom" || forceCustomOpen;
|
||||
|
||||
function pickPreset(id: PresetId) {
|
||||
if (id === "custom") {
|
||||
setForceCustomOpen(true);
|
||||
// If the spec is currently a preset, seed the custom panel with
|
||||
// its canonical form so editing starts from a sensible baseline.
|
||||
if (activePreset !== "custom") {
|
||||
onChange(presetToSpec("weekly_same", firstFire));
|
||||
}
|
||||
return;
|
||||
}
|
||||
setForceCustomOpen(false);
|
||||
if (id === "cron") {
|
||||
// Preserve any cron expression the user already typed.
|
||||
if (value.kind === "cron") return;
|
||||
onChange(presetToSpec("cron", firstFire));
|
||||
if (id === "cron" && value.kind === "cron") {
|
||||
// Already in custom mode — preserve whatever the user has typed.
|
||||
return;
|
||||
}
|
||||
onChange(presetToSpec(id, firstFire));
|
||||
@ -107,7 +75,6 @@ export function RecurrencePicker({ firstFire, value, onChange }: RecurrencePicke
|
||||
: "hover:bg-muted text-foreground",
|
||||
)}
|
||||
>
|
||||
{/* Radio dot */}
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
@ -123,44 +90,35 @@ export function RecurrencePicker({ firstFire, value, onChange }: RecurrencePicke
|
||||
<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.hint}</span>
|
||||
)}
|
||||
</span>
|
||||
|
||||
{p.id === "custom" && (
|
||||
<span className="text-muted-foreground">
|
||||
{customExpanded ? (
|
||||
<ChevronUpIcon className="size-4" />
|
||||
) : (
|
||||
<ChevronDownIcon className="size-4" />
|
||||
<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>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
|
||||
{customExpanded && (
|
||||
<CustomPanel firstFire={firstFire} value={value} onChange={onChange} />
|
||||
)}
|
||||
|
||||
{activePreset === "cron" && (
|
||||
<CronPanel value={value} onChange={onChange} />
|
||||
<CronInput value={value} onChange={onChange} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CronPanel({
|
||||
function CronInput({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: RecurrenceSpec;
|
||||
onChange: (next: RecurrenceSpec) => void;
|
||||
}) {
|
||||
const cron = value.kind === "cron" ? value.cron ?? "" : "";
|
||||
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">
|
||||
@ -168,8 +126,16 @@ function CronPanel({
|
||||
</Label>
|
||||
<Input
|
||||
id="cron-expr"
|
||||
value={value.cron ?? ""}
|
||||
onChange={(e) => onChange({ ...value, cron: e.target.value })}
|
||||
value={cron}
|
||||
onChange={(e) =>
|
||||
onChange({
|
||||
kind: "cron",
|
||||
interval: 1,
|
||||
weeklyDays: [],
|
||||
cron: e.target.value,
|
||||
end: { kind: "never" },
|
||||
})
|
||||
}
|
||||
placeholder="0 9 * * 1-5"
|
||||
className="h-8 font-mono text-sm"
|
||||
spellCheck={false}
|
||||
@ -182,235 +148,9 @@ function CronPanel({
|
||||
<ul className="text-xs text-muted-foreground font-mono space-y-0.5 pl-3">
|
||||
<li><span className="text-foreground">0 9 * * 1-5</span> — 9 am on weekdays</li>
|
||||
<li><span className="text-foreground">*/15 * * * *</span> — every 15 minutes</li>
|
||||
<li><span className="text-foreground">0 9,12,18 * * *</span> — 9, 12, and 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>
|
||||
</ul>
|
||||
<p className="text-xs text-muted-foreground italic">
|
||||
The Date+Time controls above are ignored when a cron expression is set;
|
||||
cron drives the schedule entirely. The first fire is the next time the
|
||||
cron expression matches after now.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface CustomPanelProps {
|
||||
firstFire: DateTime;
|
||||
value: RecurrenceSpec;
|
||||
onChange: (next: RecurrenceSpec) => void;
|
||||
}
|
||||
|
||||
function CustomPanel({ firstFire, value, onChange }: CustomPanelProps) {
|
||||
const kind = value.kind === "none" ? "weekly" : value.kind;
|
||||
|
||||
function setKind(k: RecurrenceKind) {
|
||||
if (k === "none") {
|
||||
onChange(presetToSpec("none", firstFire));
|
||||
return;
|
||||
}
|
||||
onChange({
|
||||
...value,
|
||||
kind: k,
|
||||
// Seed weeklyDays with the first-fire weekday when flipping into
|
||||
// weekly so the spec is concrete.
|
||||
weeklyDays:
|
||||
k === "weekly" && value.weeklyDays.length === 0
|
||||
? [firstFire.weekday]
|
||||
: value.weeklyDays,
|
||||
monthDay:
|
||||
k === "monthly" && value.monthDay === undefined
|
||||
? firstFire.day
|
||||
: value.monthDay,
|
||||
});
|
||||
}
|
||||
|
||||
function setInterval(n: number) {
|
||||
const safe = Number.isFinite(n) && n >= 1 ? Math.floor(n) : 1;
|
||||
onChange({ ...value, interval: safe });
|
||||
}
|
||||
|
||||
function toggleWeekday(iso: number) {
|
||||
const next = value.weeklyDays.includes(iso)
|
||||
? value.weeklyDays.filter((d) => d !== iso)
|
||||
: [...value.weeklyDays, iso].sort((a, b) => a - b);
|
||||
onChange({ ...value, weeklyDays: next });
|
||||
}
|
||||
|
||||
function setMonthDay(n: number | "") {
|
||||
if (n === "") {
|
||||
onChange({ ...value, monthDay: undefined });
|
||||
return;
|
||||
}
|
||||
if (n >= 1 && n <= 31) onChange({ ...value, monthDay: n });
|
||||
}
|
||||
|
||||
function setEndKind(k: EndKind) {
|
||||
if (k === "never") onChange({ ...value, end: { kind: "never" } });
|
||||
else if (k === "after")
|
||||
onChange({
|
||||
...value,
|
||||
end: { kind: "after", count: value.end.kind === "after" ? value.end.count : 10 },
|
||||
});
|
||||
else
|
||||
onChange({
|
||||
...value,
|
||||
end: { kind: "on", until: value.end.kind === "on" ? value.end.until : "" },
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4 border-t border-border bg-muted/20 p-4">
|
||||
{/* Frequency */}
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="custom-freq" className="text-xs text-muted-foreground">
|
||||
Frequency
|
||||
</Label>
|
||||
<select
|
||||
id="custom-freq"
|
||||
value={kind}
|
||||
onChange={(e) => setKind(e.target.value as RecurrenceKind)}
|
||||
className="h-8 w-full rounded-lg border border-input bg-background px-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
<option value="daily">Daily</option>
|
||||
<option value="weekly">Weekly</option>
|
||||
<option value="monthly">Monthly</option>
|
||||
<option value="yearly">Yearly</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Interval */}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Label htmlFor="custom-interval" className="text-sm">
|
||||
Every
|
||||
</Label>
|
||||
<Input
|
||||
id="custom-interval"
|
||||
type="number"
|
||||
min={1}
|
||||
max={999}
|
||||
value={value.interval}
|
||||
onChange={(e) => setInterval(Number(e.target.value))}
|
||||
className="h-8 w-20"
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{FREQ_UNIT[kind]}
|
||||
{value.interval === 1 ? "" : "s"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Weekly day picker */}
|
||||
{kind === "weekly" && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs text-muted-foreground">On these days</Label>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{WEEKDAY_LABELS.map(({ iso, short }) => {
|
||||
const active = value.weeklyDays.includes(iso);
|
||||
return (
|
||||
<button
|
||||
key={iso}
|
||||
type="button"
|
||||
onClick={() => toggleWeekday(iso)}
|
||||
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>
|
||||
)}
|
||||
|
||||
{/* Monthly day-of-month */}
|
||||
{kind === "monthly" && (
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="custom-monthday" className="text-xs text-muted-foreground">
|
||||
Day of the month
|
||||
</Label>
|
||||
<Input
|
||||
id="custom-monthday"
|
||||
type="number"
|
||||
min={1}
|
||||
max={31}
|
||||
value={value.monthDay ?? ""}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value;
|
||||
if (v === "") setMonthDay("");
|
||||
else setMonthDay(Number(v));
|
||||
}}
|
||||
placeholder={String(firstFire.day)}
|
||||
className="h-8 w-24"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Months without this day skip naturally (e.g. 31st).
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* End condition */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs text-muted-foreground">Ends</Label>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{(["never", "after", "on"] as const).map((v) => {
|
||||
const active = value.end.kind === v;
|
||||
const label = v === "never" ? "Never" : v === "after" ? "After…" : "On date…";
|
||||
return (
|
||||
<button
|
||||
key={v}
|
||||
type="button"
|
||||
onClick={() => setEndKind(v)}
|
||||
aria-pressed={active}
|
||||
className={cn(
|
||||
"inline-flex items-center rounded-full border px-3 py-1 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",
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{value.end.kind === "after" && (
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={9999}
|
||||
value={value.end.count}
|
||||
onChange={(e) => {
|
||||
const n = Number(e.target.value);
|
||||
onChange({
|
||||
...value,
|
||||
end: { kind: "after", count: Number.isFinite(n) && n >= 1 ? n : 1 },
|
||||
});
|
||||
}}
|
||||
className="h-8 w-24"
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
occurrence{value.end.count === 1 ? "" : "s"}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{value.end.kind === "on" && (
|
||||
<div className="pt-1">
|
||||
<Input
|
||||
type="date"
|
||||
value={value.end.until}
|
||||
onChange={(e) =>
|
||||
onChange({ ...value, end: { kind: "on", until: e.target.value } })
|
||||
}
|
||||
className="h-8 w-44"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -163,115 +163,102 @@ describe("specFromRrule / kindFromRrule", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("preset shortcuts (Repeats picker)", () => {
|
||||
// FIRST is 2026-05-13 = Wednesday (ISO weekday 3), day 13, May.
|
||||
it("presetToSpec produces the canonical RecurrenceSpec for each shortcut", () => {
|
||||
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("daily", FIRST)).toMatchObject({ kind: "daily", interval: 1 });
|
||||
expect(presetToSpec("weekdays", FIRST)).toMatchObject({
|
||||
kind: "weekly",
|
||||
weeklyDays: [1, 2, 3, 4, 5],
|
||||
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 *" });
|
||||
});
|
||||
expect(presetToSpec("weekends", FIRST)).toMatchObject({
|
||||
kind: "weekly",
|
||||
weeklyDays: [6, 7],
|
||||
|
||||
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",
|
||||
});
|
||||
expect(presetToSpec("weekly_same", FIRST)).toMatchObject({
|
||||
kind: "weekly",
|
||||
weeklyDays: [3], // Wed
|
||||
});
|
||||
expect(presetToSpec("monthly_same", FIRST)).toMatchObject({
|
||||
kind: "monthly",
|
||||
monthDay: 13,
|
||||
});
|
||||
expect(presetToSpec("yearly_same", FIRST)).toMatchObject({ kind: "yearly" });
|
||||
});
|
||||
|
||||
it("matchPreset round-trips through presetToSpec for every preset", () => {
|
||||
for (const id of ["none", "daily", "weekdays", "weekends", "weekly_same", "monthly_same", "yearly_same"] as const) {
|
||||
const ids = [
|
||||
"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);
|
||||
}
|
||||
});
|
||||
|
||||
it("matchPreset returns 'custom' for anything that doesn't fit a shortcut", () => {
|
||||
// Interval > 1 doesn't match any preset.
|
||||
it("matchPreset returns 'cron' for an arbitrary cron string", () => {
|
||||
expect(
|
||||
matchPreset(
|
||||
{ kind: "daily", interval: 2, weeklyDays: [], end: { kind: "never" } },
|
||||
FIRST,
|
||||
),
|
||||
).toBe("custom");
|
||||
// Weekly Mon/Wed/Fri isn't a known shortcut.
|
||||
expect(
|
||||
matchPreset(
|
||||
{ kind: "weekly", interval: 1, weeklyDays: [1, 3, 5], end: { kind: "never" } },
|
||||
FIRST,
|
||||
),
|
||||
).toBe("custom");
|
||||
// End=after takes us out of the preset matrix even at interval=1.
|
||||
expect(
|
||||
matchPreset(
|
||||
{ kind: "daily", interval: 1, weeklyDays: [], end: { kind: "after", count: 5 } },
|
||||
FIRST,
|
||||
),
|
||||
).toBe("custom");
|
||||
// Monthly on a different day-of-month than the first fire.
|
||||
expect(
|
||||
matchPreset(
|
||||
{ kind: "monthly", interval: 1, weeklyDays: [], monthDay: 1, end: { kind: "never" } },
|
||||
FIRST,
|
||||
),
|
||||
).toBe("custom");
|
||||
});
|
||||
|
||||
it("presetDescriptors returns the full preset list with first-fire-aware labels", () => {
|
||||
const items = presetDescriptors(FIRST);
|
||||
expect(items.map((d) => d.id)).toEqual([
|
||||
"none",
|
||||
"daily",
|
||||
"weekdays",
|
||||
"weekends",
|
||||
"weekly_same",
|
||||
"monthly_same",
|
||||
"yearly_same",
|
||||
"custom",
|
||||
"cron",
|
||||
]);
|
||||
// Labels should be parameterised by firstFire.
|
||||
expect(items.find((d) => d.id === "weekly_same")?.label).toBe("Every week on Wed");
|
||||
expect(items.find((d) => d.id === "monthly_same")?.label).toBe(
|
||||
"Every month on day 13",
|
||||
);
|
||||
expect(items.find((d) => d.id === "yearly_same")?.label).toBe(
|
||||
"Every year on May 13",
|
||||
);
|
||||
expect(items.find((d) => d.id === "cron")?.label).toBe("Cron expression…");
|
||||
});
|
||||
|
||||
it("presetToSpec('cron') seeds a daily-at-the-first-fire cron", () => {
|
||||
const spec = presetToSpec("cron", FIRST);
|
||||
expect(spec.kind).toBe("cron");
|
||||
// FIRST is 09:00, so default cron = "0 9 * * *" (every day at 9:00).
|
||||
expect(spec.cron).toBe("0 9 * * *");
|
||||
});
|
||||
|
||||
it("matchPreset returns 'cron' for any cron-kind spec", () => {
|
||||
expect(
|
||||
matchPreset(
|
||||
{ kind: "cron", interval: 1, weeklyDays: [], cron: "0 9 * * 1-5", end: { kind: "never" } },
|
||||
{ kind: "cron", interval: 1, weeklyDays: [], cron: "*/7 * * * *", end: { kind: "never" } },
|
||||
FIRST,
|
||||
),
|
||||
).toBe("cron");
|
||||
});
|
||||
|
||||
it("buildRrule produces a CRON: prefixed string for cron specs", () => {
|
||||
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("buildRrule produces a CRON: prefixed string for every cron preset", () => {
|
||||
expect(
|
||||
buildRrule(
|
||||
{ kind: "cron", interval: 1, weeklyDays: [], cron: "0 9 * * 1-5", end: { kind: "never" } },
|
||||
FIRST,
|
||||
),
|
||||
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", () => {
|
||||
|
||||
@ -216,31 +216,79 @@ export function kindFromRrule(rrule: string | null | undefined): RecurrenceKind
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Preset shortcuts for the Repeats picker
|
||||
// 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.
|
||||
// ---------------------------------------------------------------------------
|
||||
export type PresetId =
|
||||
| "none"
|
||||
| "daily"
|
||||
| "weekdays"
|
||||
| "weekends"
|
||||
| "weekly_same"
|
||||
| "monthly_same"
|
||||
| "yearly_same"
|
||||
| "custom"
|
||||
| "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";
|
||||
|
||||
/** Map ISO weekday (1=Mon..7=Sun) → cron weekday (0=Sun..6=Sat). */
|
||||
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 {
|
||||
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":
|
||||
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} *`;
|
||||
}
|
||||
}
|
||||
|
||||
export interface PresetDescriptor {
|
||||
id: PresetId;
|
||||
/** Short label shown in the radio list. */
|
||||
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 spec depends on the chosen first fire — e.g. "every
|
||||
* week on the same weekday" means whatever weekday firstFire lands on).
|
||||
* 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 = {
|
||||
@ -249,111 +297,85 @@ export function presetToSpec(id: PresetId, firstFire: DateTime): RecurrenceSpec
|
||||
weeklyDays: [],
|
||||
end: { kind: "never" },
|
||||
};
|
||||
switch (id) {
|
||||
case "none":
|
||||
return base;
|
||||
case "daily":
|
||||
return { ...base, kind: "daily" };
|
||||
case "weekdays":
|
||||
return { ...base, kind: "weekly", weeklyDays: [1, 2, 3, 4, 5] };
|
||||
case "weekends":
|
||||
return { ...base, kind: "weekly", weeklyDays: [6, 7] };
|
||||
case "weekly_same":
|
||||
return { ...base, kind: "weekly", weeklyDays: [firstFire.weekday] };
|
||||
case "monthly_same":
|
||||
return { ...base, kind: "monthly", monthDay: firstFire.day };
|
||||
case "yearly_same":
|
||||
return { ...base, kind: "yearly" };
|
||||
case "custom":
|
||||
// Custom is only meaningful in the picker UI — when the user
|
||||
// explicitly opts into it, callers should preserve whatever
|
||||
// detailed spec the user already had. Return a sensible weekly
|
||||
// default if the caller forgets to pass through.
|
||||
return { ...base, kind: "weekly", weeklyDays: [firstFire.weekday] };
|
||||
case "cron":
|
||||
// Default cron expression: every day at the first-fire's HH:MM.
|
||||
return {
|
||||
...base,
|
||||
kind: "cron",
|
||||
cron: `${firstFire.minute} ${firstFire.hour} * * *`,
|
||||
};
|
||||
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?
|
||||
*
|
||||
* Returns "custom" for anything that doesn't match a known shortcut —
|
||||
* the picker uses that to flip into expanded-detail mode.
|
||||
* 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";
|
||||
if (spec.kind !== "cron") return "cron";
|
||||
|
||||
const sameInterval = spec.interval === 1;
|
||||
const noEnd = spec.end.kind === "never";
|
||||
if (!sameInterval || !noEnd) return "custom";
|
||||
const expr = (spec.cron ?? "").trim();
|
||||
if (!expr) return "cron";
|
||||
|
||||
const sortedWeeklyDays = (days: number[]) => days.slice().sort((a, b) => a - b).join(",");
|
||||
|
||||
switch (spec.kind) {
|
||||
case "daily":
|
||||
return "daily";
|
||||
case "weekly": {
|
||||
const days = spec.weeklyDays.length === 0 ? [firstFire.weekday] : spec.weeklyDays;
|
||||
const key = sortedWeeklyDays(days);
|
||||
if (key === "1,2,3,4,5") return "weekdays";
|
||||
if (key === "6,7") return "weekends";
|
||||
if (key === String(firstFire.weekday)) return "weekly_same";
|
||||
return "custom";
|
||||
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;
|
||||
}
|
||||
case "monthly":
|
||||
if ((spec.monthDay ?? firstFire.day) === firstFire.day) return "monthly_same";
|
||||
return "custom";
|
||||
case "yearly":
|
||||
return "yearly_same";
|
||||
case "cron":
|
||||
return "cron";
|
||||
case "none":
|
||||
return "none";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the (firstFire-aware) labels and hints for the radio list.
|
||||
* The hint shows the concrete weekday/day-of-month/date the preset
|
||||
* would imply given the user's chosen first fire.
|
||||
* 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 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" },
|
||||
{ id: "daily", label: "Every day" },
|
||||
{ id: "weekdays", label: "Every weekday", hint: "Mon – Fri" },
|
||||
{ id: "weekends", label: "Every weekend", hint: "Sat – Sun" },
|
||||
{
|
||||
id: "weekly_same",
|
||||
label: `Every week on ${dayShort}`,
|
||||
hint: "Same weekday as the start date",
|
||||
},
|
||||
{
|
||||
id: "monthly_same",
|
||||
label: `Every month on day ${firstFire.day}`,
|
||||
hint: "Months without this day skip naturally (e.g. 31st)",
|
||||
},
|
||||
{
|
||||
id: "yearly_same",
|
||||
label: `Every year on ${firstFire.toFormat("MMM d")}`,
|
||||
},
|
||||
{
|
||||
id: "custom",
|
||||
label: "Custom…",
|
||||
hint: "Set interval, days, and end conditions yourself",
|
||||
},
|
||||
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: "Cron expression…",
|
||||
hint: "Full sec/min/hour/day/month/dow combinational power",
|
||||
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