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>
236 lines
6.4 KiB
TypeScript
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");
|
|
});
|
|
});
|