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>
360 lines
11 KiB
TypeScript
360 lines
11 KiB
TypeScript
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",
|
||
},
|
||
];
|
||
}
|