+ 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