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>
138 lines
4.9 KiB
TypeScript
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;
|
|
}
|
|
}
|