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

138 lines
4.9 KiB
TypeScript

// rrule@2.8.1 has no "exports" field and ships ESM that some bundlers can't
// resolve via either default OR named imports. Use createRequire to bridge
// to the CJS entry — works under NodeNext at runtime and Turbopack at build.
import { createRequire } from "node:module";
import type { RRule as RRuleType } from "rrule";
import { DateTime } from "luxon";
const require = createRequire(import.meta.url);
const rrulePkg = require("rrule") as typeof import("rrule");
const { RRule, rrulestr } = rrulePkg;
export const MIN_INTERVAL_MS = 5 * 60 * 1000;
/**
* Sentinel prefix marking a cron expression stored in the same column
* as RRULE strings. e.g. "CRON:0 9 * * 1-5" → 09:00 every weekday.
*
* Cron is more expressive than our subset of RRULE (true sec/min/
* hour/day/month/year combinational scheduling) so we let the user
* supply one directly. The bot dispatches on this prefix.
*/
export const CRON_PREFIX = "CRON:";
export function isCronRule(rule: string): boolean {
return rule.startsWith(CRON_PREFIX);
}
export function stripCronPrefix(rule: string): string {
return rule.startsWith(CRON_PREFIX) ? rule.slice(CRON_PREFIX.length) : rule;
}
export function parseRRule(rule: string): RRuleType {
const parsed = rrulestr(rule);
if (!(parsed instanceof RRule)) {
throw new Error("Compound RRULE/RRSET not supported");
}
return parsed;
}
export function nextOccurrence(rule: string, timezone: string, after: Date): Date | null {
if (isCronRule(rule)) {
// Lazy require keeps cron-parser out of the import graph for callers
// that never use cron rules.
const { CronExpressionParser } = require("cron-parser") as typeof import("cron-parser");
// The picker can store multiple cron expressions joined by newlines
// ("every Monday at 09:00\nevery Friday at 17:00"). We compute the
// next match for each and return the earliest — that's the very
// next time *any* of the schedules fires.
const lines = stripCronPrefix(rule)
.split("\n")
.map((s) => s.trim())
.filter(Boolean);
if (lines.length === 0) return null;
let earliest: Date | null = null;
for (const expr of lines) {
try {
const it = CronExpressionParser.parse(expr, {
currentDate: after,
tz: timezone,
});
const next = it.next().toDate();
if (!earliest || next < earliest) earliest = next;
} catch {
// Skip the malformed line; if all lines are bad the function
// returns null below.
}
}
return earliest;
}
const parsed = parseRRule(rule);
const afterInZone = DateTime.fromJSDate(after).setZone(timezone).toJSDate();
const next = parsed.after(afterInZone, false);
return next ?? null;
}
export type IntervalCheck = { ok: true } | { ok: false; reason: string };
export function validateMinInterval(rule: string, timezone: string): IntervalCheck {
if (isCronRule(rule)) {
const { CronExpressionParser } = require("cron-parser") as typeof import("cron-parser");
// Validate every line independently — a multi-rule schedule fires
// as the union, so any single rule firing too often is enough to
// breach the minimum interval.
const lines = stripCronPrefix(rule)
.split("\n")
.map((s) => s.trim())
.filter(Boolean);
if (lines.length === 0) {
return { ok: false, reason: "Empty cron rule" };
}
for (const expr of lines) {
try {
const it = CronExpressionParser.parse(expr, {
currentDate: new Date(),
tz: timezone,
});
const first = it.next().toDate();
const second = it.next().toDate();
const gap = second.getTime() - first.getTime();
if (gap < MIN_INTERVAL_MS) {
return {
ok: false,
reason: `Cron fires every ${Math.round(gap / 1000)}s; minimum interval is ${MIN_INTERVAL_MS / 1000}s.`,
};
}
} catch (err) {
return { ok: false, reason: `Invalid cron expression: ${(err as Error).message}` };
}
}
return { ok: true };
}
const parsed = parseRRule(rule);
const now = new Date();
const first = parsed.after(now, false);
if (!first) return { ok: true };
const second = parsed.after(first, false);
if (!second) return { ok: true };
const gap = second.getTime() - first.getTime();
if (gap < MIN_INTERVAL_MS) {
return {
ok: false,
reason: `Recurrence fires every ${Math.round(gap / 1000)}s; minimum interval is ${MIN_INTERVAL_MS / 1000}s.`,
};
}
return { ok: true };
}
/** Validate a cron expression — returns null on success, error message on failure. */
export function validateCronExpression(expr: string, timezone: string): string | null {
const { CronExpressionParser } = require("cron-parser") as typeof import("cron-parser");
try {
CronExpressionParser.parse(expr, { tz: timezone });
return null;
} catch (err) {
return (err as Error).message;
}
}