yiekheng 797917a4ba feat(recurrence): inline picker + multiple recurring schedules per reminder
Two changes in one cut, both per the user's redesign asks:

1. Bring the recurrence picker INLINE into the When form section.
   The dialog is gone — the type tabs and per-type config now live
   directly under the date+time inputs:

       [ Starts on ]   [ Time ]
       Repeats
       ┌──────────────────────────────────────────────────┐
       │ Schedule 1                            [✕]        │
       │ [Daily] [Weekly] [Monthly] [Yearly]              │
       │ <per-tab config>                                 │
       │ Every weekday at 09:00                           │
       ├──────────────────────────────────────────────────┤
       │ Schedule 2                            [✕]        │
       │ [Daily] [Weekly] [Monthly] [Yearly]              │
       │ <per-tab config>                                 │
       │ Every Friday at 17:00                            │
       └──────────────────────────────────────────────────┘
       [+ Add another schedule]

2. Allow multiple recurrence rules per reminder. Each row is its own
   tab strip + config; the picker compiles them down to a single
   newline-joined CRON: rule. Empty list = "Don't repeat" (one-off).
   MAX_RULES is 8.

Storage stays the same (`reminders.rrule`, `CRON:` sentinel). The
multi-rule format is just newline-separated cron expressions:

       CRON:0 9 * * 1
       0 17 * * 5

