cm_whatsapp_bot_v1/apps/web/src/lib/recurrence.test.ts
yiekheng a7a5c6821b feat(recurrence): redesign as a Temenos-style trigger + dialog picker
The previous flat radio list with N-minutes / N-hours / Custom-cron
options is gone. Per the Temenos UUX `date-recurrence-picker` pattern
(developer.temenos.com/uux/docs/components/date-recurrence-picker), the
form now shows a single read-only trigger field summarising the saved
rule:

  ┌────────────────────────────────────────────────────┐
  │ 📅  Don't repeat                                ▾  │
  └────────────────────────────────────────────────────┘

Clicking the trigger opens a modal with the recurrence types as a
tab strip and per-type config swapped in below:

  ┌──────────────────────────────────────────────────┐
  │  Repeat schedule                            ✕    │
  ├──────────────────────────────────────────────────┤
  │  [Don't repeat] [Daily] [Weekly] [Monthly] [Yearly] │
  │                                                  │
  │  <per-type config>                               │
  │                                                  │
  │  Fires: <plain-text confirmation>                │
  │                                                  │
  │  [Reset]                    [Cancel] [Save]      │
  └──────────────────────────────────────────────────┘

Per-tab config:
  - Don't repeat — informational text only
  - Daily       — radio: "Every day" / "Every weekday (Mon–Fri)"
  - Weekly      — Mon..Sun chip multi-select
  - Monthly     — day-of-month input (1-31)
  - Yearly      — month select + day input

The "Fires: …" sentence updates live as the user edits and reflects
the outer time-picker's HH:MM. Save commits, Cancel discards.

Removed:
  - Every N minutes
  - Every N hours
  - Custom cron expression…
  - The standalone helpers `flowToCron` / `flowFromCron` /
    `freqChoices` / `defaultFlowState` / `FlowState` / `FreqChoice`
    in `lib/recurrence.ts`. Their job (compile a UI state to a cron
    string and parse one back) now lives privately inside the picker.

Storage / runtime
- Output is still a `CRON:` prefixed rule in `reminders.rrule`. The
  bot's `nextOccurrence` already dispatches cron rules through
  cron-parser, so no schema or scheduler changes were needed.

Tests (132 web)
- recurrence.test.ts trimmed to keep only what survives: CRON-rule
  round-trip via buildRrule + specFromRrule, and the ISO→cron
  weekday helper.
- Existing wizard / edit-when-form integration tests are unaffected
  because the picker exports the same `<RecurrencePicker>` props.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 11:02:16 +08:00

236 lines
6.4 KiB
TypeScript

import { describe, it, expect } from "vitest";
import { DateTime } from "luxon";
import {
buildRrule,
describeRecurrence,
isoWeekdayToCron,
kindFromRrule,
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("cron weekday helper + buildRrule/specFromRrule round-trip", () => {
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("buildRrule + specFromRrule still round-trip CRON: rules", () => {
const spec: RecurrenceSpec = {
kind: "cron",
interval: 1,
weeklyDays: [],
cron: "*/15 * * * *",
end: { kind: "never" },
};
expect(buildRrule(spec, FIRST)).toBe("CRON:*/15 * * * *");
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");
});
});