From 5f1897daa54240c88a8e02a06103f4fcca26554a Mon Sep 17 00:00:00 2001 From: yiekheng Date: Sun, 10 May 2026 10:25:47 +0800 Subject: [PATCH] =?UTF-8?q?feat(recurrence):=20full=20crontab=20support=20?= =?UTF-8?q?=E2=80=94=20sec/min/hour/day/month/dow=20combinational?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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:` for cron specs. - specFromRrule round-trips a CRON-prefixed rule back into the spec. - describeRecurrence renders "Cron: " 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) --- apps/web/src/actions/reminders.ts | 51 ++++++++---- apps/web/src/components/recurrence-picker.tsx | 50 ++++++++++++ .../reminder-edit/edit-when-form.tsx | 46 +++++++---- .../reminder-wizard/when-form-client.tsx | 22 ++++++ apps/web/src/lib/recurrence.test.ts | 36 ++++++++- apps/web/src/lib/recurrence.ts | 42 +++++++++- packages/shared/package.json | 1 + packages/shared/src/rrule.test.ts | 78 ++++++++++++++++++- packages/shared/src/rrule.ts | 65 ++++++++++++++++ pnpm-lock.yaml | 3 + 10 files changed, 360 insertions(+), 34 deletions(-) diff --git a/apps/web/src/actions/reminders.ts b/apps/web/src/actions/reminders.ts index f8208ad..ac4f9f1 100644 --- a/apps/web/src/actions/reminders.ts +++ b/apps/web/src/actions/reminders.ts @@ -7,7 +7,7 @@ import { eq } from "drizzle-orm"; import { z } from "zod"; import { DateTime } from "luxon"; 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 { getSeededOperator } from "@/lib/operator"; import { checkRateLimit } from "@/lib/rate-limit"; @@ -166,12 +166,26 @@ export async function createReminderAction( }); if (!account) return { ok: false, error: "Account not yours" }; - const 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" }; + // Resolve the first-fire timestamp. Cron rules ignore the user- + // supplied date+time (the form sends a placeholder) and let the cron + // expression define when the reminder runs first. + let scheduledAt: Date; + if (rrule && isCronRule(rrule)) { + 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 @@ -278,12 +292,23 @@ export async function updateReminderAction( }); if (!targetAccount) return { ok: false, error: "Account not yours" }; - const 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" }; + let scheduledAt: Date; + if (rrule && isCronRule(rrule)) { + 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" }; + } } const groups = await db.query.whatsappGroups.findMany({ diff --git a/apps/web/src/components/recurrence-picker.tsx b/apps/web/src/components/recurrence-picker.tsx index ec60418..90d44cd 100644 --- a/apps/web/src/components/recurrence-picker.tsx +++ b/apps/web/src/components/recurrence-picker.tsx @@ -73,6 +73,12 @@ export function RecurrencePicker({ firstFire, value, onChange }: RecurrencePicke return; } 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)); } @@ -139,11 +145,55 @@ export function RecurrencePicker({ firstFire, value, onChange }: RecurrencePicke {customExpanded && ( )} + + {activePreset === "cron" && ( + + )} ); } +function CronPanel({ + value, + onChange, +}: { + value: RecurrenceSpec; + onChange: (next: RecurrenceSpec) => void; +}) { + return ( +
+ + onChange({ ...value, cron: e.target.value })} + placeholder="0 9 * * 1-5" + className="h-8 font-mono text-sm" + spellCheck={false} + /> +

+ Standard 5-field cron (m h dom mon dow) or + 6-field with seconds (s m h dom mon dow). + Examples: +

+
    +
  • 0 9 * * 1-5 — 9 am on weekdays
  • +
  • */15 * * * * — every 15 minutes
  • +
  • 0 9,12,18 * * * — 9, 12, and 18 every day
  • +
  • 0 0 1 * * — midnight on the 1st of every month
  • +
+

+ 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. +

+
+ ); +} + interface CustomPanelProps { firstFire: DateTime; value: RecurrenceSpec; diff --git a/apps/web/src/components/reminder-edit/edit-when-form.tsx b/apps/web/src/components/reminder-edit/edit-when-form.tsx index 78438d0..c0392d8 100644 --- a/apps/web/src/components/reminder-edit/edit-when-form.tsx +++ b/apps/web/src/components/reminder-edit/edit-when-form.tsx @@ -63,22 +63,36 @@ export function EditWhenForm({ })(); async function handleSave() { - 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; + let scheduledAtIso: string; + let rrule: string | null; + + if (spec.kind === "cron") { + if (!spec.cron || !spec.cron.trim()) { + setError("Enter a cron expression."); + 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); setError(null); @@ -90,7 +104,7 @@ export function EditWhenForm({ text, mediaId, caption, - scheduledAtIso: dt.toISO()!, + scheduledAtIso, rrule, timezone, }); diff --git a/apps/web/src/components/reminder-wizard/when-form-client.tsx b/apps/web/src/components/reminder-wizard/when-form-client.tsx index e51104c..c91d122 100644 --- a/apps/web/src/components/reminder-wizard/when-form-client.tsx +++ b/apps/web/src/components/reminder-wizard/when-form-client.tsx @@ -59,6 +59,28 @@ export function WhenFormClient({ })(); 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()); if (!v.ok) { const map = { diff --git a/apps/web/src/lib/recurrence.test.ts b/apps/web/src/lib/recurrence.test.ts index ba45ca9..1643750 100644 --- a/apps/web/src/lib/recurrence.test.ts +++ b/apps/web/src/lib/recurrence.test.ts @@ -225,7 +225,7 @@ describe("preset shortcuts (Repeats picker)", () => { ).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); expect(items.map((d) => d.id)).toEqual([ "none", @@ -236,6 +236,7 @@ describe("preset shortcuts (Repeats picker)", () => { "monthly_same", "yearly_same", "custom", + "cron", ]); // Labels should be parameterised by firstFire. 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( "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 * * * *", + }); }); }); diff --git a/apps/web/src/lib/recurrence.ts b/apps/web/src/lib/recurrence.ts index e64788d..bc6fa74 100644 --- a/apps/web/src/lib/recurrence.ts +++ b/apps/web/src/lib/recurrence.ts @@ -1,8 +1,10 @@ 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"; +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 }> = [ @@ -17,12 +19,14 @@ export const WEEKDAY_LABELS: Array<{ iso: number; code: string; short: string; l export interface RecurrenceSpec { kind: RecurrenceKind; - /** Every N units. Defaults to 1. Ignored for `none`. */ + /** 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" } @@ -48,6 +52,9 @@ function clampInterval(n: number): number { */ 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) { @@ -112,6 +119,9 @@ const FREQ_UNIT: Record = { */ 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]!; @@ -148,6 +158,16 @@ export function describeRecurrence(spec: RecurrenceSpec, firstFire: DateTime): s 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()) @@ -206,7 +226,8 @@ export type PresetId = | "weekly_same" | "monthly_same" | "yearly_same" - | "custom"; + | "custom" + | "cron"; export interface PresetDescriptor { id: PresetId; @@ -249,6 +270,13 @@ export function presetToSpec(id: PresetId, firstFire: DateTime): RecurrenceSpec // 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} * * *`, + }; } } @@ -260,6 +288,7 @@ export function presetToSpec(id: PresetId, firstFire: DateTime): RecurrenceSpec */ 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"; @@ -283,6 +312,8 @@ export function matchPreset(spec: RecurrenceSpec, firstFire: DateTime): PresetId return "custom"; case "yearly": return "yearly_same"; + case "cron": + return "cron"; case "none": return "none"; } @@ -319,5 +350,10 @@ export function presetDescriptors(firstFire: DateTime): PresetDescriptor[] { 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", + }, ]; } diff --git a/packages/shared/package.json b/packages/shared/package.json index 0e240f0..063154a 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -19,6 +19,7 @@ "typecheck": "tsc -p tsconfig.json --noEmit" }, "dependencies": { + "cron-parser": "^5.5.0", "rrule": "^2.8.1", "luxon": "^3.5.0" }, diff --git a/packages/shared/src/rrule.test.ts b/packages/shared/src/rrule.test.ts index f6a5272..b570fbf 100644 --- a/packages/shared/src/rrule.test.ts +++ b/packages/shared/src/rrule.test.ts @@ -1,5 +1,14 @@ 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", () => { it("accepts a daily rule", () => { @@ -43,3 +52,70 @@ describe("MIN_INTERVAL_MS", () => { 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"); + }); +}); diff --git a/packages/shared/src/rrule.ts b/packages/shared/src/rrule.ts index 622c0b7..2e0b2d2 100644 --- a/packages/shared/src/rrule.ts +++ b/packages/shared/src/rrule.ts @@ -11,6 +11,24 @@ 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)) { @@ -20,6 +38,21 @@ export function parseRRule(rule: string): RRuleType { } 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 afterInZone = DateTime.fromJSDate(after).setZone(timezone).toJSDate(); 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 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 now = new Date(); const first = parsed.after(now, false); @@ -44,3 +98,14 @@ export function validateMinInterval(rule: string, timezone: string): IntervalChe } 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; + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c14ac31..ad1c75f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -212,6 +212,9 @@ importers: packages/shared: dependencies: + cron-parser: + specifier: ^5.5.0 + version: 5.5.0 luxon: specifier: ^3.5.0 version: 3.7.2