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:
yiekheng 2026-05-10 10:32:29 +08:00
parent 5f1897daa5
commit 63b88c69b4
3 changed files with 234 additions and 485 deletions

View File

@ -1,82 +1,50 @@
"use client"; "use client";
import { useState } from "react";
import { DateTime } from "luxon"; import { DateTime } from "luxon";
import { import { CheckIcon, RepeatIcon } from "lucide-react";
CheckIcon,
ChevronDownIcon,
ChevronUpIcon,
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 {
WEEKDAY_LABELS,
matchPreset, matchPreset,
presetDescriptors, presetDescriptors,
presetToSpec, presetToSpec,
type EndKind,
type PresetId, type PresetId,
type RecurrenceKind,
type RecurrenceSpec, type RecurrenceSpec,
} from "@/lib/recurrence"; } from "@/lib/recurrence";
interface RecurrencePickerProps { 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; firstFire: DateTime;
value: RecurrenceSpec; value: RecurrenceSpec;
onChange: (next: RecurrenceSpec) => void; onChange: (next: RecurrenceSpec) => void;
} }
const FREQ_UNIT: Record<Exclude<RecurrenceKind, "none">, string> = { // Cron-only Repeats picker.
daily: "day", //
weekly: "week", // ┌─────────────────────────────────────────────────────┐
monthly: "month", // │ ○ Don't repeat Fires once and ends │
yearly: "year", // │ ○ Every minute every minute │
}; // │ ○ Every 5/15/30 minutes every N minutes │
// │ ○ Every hour at :00 │
/** // │ ○ Every day at HH:MM │
* Reminder repeat picker. // │ ○ Every weekday/weekend at HH:MM │
* // │ ○ Every <weekday> at HH:MM │
* // │ ○ Every month on day <D> at HH:MM │
* Don't repeat (one-off) // │ ○ Every year on <Mon D> at HH:MM │
* Every day // │ ○ Custom cron expression… │
* Every weekday Mon Fri // └─────────────────────────────────────────────────────┘
* Every weekend Sat Sun //
* Every week on Wed Same weekday as start // Selecting a preset sets `value` to `{ kind: "cron", cron: "<expr>" }`.
* Every month on day 13 // "Don't repeat" sets `kind: "none"`. "Custom cron…" reveals a free-
* Every year on May 13 // form text input.
* 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.
*/
export function RecurrencePicker({ firstFire, value, onChange }: RecurrencePickerProps) { export function RecurrencePicker({ firstFire, value, onChange }: RecurrencePickerProps) {
const activePreset = matchPreset(value, firstFire); 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) { function pickPreset(id: PresetId) {
if (id === "custom") { if (id === "cron" && value.kind === "cron") {
setForceCustomOpen(true); // Already in custom mode — preserve whatever the user has typed.
// 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));
return; return;
} }
onChange(presetToSpec(id, firstFire)); onChange(presetToSpec(id, firstFire));
@ -107,7 +75,6 @@ export function RecurrencePicker({ firstFire, value, onChange }: RecurrencePicke
: "hover:bg-muted text-foreground", : "hover:bg-muted text-foreground",
)} )}
> >
{/* Radio dot */}
<span <span
aria-hidden aria-hidden
className={cn( className={cn(
@ -123,44 +90,35 @@ export function RecurrencePicker({ firstFire, value, onChange }: RecurrencePicke
<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">{p.label}</span>
{p.hint && ( {p.hint && (
<span className="block text-xs text-muted-foreground">{p.hint}</span> <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> </span>
{p.id === "custom" && (
<span className="text-muted-foreground">
{customExpanded ? (
<ChevronUpIcon className="size-4" />
) : (
<ChevronDownIcon className="size-4" />
)}
</span>
)}
</button> </button>
</li> </li>
); );
})} })}
</ul> </ul>
{customExpanded && (
<CustomPanel firstFire={firstFire} value={value} onChange={onChange} />
)}
{activePreset === "cron" && ( {activePreset === "cron" && (
<CronPanel value={value} onChange={onChange} /> <CronInput value={value} onChange={onChange} />
)} )}
</div> </div>
</div> </div>
); );
} }
function CronPanel({ function CronInput({
value, value,
onChange, onChange,
}: { }: {
value: RecurrenceSpec; value: RecurrenceSpec;
onChange: (next: RecurrenceSpec) => void; onChange: (next: RecurrenceSpec) => void;
}) { }) {
const cron = value.kind === "cron" ? value.cron ?? "" : "";
return ( return (
<div className="space-y-2 border-t border-border bg-muted/20 p-4"> <div className="space-y-2 border-t border-border bg-muted/20 p-4">
<Label htmlFor="cron-expr" className="text-xs text-muted-foreground"> <Label htmlFor="cron-expr" className="text-xs text-muted-foreground">
@ -168,8 +126,16 @@ function CronPanel({
</Label> </Label>
<Input <Input
id="cron-expr" id="cron-expr"
value={value.cron ?? ""} value={cron}
onChange={(e) => onChange({ ...value, cron: e.target.value })} onChange={(e) =>
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}
@ -182,235 +148,9 @@ function CronPanel({
<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>
<li><span className="text-foreground">*/15 * * * *</span> every 15 minutes</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> <li><span className="text-foreground">0 0 1 * *</span> midnight on the 1st of every month</li>
</ul> </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> </div>
); );
} }

View File

@ -163,115 +163,102 @@ describe("specFromRrule / kindFromRrule", () => {
}); });
}); });
describe("preset shortcuts (Repeats picker)", () => { describe("preset shortcuts (cron-only Repeats picker)", () => {
// FIRST is 2026-05-13 = Wednesday (ISO weekday 3), day 13, May. // FIRST is 2026-05-13 09:00 = Wednesday (ISO weekday 3, cron 3),
it("presetToSpec produces the canonical RecurrenceSpec for each shortcut", () => { // day 13, May.
it("presetToSpec emits a cron-kind spec for every recurring preset", () => {
expect(presetToSpec("none", FIRST)).toMatchObject({ kind: "none" }); expect(presetToSpec("none", FIRST)).toMatchObject({ kind: "none" });
expect(presetToSpec("daily", FIRST)).toMatchObject({ kind: "daily", interval: 1 }); expect(presetToSpec("every_minute", FIRST)).toMatchObject({ kind: "cron", cron: "* * * * *" });
expect(presetToSpec("weekdays", FIRST)).toMatchObject({ expect(presetToSpec("every_5min", FIRST)).toMatchObject({ kind: "cron", cron: "*/5 * * * *" });
kind: "weekly", expect(presetToSpec("every_15min", FIRST)).toMatchObject({ kind: "cron", cron: "*/15 * * * *" });
weeklyDays: [1, 2, 3, 4, 5], 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", () => {
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("weekends", FIRST)).toMatchObject({
kind: "weekly",
weeklyDays: [6, 7],
});
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", () => { 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); const spec = presetToSpec(id, FIRST);
expect(matchPreset(spec, FIRST)).toBe(id); expect(matchPreset(spec, FIRST)).toBe(id);
} }
}); });
it("matchPreset returns 'custom' for anything that doesn't fit a shortcut", () => { it("matchPreset returns 'cron' for an arbitrary cron string", () => {
// Interval > 1 doesn't match any preset.
expect( expect(
matchPreset( matchPreset(
{ kind: "daily", interval: 2, weeklyDays: [], end: { kind: "never" } }, { kind: "cron", interval: 1, weeklyDays: [], cron: "*/7 * * * *", 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" } },
FIRST, FIRST,
), ),
).toBe("cron"); ).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( expect(
buildRrule( buildRrule(presetToSpec("every_weekday", FIRST), FIRST),
{ kind: "cron", interval: 1, weeklyDays: [], cron: "0 9 * * 1-5", end: { kind: "never" } },
FIRST,
),
).toBe("CRON:0 9 * * 1-5"); ).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("specFromRrule round-trips a CRON: prefixed rule", () => {

View File

@ -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 = export type PresetId =
| "none" | "none"
| "daily" | "every_minute"
| "weekdays" | "every_5min"
| "weekends" | "every_15min"
| "weekly_same" | "every_30min"
| "monthly_same" | "every_hour"
| "yearly_same" | "every_day"
| "custom" | "every_weekday"
| "every_weekend"
| "every_same_dow"
| "every_month_dom"
| "every_year"
| "cron"; | "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 { export interface PresetDescriptor {
id: PresetId; id: PresetId;
/** Short label shown in the radio list. */ /** Short label shown in the radio list. */
label: string; label: string;
/** Optional one-line hint shown beneath the label. */ /** Optional one-line hint shown beneath the label. */
hint?: string; 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 * Build the canonical RecurrenceSpec for a preset given the first-fire
* DateTime (the spec depends on the chosen first fire e.g. "every * DateTime. The picker is cron-only every recurring preset emits a
* week on the same weekday" means whatever weekday firstFire lands on). * `{ 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 { export function presetToSpec(id: PresetId, firstFire: DateTime): RecurrenceSpec {
const base: RecurrenceSpec = { const base: RecurrenceSpec = {
@ -249,111 +297,85 @@ export function presetToSpec(id: PresetId, firstFire: DateTime): RecurrenceSpec
weeklyDays: [], weeklyDays: [],
end: { kind: "never" }, end: { kind: "never" },
}; };
switch (id) { if (id === "none") return base;
case "none": if (id === "cron") {
return base; // Default seed for the custom textbox — every day at the first
case "daily": // fire's HH:MM. The user is free to overwrite.
return { ...base, kind: "daily" }; return { ...base, kind: "cron", cron: `${firstFire.minute} ${firstFire.hour} * * *` };
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} * * *`,
};
} }
return { ...base, kind: "cron", cron: presetCron(id, firstFire) };
} }
/** /**
* Reverse mapping: which preset (if any) does this spec match? * Reverse mapping: which preset (if any) does this spec match?
* *
* Returns "custom" for anything that doesn't match a known shortcut * For a cron spec, compare the cron expression against each preset's
* the picker uses that to flip into expanded-detail mode. * 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 { export function matchPreset(spec: RecurrenceSpec, firstFire: DateTime): PresetId {
if (spec.kind === "none") return "none"; if (spec.kind === "none") return "none";
if (spec.kind === "cron") return "cron"; if (spec.kind !== "cron") return "cron";
const sameInterval = spec.interval === 1; const expr = (spec.cron ?? "").trim();
const noEnd = spec.end.kind === "never"; if (!expr) return "cron";
if (!sameInterval || !noEnd) return "custom";
const sortedWeeklyDays = (days: number[]) => days.slice().sort((a, b) => a - b).join(","); const ids: Array<Exclude<PresetId, "none" | "cron">> = [
"every_minute",
switch (spec.kind) { "every_5min",
case "daily": "every_15min",
return "daily"; "every_30min",
case "weekly": { "every_hour",
const days = spec.weeklyDays.length === 0 ? [firstFire.weekday] : spec.weeklyDays; "every_day",
const key = sortedWeeklyDays(days); "every_weekday",
if (key === "1,2,3,4,5") return "weekdays"; "every_weekend",
if (key === "6,7") return "weekends"; "every_same_dow",
if (key === String(firstFire.weekday)) return "weekly_same"; "every_month_dom",
return "custom"; "every_year",
} ];
case "monthly": for (const id of ids) {
if ((spec.monthDay ?? firstFire.day) === firstFire.day) return "monthly_same"; if (presetCron(id, firstFire) === expr) return id;
return "custom";
case "yearly":
return "yearly_same";
case "cron":
return "cron";
case "none":
return "none";
} }
return "cron";
} }
/** /**
* Render the (firstFire-aware) labels and hints for the radio list. * Render the cron-flavoured radio list. Every recurring preset shows
* The hint shows the concrete weekday/day-of-month/date the preset * its underlying cron expression as the hint so the user can see what
* would imply given the user's chosen first fire. * 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[] { export function presetDescriptors(firstFire: DateTime): PresetDescriptor[] {
const t = firstFire.toFormat("HH:mm");
const dayShort = WEEKDAY_LABELS[firstFire.weekday - 1]?.short ?? ""; 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" },
{ id: "daily", label: "Every day" }, item("every_minute", "Every minute"),
{ id: "weekdays", label: "Every weekday", hint: "Mon Fri" }, item("every_5min", "Every 5 minutes"),
{ id: "weekends", label: "Every weekend", hint: "Sat Sun" }, item("every_15min", "Every 15 minutes"),
{ item("every_30min", "Every 30 minutes"),
id: "weekly_same", item("every_hour", `Every hour at :${firstFire.toFormat("mm")}`),
label: `Every week on ${dayShort}`, item("every_day", `Every day at ${t}`),
hint: "Same weekday as the start date", item("every_weekday", `Every weekday at ${t}`),
}, item("every_weekend", `Every weekend at ${t}`),
{ item("every_same_dow", `Every ${dayShort} at ${t}`),
id: "monthly_same", item("every_month_dom", `Every month on day ${dom} at ${t}`),
label: `Every month on day ${firstFire.day}`, item("every_year", `Every year on ${monShort} at ${t}`),
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",
},
{ {
id: "cron", id: "cron",
label: "Cron expression…", label: "Custom cron expression…",
hint: "Full sec/min/hour/day/month/dow combinational power", hint: "Write your own — full sec/min/hour/day/month/dow combinational power",
}, },
]; ];
} }