`@cmbot/shared` updates to support that:

  - nextOccurrence: splits on newline, computes the next match for
    each rule independently, returns the earliest. Malformed lines
    are skipped (so one bad rule doesn't kill the whole schedule).
  - validateMinInterval: validates every line; any single line firing
    more often than the 5-min minimum fails the whole rule.

Removed: the standalone modal Dialog wrapper, Reset/Cancel/Save
buttons, and the saved-vs-draft synchronisation. The picker now
edits state directly and the parent form's Save commits everything
at once (consistent with the date+time inputs that have always
behaved that way).

Tests (+3 in shared rrule.test.ts; total 20 shared + 26 bot + 132 web
= 178)
- nextOccurrence on a multi-line cron picks the earliest:
  * "0 9 * * 1\n0 17 * * 5" starting Saturday → Mon 09:00 KL
  * Same rule starting Tuesday → Fri 17:00 KL
- nextOccurrence ignores malformed lines and still returns the next
  match from the valid ones.
- validateMinInterval: passes a clean two-line rule; rejects a rule
  containing a too-frequent line.

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

157 lines
5.4 KiB
TypeScript

import { describe, expect, it } from "vitest";
import {
parseRRule,
nextOccurrence,
validateMinInterval,
validateCronExpression,
isCronRule,
stripCronPrefix,
CRON_PREFIX,
MIN_INTERVAL_MS,
} from "./rrule.js";
describe("parseRRule", () => {
it("accepts a daily rule", () => {
expect(() => parseRRule("FREQ=DAILY;BYHOUR=9;BYMINUTE=0")).not.toThrow();
});
it("rejects invalid syntax", () => {
expect(() => parseRRule("not-a-rule")).toThrow();
});
});
describe("nextOccurrence", () => {
it("returns the next firing time after `after`", () => {
const rule = "FREQ=DAILY;BYHOUR=9;BYMINUTE=0";
const after = new Date("2026-05-03T08:00:00Z");
const next = nextOccurrence(rule, "Asia/Kuala_Lumpur", after);
expect(next).toBeInstanceOf(Date);
expect(next!.getTime()).toBeGreaterThan(after.getTime());
});
it("returns null when the rule has no further occurrences", () => {
const past = "DTSTART:20200101T000000Z\nRRULE:FREQ=DAILY;COUNT=1";
expect(nextOccurrence(past, "Asia/Kuala_Lumpur", new Date())).toBeNull();
});
});
describe("validateMinInterval", () => {
it("accepts a daily rule (interval > 5 min)", () => {
expect(validateMinInterval("FREQ=DAILY;BYHOUR=9;BYMINUTE=0", "Asia/Kuala_Lumpur"))
.toEqual({ ok: true });
});
it("rejects a rule firing every minute", () => {
const result = validateMinInterval("FREQ=MINUTELY", "Asia/Kuala_Lumpur");
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.reason).toMatch(/minimum interval/i);
}
});
});
describe("MIN_INTERVAL_MS", () => {
it("equals 5 minutes", () => {
expect(MIN_INTERVAL_MS).toBe(5 * 60 * 1000);
});
});
describe("cron prefix detection", () => {
it("CRON_PREFIX is 'CRON:'", () => {
expect(CRON_PREFIX).toBe("CRON:");
});
it("isCronRule recognises the prefix", () => {
expect(isCronRule("CRON:0 9 * * *")).toBe(true);
expect(isCronRule("FREQ=DAILY")).toBe(false);
expect(isCronRule("")).toBe(false);
});
it("stripCronPrefix removes the prefix when present", () => {
expect(stripCronPrefix("CRON:0 9 * * *")).toBe("0 9 * * *");
// Idempotent for non-cron rules.
expect(stripCronPrefix("FREQ=DAILY")).toBe("FREQ=DAILY");
});
});
describe("nextOccurrence with cron rules", () => {
it("dispatches CRON: rules to cron-parser and returns the next match", () => {
// 9:00 every weekday in Asia/Kuala_Lumpur.
const after = new Date("2026-05-09T08:00:00Z"); // Sat
const next = nextOccurrence("CRON:0 9 * * 1-5", "Asia/Kuala_Lumpur", after);
expect(next).toBeInstanceOf(Date);
// Next weekday at 9 AM KL is Mon 2026-05-11 09:00 KL → 01:00 UTC.
expect(next!.toISOString()).toBe("2026-05-11T01:00:00.000Z");
});
it("multi-line CRON rules return the EARLIEST next match across all lines", () => {
// Two schedules joined: every Monday 9 AM KL + every Friday 5 PM KL.
// Starting Saturday 2026-05-09 — the next match should be the Monday.
const rule = "CRON:0 9 * * 1\n0 17 * * 5";
const next = nextOccurrence(rule, "Asia/Kuala_Lumpur", new Date("2026-05-09T08:00:00Z"));
expect(next!.toISOString()).toBe("2026-05-11T01:00:00.000Z"); // Mon 09:00 KL
// Now ask after Tuesday — Friday 5 PM KL should win.
const nextFri = nextOccurrence(
rule,
"Asia/Kuala_Lumpur",
new Date("2026-05-12T08:00:00Z"),
);
expect(nextFri!.toISOString()).toBe("2026-05-15T09:00:00.000Z"); // Fri 17:00 KL = 09:00 UTC
});
it("multi-line CRON rules ignore malformed lines and use the rest", () => {
const rule = "CRON:not a cron\n0 9 * * 1-5";
const next = nextOccurrence(
rule,
"Asia/Kuala_Lumpur",
new Date("2026-05-09T08:00:00Z"),
);
expect(next).not.toBeNull();
});
it("still handles RRULE rules unchanged", () => {
const next = nextOccurrence(
"FREQ=DAILY;BYHOUR=9;BYMINUTE=0",
"Asia/Kuala_Lumpur",
new Date("2026-05-03T08:00:00Z"),
);
expect(next).toBeInstanceOf(Date);
});
});
describe("validateMinInterval with cron rules", () => {
it("accepts an hourly cron (interval > 5 min)", () => {
expect(validateMinInterval("CRON:0 * * * *", "Asia/Kuala_Lumpur")).toEqual({ ok: true });
});
it("rejects a cron firing every minute", () => {
const r = validateMinInterval("CRON:* * * * *", "Asia/Kuala_Lumpur");
expect(r.ok).toBe(false);
if (!r.ok) expect(r.reason).toMatch(/minimum interval|fires every/i);
});
it("rejects a malformed cron string with a useful message", () => {
const r = validateMinInterval("CRON:not a cron", "Asia/Kuala_Lumpur");
expect(r.ok).toBe(false);
if (!r.ok) expect(r.reason.toLowerCase()).toContain("invalid cron");
});
it("validates every line of a multi-line cron rule", () => {
expect(
validateMinInterval("CRON:0 9 * * 1\n0 17 * * 5", "Asia/Kuala_Lumpur"),
).toEqual({ ok: true });
// Any single line firing too often fails the whole rule.
const r = validateMinInterval("CRON:0 9 * * 1\n* * * * *", "Asia/Kuala_Lumpur");
expect(r.ok).toBe(false);
});
});
describe("validateCronExpression", () => {
it("returns null for a valid cron expression", () => {
expect(validateCronExpression("0 9 * * 1-5", "Asia/Kuala_Lumpur")).toBe(null);
});
it("returns an error string for malformed input", () => {
const err = validateCronExpression("not a cron", "Asia/Kuala_Lumpur");
expect(typeof err).toBe("string");
});
});