cm_whatsapp_bot_v1/apps/web/src/lib/recurrence.test.ts
yiekheng 5f1897daa5 feat(recurrence): full crontab support — sec/min/hour/day/month/dow combinational
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>
2026-05-10 10:25:47 +08:00

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");
});
});