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>
157 lines
5.4 KiB
TypeScript
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");
|
|
});
|
|
});
|