yiekheng 5f1897daa5 feat(recurrence): full crontab support — sec/min/hour/day/month/dow combinational
The custom RRULE panel covers most patterns but can't express "every
weekday at 9, 12, and 18" or "every 15 minutes within working hours"
in a single rule. RRULE's BYxxx fields can technically combine, but
the picker UX gets unwieldy fast. Cron expressions cover everything
in one line.

Storage / dispatch
- Cron rules live in the same `reminders.rrule` column with a sentinel
  prefix: `CRON:0 9 * * 1-5`. No schema change.
- `@cmbot/shared` now exports:
    CRON_PREFIX, isCronRule, stripCronPrefix, validateCronExpression
- `nextOccurrence(rule, tz, after)` and `validateMinInterval(rule, tz)`
  detect the prefix and dispatch to `cron-parser`; non-cron rules
  continue to flow through rrule unchanged.
- `cron-parser@^5.5.0` added as an explicit dep on @cmbot/shared
  (it was already transitively present via pg-boss).

Server actions
- `createReminderAction` / `updateReminderAction`: when rrule has the
  CRON: prefix, the user's date+time inputs are ignored — the action
  validates the cron, runs the min-interval check (5 min between
  fires), and computes scheduledAt as the next match of the cron
  expression after now. The bot's existing fire-reminder loop
  re-arms via `nextOccurrence` after each fire, which already speaks
  cron via the dispatch above.

Picker
- New "Cron expression…" preset at the bottom of the radio list:
    "Full sec/min/hour/day/month/dow combinational power"
  Selecting it reveals a CronPanel:
    * font-mono cron input (5- or 6-field accepted)
    * inline examples: 0 9 * * 1-5, */15 * * * *, 0 9,12,18 * * *,
      0 0 1 * *
    * note that the Date+Time controls above are ignored once a cron
      expression is set
- RecurrenceSpec gains an optional `cron` string and a new `kind: "cron"`.
- buildRrule emits `CRON:<expr>` for cron specs.
- specFromRrule round-trips a CRON-prefixed rule back into the spec.
- describeRecurrence renders "Cron: <expr>" so the list view and
  review steps show the expression.

Tests (+10; 17 shared + 26 bot + 138 web = 181 total)
- packages/shared rrule.test.ts (+8):
  * CRON_PREFIX / isCronRule / stripCronPrefix
  * nextOccurrence on a CRON rule returns the right next match in the
    operator timezone (e.g. weekday 9 AM KL ↔ exact UTC instant)
  * RRULE rules still flow through unchanged
  * validateMinInterval on cron: hourly OK, every-minute rejected,
    malformed string returns a useful error
  * validateCronExpression positive + negative cases
- recurrence.test.ts (+5): cron preset round-trip, label assertions,
  `buildRrule`/`specFromRrule` for cron specs.

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

