The custom RRULE panel covers most patterns but can't express "every
weekday at 9, 12, and 18" or "every 15 minutes within working hours"
in a single rule. RRULE's BYxxx fields can technically combine, but
the picker UX gets unwieldy fast. Cron expressions cover everything
in one line.
Storage / dispatch
- Cron rules live in the same `reminders.rrule` column with a sentinel
prefix: `CRON:0 9 * * 1-5`. No schema change.
- `@cmbot/shared` now exports:
CRON_PREFIX, isCronRule, stripCronPrefix, validateCronExpression
- `nextOccurrence(rule, tz, after)` and `validateMinInterval(rule, tz)`
detect the prefix and dispatch to `cron-parser`; non-cron rules
continue to flow through rrule unchanged.
- `cron-parser@^5.5.0` added as an explicit dep on @cmbot/shared
(it was already transitively present via pg-boss).
Server actions
- `createReminderAction` / `updateReminderAction`: when rrule has the
CRON: prefix, the user's date+time inputs are ignored — the action
validates the cron, runs the min-interval check (5 min between
fires), and computes scheduledAt as the next match of the cron
expression after now. The bot's existing fire-reminder loop
re-arms via `nextOccurrence` after each fire, which already speaks
cron via the dispatch above.
Picker
- New "Cron expression…" preset at the bottom of the radio list:
"Full sec/min/hour/day/month/dow combinational power"
Selecting it reveals a CronPanel:
* font-mono cron input (5- or 6-field accepted)
* inline examples: 0 9 * * 1-5, */15 * * * *, 0 9,12,18 * * *,
0 0 1 * *
* note that the Date+Time controls above are ignored once a cron
expression is set
- RecurrenceSpec gains an optional `cron` string and a new `kind: "cron"`.
- buildRrule emits `CRON:<expr>` for cron specs.
- specFromRrule round-trips a CRON-prefixed rule back into the spec.
- describeRecurrence renders "Cron: <expr>" so the list view and
review steps show the expression.
Tests (+10; 17 shared + 26 bot + 138 web = 181 total)
- packages/shared rrule.test.ts (+8):
* CRON_PREFIX / isCronRule / stripCronPrefix
* nextOccurrence on a CRON rule returns the right next match in the
operator timezone (e.g. weekday 9 AM KL ↔ exact UTC instant)
* RRULE rules still flow through unchanged
* validateMinInterval on cron: hourly OK, every-minute rejected,
malformed string returns a useful error
* validateCronExpression positive + negative cases
- recurrence.test.ts (+5): cron preset round-trip, label assertions,
`buildRrule`/`specFromRrule` for cron specs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
333 lines
9.6 KiB
TypeScript
333 lines
9.6 KiB
TypeScript
import { describe, it, expect } from "vitest";
|
|
import { DateTime } from "luxon";
|
|
import {
|
|
buildRrule,
|
|
describeRecurrence,
|
|
kindFromRrule,
|
|
matchPreset,
|
|
presetDescriptors,
|
|
presetToSpec,
|
|
specFromRrule,
|
|
type RecurrenceSpec,
|
|
} from "./recurrence";
|
|
|
|
const FIRST = DateTime.fromISO("2026-05-13T09:00:00", { zone: "Asia/Kuala_Lumpur" });
|
|
|
|
const baseSpec = (over: Partial<RecurrenceSpec> = {}): RecurrenceSpec => ({
|
|
kind: "none",
|
|
interval: 1,
|
|
weeklyDays: [],
|
|
end: { kind: "never" },
|
|
...over,
|
|
});
|
|
|
|
describe("buildRrule", () => {
|
|
it("returns null for one-off", () => {
|
|
expect(buildRrule(baseSpec({ kind: "none" }), FIRST)).toBe(null);
|
|
});
|
|
|
|
it("daily simple", () => {
|
|
expect(buildRrule(baseSpec({ kind: "daily" }), FIRST)).toBe("FREQ=DAILY");
|
|
});
|
|
|
|
it("daily with interval", () => {
|
|
expect(buildRrule(baseSpec({ kind: "daily", interval: 3 }), FIRST)).toBe(
|
|
"FREQ=DAILY;INTERVAL=3",
|
|
);
|
|
});
|
|
|
|
it("weekly with explicit days sorts to canonical order", () => {
|
|
expect(
|
|
buildRrule(baseSpec({ kind: "weekly", weeklyDays: [3, 1, 5] }), FIRST),
|
|
).toBe("FREQ=WEEKLY;BYDAY=MO,WE,FR");
|
|
});
|
|
|
|
it("weekly with no days falls back to first-fire weekday (Wed)", () => {
|
|
expect(buildRrule(baseSpec({ kind: "weekly" }), FIRST)).toBe("FREQ=WEEKLY;BYDAY=WE");
|
|
});
|
|
|
|
it("monthly defaults to first-fire day-of-month", () => {
|
|
expect(buildRrule(baseSpec({ kind: "monthly" }), FIRST)).toBe(
|
|
"FREQ=MONTHLY;BYMONTHDAY=13",
|
|
);
|
|
});
|
|
|
|
it("monthly honours explicit monthDay", () => {
|
|
expect(buildRrule(baseSpec({ kind: "monthly", monthDay: 1 }), FIRST)).toBe(
|
|
"FREQ=MONTHLY;BYMONTHDAY=1",
|
|
);
|
|
});
|
|
|
|
it("yearly uses BYMONTH and BYMONTHDAY", () => {
|
|
expect(buildRrule(baseSpec({ kind: "yearly" }), FIRST)).toBe(
|
|
"FREQ=YEARLY;BYMONTH=5;BYMONTHDAY=13",
|
|
);
|
|
});
|
|
|
|
it("end=after attaches COUNT", () => {
|
|
expect(
|
|
buildRrule(
|
|
baseSpec({ kind: "daily", end: { kind: "after", count: 7 } }),
|
|
FIRST,
|
|
),
|
|
).toBe("FREQ=DAILY;COUNT=7");
|
|
});
|
|
|
|
it("end=on attaches UNTIL in UTC", () => {
|
|
const r = buildRrule(
|
|
baseSpec({ kind: "daily", end: { kind: "on", until: "2026-06-01" } }),
|
|
FIRST,
|
|
);
|
|
expect(r).toMatch(/^FREQ=DAILY;UNTIL=2026060[0-2]T235959Z$/);
|
|
});
|
|
|
|
it("interval + weekly + count compose correctly", () => {
|
|
expect(
|
|
buildRrule(
|
|
baseSpec({
|
|
kind: "weekly",
|
|
interval: 2,
|
|
weeklyDays: [1, 3, 5],
|
|
end: { kind: "after", count: 12 },
|
|
}),
|
|
FIRST,
|
|
),
|
|
).toBe("FREQ=WEEKLY;BYDAY=MO,WE,FR;INTERVAL=2;COUNT=12");
|
|
});
|
|
});
|
|
|
|
describe("specFromRrule / kindFromRrule", () => {
|
|
it("returns the default spec for null/undefined", () => {
|
|
expect(specFromRrule(null)).toEqual({
|
|
kind: "none",
|
|
interval: 1,
|
|
weeklyDays: [],
|
|
monthDay: undefined,
|
|
end: { kind: "never" },
|
|
});
|
|
expect(kindFromRrule(undefined)).toBe("none");
|
|
});
|
|
|
|
it("parses daily with interval", () => {
|
|
expect(specFromRrule("FREQ=DAILY;INTERVAL=3")).toEqual({
|
|
kind: "daily",
|
|
interval: 3,
|
|
weeklyDays: [],
|
|
monthDay: undefined,
|
|
end: { kind: "never" },
|
|
});
|
|
});
|
|
|
|
it("parses weekly with BYDAY", () => {
|
|
expect(specFromRrule("FREQ=WEEKLY;BYDAY=MO,WE,FR")).toMatchObject({
|
|
kind: "weekly",
|
|
weeklyDays: [1, 3, 5],
|
|
});
|
|
});
|
|
|
|
it("parses monthly with BYMONTHDAY", () => {
|
|
expect(specFromRrule("FREQ=MONTHLY;BYMONTHDAY=15")).toMatchObject({
|
|
kind: "monthly",
|
|
monthDay: 15,
|
|
});
|
|
});
|
|
|
|
it("parses COUNT into end=after", () => {
|
|
expect(specFromRrule("FREQ=DAILY;COUNT=10").end).toEqual({
|
|
kind: "after",
|
|
count: 10,
|
|
});
|
|
});
|
|
|
|
it("parses UNTIL into end=on (date only)", () => {
|
|
expect(specFromRrule("FREQ=DAILY;UNTIL=20260601T235959Z").end).toEqual({
|
|
kind: "on",
|
|
until: "2026-06-01",
|
|
});
|
|
});
|
|
|
|
it("round-trips through buildRrule + specFromRrule for compound rules", () => {
|
|
const spec = baseSpec({
|
|
kind: "weekly",
|
|
interval: 2,
|
|
weeklyDays: [1, 3, 5],
|
|
end: { kind: "after", count: 12 },
|
|
});
|
|
const rule = buildRrule(spec, FIRST)!;
|
|
expect(specFromRrule(rule)).toMatchObject({
|
|
kind: "weekly",
|
|
interval: 2,
|
|
weeklyDays: [1, 3, 5],
|
|
end: { kind: "after", count: 12 },
|
|
});
|
|
});
|
|
});
|
|
|
|
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", () => {
|
|
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("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", () => {
|
|
for (const id of ["none", "daily", "weekdays", "weekends", "weekly_same", "monthly_same", "yearly_same"] as const) {
|
|
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.
|
|
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" } },
|
|
FIRST,
|
|
),
|
|
).toBe("cron");
|
|
});
|
|
|
|
it("buildRrule produces a CRON: prefixed string for cron specs", () => {
|
|
expect(
|
|
buildRrule(
|
|
{ kind: "cron", interval: 1, weeklyDays: [], cron: "0 9 * * 1-5", end: { kind: "never" } },
|
|
FIRST,
|
|
),
|
|
).toBe("CRON:0 9 * * 1-5");
|
|
});
|
|
|
|
it("specFromRrule round-trips a CRON: prefixed rule", () => {
|
|
expect(specFromRrule("CRON:*/15 * * * *")).toMatchObject({
|
|
kind: "cron",
|
|
cron: "*/15 * * * *",
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("describeRecurrence", () => {
|
|
it("renders a one-off label", () => {
|
|
expect(describeRecurrence(baseSpec({ kind: "none" }), FIRST)).toBe("One-off");
|
|
});
|
|
|
|
it("renders interval and unit pluralisation", () => {
|
|
expect(describeRecurrence(baseSpec({ kind: "daily" }), FIRST)).toBe("Every day");
|
|
expect(describeRecurrence(baseSpec({ kind: "daily", interval: 2 }), FIRST)).toBe(
|
|
"Every 2 days",
|
|
);
|
|
});
|
|
|
|
it("renders weekly days as Short labels in canonical order", () => {
|
|
expect(
|
|
describeRecurrence(baseSpec({ kind: "weekly", weeklyDays: [5, 1, 3] }), FIRST),
|
|
).toBe("Every week on Mon, Wed, Fri");
|
|
});
|
|
|
|
it("renders monthly with day", () => {
|
|
expect(describeRecurrence(baseSpec({ kind: "monthly", monthDay: 14 }), FIRST)).toBe(
|
|
"Every month on day 14",
|
|
);
|
|
});
|
|
|
|
it("renders yearly with month and day", () => {
|
|
expect(describeRecurrence(baseSpec({ kind: "yearly" }), FIRST)).toBe(
|
|
"Every year on May 13",
|
|
);
|
|
});
|
|
|
|
it("appends end=after as ', N times'", () => {
|
|
expect(
|
|
describeRecurrence(
|
|
baseSpec({ kind: "daily", end: { kind: "after", count: 5 } }),
|
|
FIRST,
|
|
),
|
|
).toBe("Every day, 5 times");
|
|
});
|
|
|
|
it("appends end=on as ', until <date>'", () => {
|
|
expect(
|
|
describeRecurrence(
|
|
baseSpec({ kind: "daily", end: { kind: "on", until: "2026-06-01" } }),
|
|
FIRST,
|
|
),
|
|
).toBe("Every day, until 2026-06-01");
|
|
});
|
|
});
|