cm_whatsapp_bot_v1/apps/web/src/lib/recurrence.test.ts
yiekheng 991ff5fb22 feat(recurrence): redesign Repeats picker as a preset radio list
The old picker was a row of 5 frequency pills (One-off / Daily / Weekly /
Monthly / Yearly) followed by a separate detail panel — common cases
needed several clicks (interval, weekday list, etc.) and the visual
hierarchy didn't show what was selected at a glance.

New design — a vertical radio list with seven first-fire-aware presets
plus a Custom… expander:

  ○ Don't repeat                                      (one-off)
  ○ Every day
  ○ Every weekday                                     (Mon – Fri)
  ○ Every weekend                                     (Sat – Sun)
  ○ Every week on Wed                                 (matches start)
  ○ Every month on day 13                             (matches start)
  ○ Every year on May 13                              (matches start)
  ○ Custom…                                           ▼ expands

Custom… reveals the existing power-user controls (frequency dropdown,
interval input, weekday picker, day-of-month, end-condition) without
crowding the common path. Toggling between presets and custom is
lossless — the spec is the source of truth.

New helpers in `lib/recurrence.ts`:
- `presetToSpec(id, firstFire)` — canonical RecurrenceSpec for each
  preset (round-trippable).
- `matchPreset(spec, firstFire)` — reverse mapping; returns "custom"
  for anything that doesn't fit a shortcut, so the picker auto-flips
  into expanded mode for non-preset specs.
- `presetDescriptors(firstFire)` — list of preset id/label/hint with
  first-fire-aware copy ("Every week on Wed", "May 13", etc).

Wired into both:
- reminder-wizard/when-form-client.tsx (creating)
- reminder-edit/edit-when-form.tsx (editing a section in place)

Tests (+4, 134 web + 26 bot = 160 total green):
- recurrence.test.ts gains a "preset shortcuts" suite covering:
  * presetToSpec → canonical spec for each id
  * round-trip via matchPreset
  * matchPreset returns "custom" for non-shortcut specs
    (interval > 1, weekly Mon/Wed/Fri, end=after, monthly on a
    different day-of-month than the first fire)
  * presetDescriptors labels are first-fire-aware

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

299 lines
8.5 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 8 entries 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",
]);
// 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",
);
});
});
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");
});
});