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>
299 lines
8.5 KiB
TypeScript
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");
|
|
});
|
|
});
|