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>
This commit is contained in:
parent
991ff5fb22
commit
5f1897daa5
@ -7,7 +7,7 @@ import { eq } from "drizzle-orm";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { DateTime } from "luxon";
|
import { DateTime } from "luxon";
|
||||||
import { reminders, reminderTargets, reminderMessages } from "@cmbot/db";
|
import { reminders, reminderTargets, reminderMessages } from "@cmbot/db";
|
||||||
import { DEFAULT_TIMEZONE } from "@cmbot/shared";
|
import { DEFAULT_TIMEZONE, isCronRule, nextOccurrence, validateMinInterval } from "@cmbot/shared";
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import { getSeededOperator } from "@/lib/operator";
|
import { getSeededOperator } from "@/lib/operator";
|
||||||
import { checkRateLimit } from "@/lib/rate-limit";
|
import { checkRateLimit } from "@/lib/rate-limit";
|
||||||
@ -166,12 +166,26 @@ export async function createReminderAction(
|
|||||||
});
|
});
|
||||||
if (!account) return { ok: false, error: "Account not yours" };
|
if (!account) return { ok: false, error: "Account not yours" };
|
||||||
|
|
||||||
const scheduledAt = DateTime.fromISO(scheduledAtIso, { zone: timezone }).toJSDate();
|
// Resolve the first-fire timestamp. Cron rules ignore the user-
|
||||||
if (Number.isNaN(scheduledAt.getTime())) {
|
// supplied date+time (the form sends a placeholder) and let the cron
|
||||||
return { ok: false, error: "Invalid date" };
|
// expression define when the reminder runs first.
|
||||||
}
|
let scheduledAt: Date;
|
||||||
if (scheduledAt.getTime() <= Date.now()) {
|
if (rrule && isCronRule(rrule)) {
|
||||||
return { ok: false, error: "Time is in the past" };
|
const minCheck = validateMinInterval(rrule, timezone);
|
||||||
|
if (!minCheck.ok) return { ok: false, error: minCheck.reason };
|
||||||
|
const firstFire = nextOccurrence(rrule, timezone, new Date());
|
||||||
|
if (!firstFire) {
|
||||||
|
return { ok: false, error: "Cron expression doesn't produce any future fire times" };
|
||||||
|
}
|
||||||
|
scheduledAt = firstFire;
|
||||||
|
} else {
|
||||||
|
scheduledAt = DateTime.fromISO(scheduledAtIso, { zone: timezone }).toJSDate();
|
||||||
|
if (Number.isNaN(scheduledAt.getTime())) {
|
||||||
|
return { ok: false, error: "Invalid date" };
|
||||||
|
}
|
||||||
|
if (scheduledAt.getTime() <= Date.now()) {
|
||||||
|
return { ok: false, error: "Time is in the past" };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify all groups belong to this account
|
// Verify all groups belong to this account
|
||||||
@ -278,12 +292,23 @@ export async function updateReminderAction(
|
|||||||
});
|
});
|
||||||
if (!targetAccount) return { ok: false, error: "Account not yours" };
|
if (!targetAccount) return { ok: false, error: "Account not yours" };
|
||||||
|
|
||||||
const scheduledAt = DateTime.fromISO(scheduledAtIso, { zone: timezone }).toJSDate();
|
let scheduledAt: Date;
|
||||||
if (Number.isNaN(scheduledAt.getTime())) {
|
if (rrule && isCronRule(rrule)) {
|
||||||
return { ok: false, error: "Invalid date" };
|
const minCheck = validateMinInterval(rrule, timezone);
|
||||||
}
|
if (!minCheck.ok) return { ok: false, error: minCheck.reason };
|
||||||
if (scheduledAt.getTime() <= Date.now()) {
|
const firstFire = nextOccurrence(rrule, timezone, new Date());
|
||||||
return { ok: false, error: "Time is in the past" };
|
if (!firstFire) {
|
||||||
|
return { ok: false, error: "Cron expression doesn't produce any future fire times" };
|
||||||
|
}
|
||||||
|
scheduledAt = firstFire;
|
||||||
|
} else {
|
||||||
|
scheduledAt = DateTime.fromISO(scheduledAtIso, { zone: timezone }).toJSDate();
|
||||||
|
if (Number.isNaN(scheduledAt.getTime())) {
|
||||||
|
return { ok: false, error: "Invalid date" };
|
||||||
|
}
|
||||||
|
if (scheduledAt.getTime() <= Date.now()) {
|
||||||
|
return { ok: false, error: "Time is in the past" };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const groups = await db.query.whatsappGroups.findMany({
|
const groups = await db.query.whatsappGroups.findMany({
|
||||||
|
|||||||
@ -73,6 +73,12 @@ export function RecurrencePicker({ firstFire, value, onChange }: RecurrencePicke
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setForceCustomOpen(false);
|
setForceCustomOpen(false);
|
||||||
|
if (id === "cron") {
|
||||||
|
// Preserve any cron expression the user already typed.
|
||||||
|
if (value.kind === "cron") return;
|
||||||
|
onChange(presetToSpec("cron", firstFire));
|
||||||
|
return;
|
||||||
|
}
|
||||||
onChange(presetToSpec(id, firstFire));
|
onChange(presetToSpec(id, firstFire));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -139,11 +145,55 @@ export function RecurrencePicker({ firstFire, value, onChange }: RecurrencePicke
|
|||||||
{customExpanded && (
|
{customExpanded && (
|
||||||
<CustomPanel firstFire={firstFire} value={value} onChange={onChange} />
|
<CustomPanel firstFire={firstFire} value={value} onChange={onChange} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{activePreset === "cron" && (
|
||||||
|
<CronPanel value={value} onChange={onChange} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function CronPanel({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
value: RecurrenceSpec;
|
||||||
|
onChange: (next: RecurrenceSpec) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-2 border-t border-border bg-muted/20 p-4">
|
||||||
|
<Label htmlFor="cron-expr" className="text-xs text-muted-foreground">
|
||||||
|
Cron expression
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="cron-expr"
|
||||||
|
value={value.cron ?? ""}
|
||||||
|
onChange={(e) => onChange({ ...value, cron: e.target.value })}
|
||||||
|
placeholder="0 9 * * 1-5"
|
||||||
|
className="h-8 font-mono text-sm"
|
||||||
|
spellCheck={false}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||||
|
Standard 5-field cron (<code className="font-mono">m h dom mon dow</code>) or
|
||||||
|
6-field with seconds (<code className="font-mono">s m h dom mon dow</code>).
|
||||||
|
Examples:
|
||||||
|
</p>
|
||||||
|
<ul className="text-xs text-muted-foreground font-mono space-y-0.5 pl-3">
|
||||||
|
<li><span className="text-foreground">0 9 * * 1-5</span> — 9 am on weekdays</li>
|
||||||
|
<li><span className="text-foreground">*/15 * * * *</span> — every 15 minutes</li>
|
||||||
|
<li><span className="text-foreground">0 9,12,18 * * *</span> — 9, 12, and 18 every day</li>
|
||||||
|
<li><span className="text-foreground">0 0 1 * *</span> — midnight on the 1st of every month</li>
|
||||||
|
</ul>
|
||||||
|
<p className="text-xs text-muted-foreground italic">
|
||||||
|
The Date+Time controls above are ignored when a cron expression is set;
|
||||||
|
cron drives the schedule entirely. The first fire is the next time the
|
||||||
|
cron expression matches after now.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
interface CustomPanelProps {
|
interface CustomPanelProps {
|
||||||
firstFire: DateTime;
|
firstFire: DateTime;
|
||||||
value: RecurrenceSpec;
|
value: RecurrenceSpec;
|
||||||
|
|||||||
@ -63,22 +63,36 @@ export function EditWhenForm({
|
|||||||
})();
|
})();
|
||||||
|
|
||||||
async function handleSave() {
|
async function handleSave() {
|
||||||
const v = validateScheduledAt(date, time, timezone, Date.now());
|
let scheduledAtIso: string;
|
||||||
if (!v.ok) {
|
let rrule: string | null;
|
||||||
const map = {
|
|
||||||
missing: "Pick both a date and a time.",
|
if (spec.kind === "cron") {
|
||||||
invalid: "Invalid date or time.",
|
if (!spec.cron || !spec.cron.trim()) {
|
||||||
past: "The first occurrence is in the past. Pick a future date and time.",
|
setError("Enter a cron expression.");
|
||||||
} as const;
|
return;
|
||||||
setError(map[v.reason]);
|
}
|
||||||
return;
|
// Server overrides scheduledAt with the cron's next match. Send
|
||||||
|
// a stub timestamp so the action's Zod schema is happy.
|
||||||
|
scheduledAtIso = DateTime.now().plus({ minutes: 1 }).toISO()!;
|
||||||
|
rrule = buildRrule(spec, previewDt);
|
||||||
|
} else {
|
||||||
|
const v = validateScheduledAt(date, time, timezone, Date.now());
|
||||||
|
if (!v.ok) {
|
||||||
|
const map = {
|
||||||
|
missing: "Pick both a date and a time.",
|
||||||
|
invalid: "Invalid date or time.",
|
||||||
|
past: "The first occurrence is in the past. Pick a future date and time.",
|
||||||
|
} as const;
|
||||||
|
setError(map[v.reason]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (spec.end.kind === "on" && !spec.end.until) {
|
||||||
|
setError("Pick the end date for this recurrence.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
scheduledAtIso = v.dt.toISO()!;
|
||||||
|
rrule = buildRrule(spec, v.dt);
|
||||||
}
|
}
|
||||||
if (spec.end.kind === "on" && !spec.end.until) {
|
|
||||||
setError("Pick the end date for this recurrence.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const dt = v.dt;
|
|
||||||
const rrule = buildRrule(spec, dt);
|
|
||||||
|
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
@ -90,7 +104,7 @@ export function EditWhenForm({
|
|||||||
text,
|
text,
|
||||||
mediaId,
|
mediaId,
|
||||||
caption,
|
caption,
|
||||||
scheduledAtIso: dt.toISO()!,
|
scheduledAtIso,
|
||||||
rrule,
|
rrule,
|
||||||
timezone,
|
timezone,
|
||||||
});
|
});
|
||||||
|
|||||||
@ -59,6 +59,28 @@ export function WhenFormClient({
|
|||||||
})();
|
})();
|
||||||
|
|
||||||
function handleContinue() {
|
function handleContinue() {
|
||||||
|
// Cron mode: the cron expression defines its own first fire on the
|
||||||
|
// server side. The Date/Time inputs are ignored — pass a stub
|
||||||
|
// scheduledAt that the server will overwrite.
|
||||||
|
if (spec.kind === "cron") {
|
||||||
|
if (!spec.cron || !spec.cron.trim()) {
|
||||||
|
setError("Enter a cron expression.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const rrule = buildRrule(spec, previewDt);
|
||||||
|
const scheduledAt = DateTime.now().plus({ minutes: 1 }).toISO()!;
|
||||||
|
const sp = new URLSearchParams({ step: "4", accountId, scheduledAt });
|
||||||
|
if (groupIds) sp.set("groupIds", groupIds);
|
||||||
|
if (rrule) sp.set("rrule", rrule);
|
||||||
|
if (passThroughParams.text) sp.set("text", passThroughParams.text);
|
||||||
|
if (passThroughParams.mediaId) sp.set("mediaId", passThroughParams.mediaId);
|
||||||
|
if (passThroughParams.caption) sp.set("caption", passThroughParams.caption);
|
||||||
|
if (passThroughParams.editReminderId) sp.set("editReminderId", passThroughParams.editReminderId);
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
router.push(`/reminders/new?${sp.toString()}` as any);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const v = validateScheduledAt(date, time, timezone, Date.now());
|
const v = validateScheduledAt(date, time, timezone, Date.now());
|
||||||
if (!v.ok) {
|
if (!v.ok) {
|
||||||
const map = {
|
const map = {
|
||||||
|
|||||||
@ -225,7 +225,7 @@ describe("preset shortcuts (Repeats picker)", () => {
|
|||||||
).toBe("custom");
|
).toBe("custom");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("presetDescriptors returns 8 entries with first-fire-aware labels", () => {
|
it("presetDescriptors returns the full preset list with first-fire-aware labels", () => {
|
||||||
const items = presetDescriptors(FIRST);
|
const items = presetDescriptors(FIRST);
|
||||||
expect(items.map((d) => d.id)).toEqual([
|
expect(items.map((d) => d.id)).toEqual([
|
||||||
"none",
|
"none",
|
||||||
@ -236,6 +236,7 @@ describe("preset shortcuts (Repeats picker)", () => {
|
|||||||
"monthly_same",
|
"monthly_same",
|
||||||
"yearly_same",
|
"yearly_same",
|
||||||
"custom",
|
"custom",
|
||||||
|
"cron",
|
||||||
]);
|
]);
|
||||||
// Labels should be parameterised by firstFire.
|
// Labels should be parameterised by firstFire.
|
||||||
expect(items.find((d) => d.id === "weekly_same")?.label).toBe("Every week on Wed");
|
expect(items.find((d) => d.id === "weekly_same")?.label).toBe("Every week on Wed");
|
||||||
@ -245,6 +246,39 @@ describe("preset shortcuts (Repeats picker)", () => {
|
|||||||
expect(items.find((d) => d.id === "yearly_same")?.label).toBe(
|
expect(items.find((d) => d.id === "yearly_same")?.label).toBe(
|
||||||
"Every year on May 13",
|
"Every year on May 13",
|
||||||
);
|
);
|
||||||
|
expect(items.find((d) => d.id === "cron")?.label).toBe("Cron expression…");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("presetToSpec('cron') seeds a daily-at-the-first-fire cron", () => {
|
||||||
|
const spec = presetToSpec("cron", FIRST);
|
||||||
|
expect(spec.kind).toBe("cron");
|
||||||
|
// FIRST is 09:00, so default cron = "0 9 * * *" (every day at 9:00).
|
||||||
|
expect(spec.cron).toBe("0 9 * * *");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("matchPreset returns 'cron' for any cron-kind spec", () => {
|
||||||
|
expect(
|
||||||
|
matchPreset(
|
||||||
|
{ kind: "cron", interval: 1, weeklyDays: [], cron: "0 9 * * 1-5", end: { kind: "never" } },
|
||||||
|
FIRST,
|
||||||
|
),
|
||||||
|
).toBe("cron");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("buildRrule produces a CRON: prefixed string for cron specs", () => {
|
||||||
|
expect(
|
||||||
|
buildRrule(
|
||||||
|
{ kind: "cron", interval: 1, weeklyDays: [], cron: "0 9 * * 1-5", end: { kind: "never" } },
|
||||||
|
FIRST,
|
||||||
|
),
|
||||||
|
).toBe("CRON:0 9 * * 1-5");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("specFromRrule round-trips a CRON: prefixed rule", () => {
|
||||||
|
expect(specFromRrule("CRON:*/15 * * * *")).toMatchObject({
|
||||||
|
kind: "cron",
|
||||||
|
cron: "*/15 * * * *",
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -1,8 +1,10 @@
|
|||||||
import { DateTime } from "luxon";
|
import { DateTime } from "luxon";
|
||||||
|
|
||||||
export type RecurrenceKind = "none" | "daily" | "weekly" | "monthly" | "yearly";
|
export type RecurrenceKind = "none" | "daily" | "weekly" | "monthly" | "yearly" | "cron";
|
||||||
export type EndKind = "never" | "after" | "on";
|
export type EndKind = "never" | "after" | "on";
|
||||||
|
|
||||||
|
const CRON_PREFIX = "CRON:";
|
||||||
|
|
||||||
const WEEKDAY_CODES = ["MO", "TU", "WE", "TH", "FR", "SA", "SU"] as const;
|
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 }> = [
|
export const WEEKDAY_LABELS: Array<{ iso: number; code: string; short: string; long: string }> = [
|
||||||
@ -17,12 +19,14 @@ export const WEEKDAY_LABELS: Array<{ iso: number; code: string; short: string; l
|
|||||||
|
|
||||||
export interface RecurrenceSpec {
|
export interface RecurrenceSpec {
|
||||||
kind: RecurrenceKind;
|
kind: RecurrenceKind;
|
||||||
/** Every N units. Defaults to 1. Ignored for `none`. */
|
/** Every N units. Defaults to 1. Ignored for `none` and `cron`. */
|
||||||
interval: number;
|
interval: number;
|
||||||
/** ISO weekday numbers (1=Mon..7=Sun). Used for `weekly`. */
|
/** ISO weekday numbers (1=Mon..7=Sun). Used for `weekly`. */
|
||||||
weeklyDays: number[];
|
weeklyDays: number[];
|
||||||
/** Day-of-month for `monthly` (1-31). If omitted, falls back to firstFire.day. */
|
/** Day-of-month for `monthly` (1-31). If omitted, falls back to firstFire.day. */
|
||||||
monthDay?: number;
|
monthDay?: number;
|
||||||
|
/** Cron expression — only meaningful when kind === "cron". */
|
||||||
|
cron?: string;
|
||||||
/** End condition. */
|
/** End condition. */
|
||||||
end:
|
end:
|
||||||
| { kind: "never" }
|
| { kind: "never" }
|
||||||
@ -48,6 +52,9 @@ function clampInterval(n: number): number {
|
|||||||
*/
|
*/
|
||||||
export function buildRrule(spec: RecurrenceSpec, firstFire: DateTime): string | null {
|
export function buildRrule(spec: RecurrenceSpec, firstFire: DateTime): string | null {
|
||||||
if (spec.kind === "none") return null;
|
if (spec.kind === "none") return null;
|
||||||
|
if (spec.kind === "cron") {
|
||||||
|
return spec.cron ? `${CRON_PREFIX}${spec.cron.trim()}` : null;
|
||||||
|
}
|
||||||
|
|
||||||
const parts: string[] = [];
|
const parts: string[] = [];
|
||||||
switch (spec.kind) {
|
switch (spec.kind) {
|
||||||
@ -112,6 +119,9 @@ const FREQ_UNIT: Record<string, string> = {
|
|||||||
*/
|
*/
|
||||||
export function describeRecurrence(spec: RecurrenceSpec, firstFire: DateTime): string {
|
export function describeRecurrence(spec: RecurrenceSpec, firstFire: DateTime): string {
|
||||||
if (spec.kind === "none") return "One-off";
|
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 interval = clampInterval(spec.interval);
|
||||||
const unit = FREQ_UNIT[spec.kind]!;
|
const unit = FREQ_UNIT[spec.kind]!;
|
||||||
@ -148,6 +158,16 @@ export function describeRecurrence(spec: RecurrenceSpec, firstFire: DateTime): s
|
|||||||
export function specFromRrule(rrule: string | null | undefined): RecurrenceSpec {
|
export function specFromRrule(rrule: string | null | undefined): RecurrenceSpec {
|
||||||
if (!rrule) return { ...DEFAULT_RECURRENCE };
|
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
|
const tokens = rrule
|
||||||
.split(";")
|
.split(";")
|
||||||
.map((t) => t.trim())
|
.map((t) => t.trim())
|
||||||
@ -206,7 +226,8 @@ export type PresetId =
|
|||||||
| "weekly_same"
|
| "weekly_same"
|
||||||
| "monthly_same"
|
| "monthly_same"
|
||||||
| "yearly_same"
|
| "yearly_same"
|
||||||
| "custom";
|
| "custom"
|
||||||
|
| "cron";
|
||||||
|
|
||||||
export interface PresetDescriptor {
|
export interface PresetDescriptor {
|
||||||
id: PresetId;
|
id: PresetId;
|
||||||
@ -249,6 +270,13 @@ export function presetToSpec(id: PresetId, firstFire: DateTime): RecurrenceSpec
|
|||||||
// detailed spec the user already had. Return a sensible weekly
|
// detailed spec the user already had. Return a sensible weekly
|
||||||
// default if the caller forgets to pass through.
|
// default if the caller forgets to pass through.
|
||||||
return { ...base, kind: "weekly", weeklyDays: [firstFire.weekday] };
|
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} * * *`,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -260,6 +288,7 @@ export function presetToSpec(id: PresetId, firstFire: DateTime): RecurrenceSpec
|
|||||||
*/
|
*/
|
||||||
export function matchPreset(spec: RecurrenceSpec, firstFire: DateTime): PresetId {
|
export function matchPreset(spec: RecurrenceSpec, firstFire: DateTime): PresetId {
|
||||||
if (spec.kind === "none") return "none";
|
if (spec.kind === "none") return "none";
|
||||||
|
if (spec.kind === "cron") return "cron";
|
||||||
|
|
||||||
const sameInterval = spec.interval === 1;
|
const sameInterval = spec.interval === 1;
|
||||||
const noEnd = spec.end.kind === "never";
|
const noEnd = spec.end.kind === "never";
|
||||||
@ -283,6 +312,8 @@ export function matchPreset(spec: RecurrenceSpec, firstFire: DateTime): PresetId
|
|||||||
return "custom";
|
return "custom";
|
||||||
case "yearly":
|
case "yearly":
|
||||||
return "yearly_same";
|
return "yearly_same";
|
||||||
|
case "cron":
|
||||||
|
return "cron";
|
||||||
case "none":
|
case "none":
|
||||||
return "none";
|
return "none";
|
||||||
}
|
}
|
||||||
@ -319,5 +350,10 @@ export function presetDescriptors(firstFire: DateTime): PresetDescriptor[] {
|
|||||||
label: "Custom…",
|
label: "Custom…",
|
||||||
hint: "Set interval, days, and end conditions yourself",
|
hint: "Set interval, days, and end conditions yourself",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "cron",
|
||||||
|
label: "Cron expression…",
|
||||||
|
hint: "Full sec/min/hour/day/month/dow combinational power",
|
||||||
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@ -19,6 +19,7 @@
|
|||||||
"typecheck": "tsc -p tsconfig.json --noEmit"
|
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"cron-parser": "^5.5.0",
|
||||||
"rrule": "^2.8.1",
|
"rrule": "^2.8.1",
|
||||||
"luxon": "^3.5.0"
|
"luxon": "^3.5.0"
|
||||||
},
|
},
|
||||||
|
|||||||
@ -1,5 +1,14 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { parseRRule, nextOccurrence, validateMinInterval, MIN_INTERVAL_MS } from "./rrule.js";
|
import {
|
||||||
|
parseRRule,
|
||||||
|
nextOccurrence,
|
||||||
|
validateMinInterval,
|
||||||
|
validateCronExpression,
|
||||||
|
isCronRule,
|
||||||
|
stripCronPrefix,
|
||||||
|
CRON_PREFIX,
|
||||||
|
MIN_INTERVAL_MS,
|
||||||
|
} from "./rrule.js";
|
||||||
|
|
||||||
describe("parseRRule", () => {
|
describe("parseRRule", () => {
|
||||||
it("accepts a daily rule", () => {
|
it("accepts a daily rule", () => {
|
||||||
@ -43,3 +52,70 @@ describe("MIN_INTERVAL_MS", () => {
|
|||||||
expect(MIN_INTERVAL_MS).toBe(5 * 60 * 1000);
|
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("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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@ -11,6 +11,24 @@ const { RRule, rrulestr } = rrulePkg;
|
|||||||
|
|
||||||
export const MIN_INTERVAL_MS = 5 * 60 * 1000;
|
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 {
|
export function parseRRule(rule: string): RRuleType {
|
||||||
const parsed = rrulestr(rule);
|
const parsed = rrulestr(rule);
|
||||||
if (!(parsed instanceof RRule)) {
|
if (!(parsed instanceof RRule)) {
|
||||||
@ -20,6 +38,21 @@ export function parseRRule(rule: string): RRuleType {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function nextOccurrence(rule: string, timezone: string, after: Date): Date | null {
|
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");
|
||||||
|
try {
|
||||||
|
const it = CronExpressionParser.parse(stripCronPrefix(rule), {
|
||||||
|
currentDate: after,
|
||||||
|
tz: timezone,
|
||||||
|
});
|
||||||
|
const next = it.next();
|
||||||
|
return next.toDate();
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
const parsed = parseRRule(rule);
|
const parsed = parseRRule(rule);
|
||||||
const afterInZone = DateTime.fromJSDate(after).setZone(timezone).toJSDate();
|
const afterInZone = DateTime.fromJSDate(after).setZone(timezone).toJSDate();
|
||||||
const next = parsed.after(afterInZone, false);
|
const next = parsed.after(afterInZone, false);
|
||||||
@ -29,6 +62,27 @@ export function nextOccurrence(rule: string, timezone: string, after: Date): Dat
|
|||||||
export type IntervalCheck = { ok: true } | { ok: false; reason: string };
|
export type IntervalCheck = { ok: true } | { ok: false; reason: string };
|
||||||
|
|
||||||
export function validateMinInterval(rule: string, timezone: string): IntervalCheck {
|
export function validateMinInterval(rule: string, timezone: string): IntervalCheck {
|
||||||
|
if (isCronRule(rule)) {
|
||||||
|
const { CronExpressionParser } = require("cron-parser") as typeof import("cron-parser");
|
||||||
|
try {
|
||||||
|
const it = CronExpressionParser.parse(stripCronPrefix(rule), {
|
||||||
|
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.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { ok: true };
|
||||||
|
} catch (err) {
|
||||||
|
return { ok: false, reason: `Invalid cron expression: ${(err as Error).message}` };
|
||||||
|
}
|
||||||
|
}
|
||||||
const parsed = parseRRule(rule);
|
const parsed = parseRRule(rule);
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const first = parsed.after(now, false);
|
const first = parsed.after(now, false);
|
||||||
@ -44,3 +98,14 @@ export function validateMinInterval(rule: string, timezone: string): IntervalChe
|
|||||||
}
|
}
|
||||||
return { ok: true };
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
3
pnpm-lock.yaml
generated
3
pnpm-lock.yaml
generated
@ -212,6 +212,9 @@ importers:
|
|||||||
|
|
||||||
packages/shared:
|
packages/shared:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
cron-parser:
|
||||||
|
specifier: ^5.5.0
|
||||||
|
version: 5.5.0
|
||||||
luxon:
|
luxon:
|
||||||
specifier: ^3.5.0
|
specifier: ^3.5.0
|
||||||
version: 3.7.2
|
version: 3.7.2
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user