360 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { DateTime } from "luxon";
export type RecurrenceKind = "none" | "daily" | "weekly" | "monthly" | "yearly" | "cron";
export type EndKind = "never" | "after" | "on";
const CRON_PREFIX = "CRON:";
const WEEKDAY_CODES = ["MO", "TU", "WE", "TH", "FR", "SA", "SU"] as const;
export const WEEKDAY_LABELS: Array<{ iso: number; code: string; short: string; long: string }> = [
{ iso: 1, code: "MO", short: "Mon", long: "Monday" },
{ iso: 2, code: "TU", short: "Tue", long: "Tuesday" },
{ iso: 3, code: "WE", short: "Wed", long: "Wednesday" },
{ iso: 4, code: "TH", short: "Thu", long: "Thursday" },
{ iso: 5, code: "FR", short: "Fri", long: "Friday" },
{ iso: 6, code: "SA", short: "Sat", long: "Saturday" },
{ iso: 7, code: "SU", short: "Sun", long: "Sunday" },
];
export interface RecurrenceSpec {
kind: RecurrenceKind;
/** Every N units. Defaults to 1. Ignored for `none` and `cron`. */
interval: number;
/** ISO weekday numbers (1=Mon..7=Sun). Used for `weekly`. */
weeklyDays: number[];
/** Day-of-month for `monthly` (1-31). If omitted, falls back to firstFire.day. */
monthDay?: number;
/** Cron expression — only meaningful when kind === "cron". */
cron?: string;
/** End condition. */
end:
| { kind: "never" }
| { kind: "after"; count: number }
| { kind: "on"; until: string /* ISO date YYYY-MM-DD */ };
}
export const DEFAULT_RECURRENCE: RecurrenceSpec = {
kind: "none",
interval: 1,
weeklyDays: [],
end: { kind: "never" },
};
function clampInterval(n: number): number {
if (!Number.isFinite(n) || n < 1) return 1;
return Math.floor(n);
}
/**
* Build an RRULE string. Supports interval, weekday list, monthday, and the
* end condition (COUNT or UNTIL). Returns null for one-off reminders.
*/
export function buildRrule(spec: RecurrenceSpec, firstFire: DateTime): string | null {
if (spec.kind === "none") return null;
if (spec.kind === "cron") {
return spec.cron ? `${CRON_PREFIX}${spec.cron.trim()}` : null;
}
const parts: string[] = [];
switch (spec.kind) {
case "daily":
parts.push("FREQ=DAILY");
break;
case "weekly": {
parts.push("FREQ=WEEKLY");
const days =
spec.weeklyDays.length > 0
? spec.weeklyDays
: [firstFire.weekday];
const codes = days
.slice()
.sort((a, b) => a - b)
.map((d) => WEEKDAY_CODES[d - 1])
.filter(Boolean);
parts.push(`BYDAY=${codes.join(",")}`);
break;
}
case "monthly":
parts.push("FREQ=MONTHLY");
parts.push(`BYMONTHDAY=${spec.monthDay ?? firstFire.day}`);
break;
case "yearly":
parts.push("FREQ=YEARLY");
parts.push(`BYMONTH=${firstFire.month}`);
parts.push(`BYMONTHDAY=${firstFire.day}`);
break;
}
const interval = clampInterval(spec.interval);
if (interval !== 1) parts.push(`INTERVAL=${interval}`);
if (spec.end.kind === "after" && spec.end.count > 0) {
parts.push(`COUNT=${Math.floor(spec.end.count)}`);
} else if (spec.end.kind === "on" && spec.end.until) {
// RRULE UNTIL is a UTC timestamp. Translate the user's "on this date"
// into 23:59:59 UTC of that day so the last occurrence is included.
const dt = DateTime.fromISO(`${spec.end.until}T23:59:59`, { zone: "utc" });
if (dt.isValid) {
parts.push(`UNTIL=${dt.toFormat("yyyyMMdd'T'HHmmss'Z'")}`);
}
}
return parts.join(";");
}
const FREQ_UNIT: Record<string, string> = {
daily: "day",
weekly: "week",
monthly: "month",
yearly: "year",
};
/**
* Render the spec as a human sentence, e.g.
* "Every day"
* "Every 2 weeks on Mon, Wed, Fri"
* "Every month on day 14, 12 times"
* "Every year on May 13, until 2027-05-13"
*/
export function describeRecurrence(spec: RecurrenceSpec, firstFire: DateTime): string {
if (spec.kind === "none") return "One-off";
if (spec.kind === "cron") {
return spec.cron ? `Cron: ${spec.cron}` : "Cron (not configured)";
}
const interval = clampInterval(spec.interval);
const unit = FREQ_UNIT[spec.kind]!;
const head =
interval === 1 ? `Every ${unit}` : `Every ${interval} ${unit}s`;
let body = "";
if (spec.kind === "weekly") {
const days = spec.weeklyDays.length > 0 ? spec.weeklyDays : [firstFire.weekday];
const labels = days
.slice()
.sort((a, b) => a - b)
.map((d) => WEEKDAY_LABELS[d - 1]?.short)
.filter(Boolean)
.join(", ");
body = ` on ${labels}`;
} else if (spec.kind === "monthly") {
body = ` on day ${spec.monthDay ?? firstFire.day}`;
} else if (spec.kind === "yearly") {
body = ` on ${firstFire.toFormat("MMM d")}`;
}
let tail = "";
if (spec.end.kind === "after" && spec.end.count > 0) {
tail = `, ${Math.floor(spec.end.count)} time${spec.end.count === 1 ? "" : "s"}`;
} else if (spec.end.kind === "on" && spec.end.until) {
tail = `, until ${spec.end.until}`;
}
return head + body + tail;
}
/** Parse a stored RRULE back into a spec for resuming the wizard / editing. */
export function specFromRrule(rrule: string | null | undefined): RecurrenceSpec {
if (!rrule) return { ...DEFAULT_RECURRENCE };
if (rrule.startsWith(CRON_PREFIX)) {
return {
kind: "cron",
interval: 1,
weeklyDays: [],
cron: rrule.slice(CRON_PREFIX.length),
end: { kind: "never" },
};
}
const tokens = rrule
.split(";")
.map((t) => t.trim())
.filter(Boolean)
.reduce<Record<string, string>>((acc, t) => {
const [k, v] = t.split("=");
if (k && v !== undefined) acc[k.toUpperCase()] = v;
return acc;
}, {});
const freq = (tokens.FREQ ?? "").toUpperCase();
let kind: RecurrenceKind = "none";
if (freq === "DAILY") kind = "daily";
else if (freq === "WEEKLY") kind = "weekly";
else if (freq === "MONTHLY") kind = "monthly";
else if (freq === "YEARLY") kind = "yearly";
const interval = tokens.INTERVAL ? clampInterval(Number(tokens.INTERVAL)) : 1;
const weeklyDays: number[] = [];
if (tokens.BYDAY) {
for (const code of tokens.BYDAY.split(",")) {
const idx = WEEKDAY_CODES.indexOf(code.toUpperCase() as (typeof WEEKDAY_CODES)[number]);
if (idx >= 0) weeklyDays.push(idx + 1);
}
}
const monthDay = tokens.BYMONTHDAY ? Number(tokens.BYMONTHDAY) || undefined : undefined;
let end: RecurrenceSpec["end"] = { kind: "never" };
if (tokens.COUNT) {
const n = Number(tokens.COUNT);
if (Number.isFinite(n) && n > 0) end = { kind: "after", count: Math.floor(n) };
} else if (tokens.UNTIL) {
// UNTIL is `YYYYMMDDTHHMMSSZ` per RFC. Pull the date.
const m = tokens.UNTIL.match(/^(\d{4})(\d{2})(\d{2})/);
if (m) end = { kind: "on", until: `${m[1]}-${m[2]}-${m[3]}` };
}
return { kind, interval, weeklyDays, monthDay, end };
}
/** Backwards-compatible helper for callers that only need the kind. */
export function kindFromRrule(rrule: string | null | undefined): RecurrenceKind {
return specFromRrule(rrule).kind;
}
// ---------------------------------------------------------------------------
// Preset shortcuts for the Repeats picker
// ---------------------------------------------------------------------------
export type PresetId =
| "none"
| "daily"
| "weekdays"
| "weekends"
| "weekly_same"
| "monthly_same"
| "yearly_same"
| "custom"
| "cron";
export interface PresetDescriptor {
id: PresetId;
/** Short label shown in the radio list. */
label: string;
/** Optional one-line hint shown beneath the label. */
hint?: string;
}
/**
* Build the canonical RecurrenceSpec for a preset given the first-fire
* DateTime (the spec depends on the chosen first fire — e.g. "every
* week on the same weekday" means whatever weekday firstFire lands on).
*/
export function presetToSpec(id: PresetId, firstFire: DateTime): RecurrenceSpec {
const base: RecurrenceSpec = {
kind: "none",
interval: 1,
weeklyDays: [],
end: { kind: "never" },
};
switch (id) {
case "none":
return base;
case "daily":
return { ...base, kind: "daily" };
case "weekdays":
return { ...base, kind: "weekly", weeklyDays: [1, 2, 3, 4, 5] };
case "weekends":
return { ...base, kind: "weekly", weeklyDays: [6, 7] };
case "weekly_same":
return { ...base, kind: "weekly", weeklyDays: [firstFire.weekday] };
case "monthly_same":
return { ...base, kind: "monthly", monthDay: firstFire.day };
case "yearly_same":
return { ...base, kind: "yearly" };
case "custom":
// Custom is only meaningful in the picker UI — when the user
// explicitly opts into it, callers should preserve whatever
// detailed spec the user already had. Return a sensible weekly
// default if the caller forgets to pass through.
return { ...base, kind: "weekly", weeklyDays: [firstFire.weekday] };
case "cron":
// Default cron expression: every day at the first-fire's HH:MM.
return {
...base,
kind: "cron",
cron: `${firstFire.minute} ${firstFire.hour} * * *`,
};
}
}
/**
* Reverse mapping: which preset (if any) does this spec match?
*
* Returns "custom" for anything that doesn't match a known shortcut —
* the picker uses that to flip into expanded-detail mode.
*/
export function matchPreset(spec: RecurrenceSpec, firstFire: DateTime): PresetId {
if (spec.kind === "none") return "none";
if (spec.kind === "cron") return "cron";
const sameInterval = spec.interval === 1;
const noEnd = spec.end.kind === "never";
if (!sameInterval || !noEnd) return "custom";
const sortedWeeklyDays = (days: number[]) => days.slice().sort((a, b) => a - b).join(",");
switch (spec.kind) {
case "daily":
return "daily";
case "weekly": {
const days = spec.weeklyDays.length === 0 ? [firstFire.weekday] : spec.weeklyDays;
const key = sortedWeeklyDays(days);
if (key === "1,2,3,4,5") return "weekdays";
if (key === "6,7") return "weekends";
if (key === String(firstFire.weekday)) return "weekly_same";
return "custom";
}
case "monthly":
if ((spec.monthDay ?? firstFire.day) === firstFire.day) return "monthly_same";
return "custom";
case "yearly":
return "yearly_same";
case "cron":
return "cron";
case "none":
return "none";
}
}
/**
* Render the (firstFire-aware) labels and hints for the radio list.
* The hint shows the concrete weekday/day-of-month/date the preset
* would imply given the user's chosen first fire.
*/
export function presetDescriptors(firstFire: DateTime): PresetDescriptor[] {
const dayShort = WEEKDAY_LABELS[firstFire.weekday - 1]?.short ?? "";
return [
{ id: "none", label: "Don't repeat", hint: "Fires once and ends" },
{ id: "daily", label: "Every day" },
{ id: "weekdays", label: "Every weekday", hint: "Mon Fri" },
{ id: "weekends", label: "Every weekend", hint: "Sat Sun" },
{
id: "weekly_same",
label: `Every week on ${dayShort}`,
hint: "Same weekday as the start date",
},
{
id: "monthly_same",
label: `Every month on day ${firstFire.day}`,
hint: "Months without this day skip naturally (e.g. 31st)",
},
{
id: "yearly_same",
label: `Every year on ${firstFire.toFormat("MMM d")}`,
},
{
id: "custom",
label: "Custom…",
hint: "Set interval, days, and end conditions yourself",
},
{
id: "cron",
label: "Cron expression…",
hint: "Full sec/min/hour/day/month/dow combinational power",
},
];
}