Cron Editor
A schedule as a cron expression, with a plain-language reading and the next runs.
Pick at least one day.
At 09:00 AM on Monday
Next 5 runs · Europe/London
Installation
pnpm dlx @dowel-ui/cli add cron-editorInstalls input and select as well, because this component imports them.
No npm packages are needed beyond what Dowel already requires.
Accessibility
The expression field is described by one element that holds either the plain-language reading or the reason the expression is invalid, so a reader hears the error replace the reading rather than both at once; an invalid expression is never applied. The builder's day buttons are a named group of aria-pressed toggles with the full day name as the accessible name, and the last selected day cannot be removed silently — the group says why. Days 29 to 31 say in text that shorter months skip them. Next runs are a list of time elements with machine-readable datetimes, headed by the zone they are in, because a time with no zone is the classic scheduling mistake. Nothing clock-dependent renders until the clock is known, so server and client cannot disagree.
Props
Cron
| Prop | Type | Default |
|---|---|---|
locale | string | — |
nowThe instant next runs are counted from. Pass one that changes when you refresh. | Date | — |
onValueChange | (expression: string) => void | — |
timeZoneIANA zone the expression's times are in. Defaults to the runtime zone. | string | — |
valueThe expression. Controlled. | string | — |
Plus every attribute of <div> except defaultValue, onChange, children.
CronBuilder
Plus every attribute of <div> except children.
CronExpression
Plus every attribute of <div> except children.
CronNextRuns
| Prop | Type | Default |
|---|---|---|
count | number | 5 |
Plus every attribute of <div> except children.
Quality
9/10 checks, measured from the source and its tests
- Tested — passes
- axe assertion — passes
- Keyboard tested — fails
- Storybook examples — passes
- Accessibility documented — passes
- Semantic tokens only — passes
- Motion from tokens — passes
- className merged — passes
- Visible focus — passes
- No fixed widths — passes
Source
This is exactly what dowel add cron-editor writes into your project, with imports rewritten to your own path alias.
/**
* The five-field cron expression, as a value: parsed, described in words, and
* projected onto a calendar in a named time zone.
*
* The dialect is the POSIX one — minute, hour, day of month, month, day of
* week — which is what crontab, GitHub Actions, Vercel, Kubernetes and Airflow
* all read. Not the Quartz one: no seconds field and no `L`, `W` or `#`,
* because the products people schedule from a form do not accept them, and an
* expression this editor produces has to run where it is pasted. The `@daily`
* family of shortcuts is accepted, since crontab accepts it.
*
* Everything here is pure and takes its clock as an argument, so the
* description, the run times and the two things every reimplementation gets
* wrong — day-of-month OR day-of-week, and a wall-clock time that does not
* exist on the day the clocks go forward — are tested without rendering
* anything, and an application can compute the next run on the server from
* the same expression the editor produced.
*/
export type CronField = "minute" | "hour" | "dayOfMonth" | "month" | "dayOfWeek";
export class CronExpressionError extends Error {
constructor(
message: string,
/** Which of the five fields is wrong, when one is. */
public readonly field?: CronField,
) {
super(message);
this.name = "CronExpressionError";
}
}
export interface CronSchedule {
minutes: number[];
hours: number[];
daysOfMonth: number[];
months: number[];
/** 0 to 6, Sunday first. `7` in the source is folded into 0. */
daysOfWeek: number[];
/**
* Whether each day field was written as something other than `*`. Cron's
* rule, which every reimplementation gets wrong at least once: when both are
* restricted a day matches if EITHER does, not both.
*/
dayOfMonthRestricted: boolean;
dayOfWeekRestricted: boolean;
/** The expression with any shortcut expanded. */
expression: string;
}
const FIELDS: readonly CronField[] = ["minute", "hour", "dayOfMonth", "month", "dayOfWeek"];
const RANGES: Record<CronField, { min: number; max: number; label: string }> = {
minute: { min: 0, max: 59, label: "Minute" },
hour: { min: 0, max: 23, label: "Hour" },
dayOfMonth: { min: 1, max: 31, label: "Day of month" },
month: { min: 1, max: 12, label: "Month" },
dayOfWeek: { min: 0, max: 7, label: "Day of week" },
};
const MONTH_NAMES = [
"JAN",
"FEB",
"MAR",
"APR",
"MAY",
"JUN",
"JUL",
"AUG",
"SEP",
"OCT",
"NOV",
"DEC",
];
const DAY_NAMES = ["SUN", "MON", "TUE", "WED", "THU", "FRI", "SAT"];
export const CRON_SHORTCUTS: Record<string, string> = {
"@yearly": "0 0 1 1 *",
"@annually": "0 0 1 1 *",
"@monthly": "0 0 1 * *",
"@weekly": "0 0 * * 0",
"@daily": "0 0 * * *",
"@midnight": "0 0 * * *",
"@hourly": "0 * * * *",
};
function nameToNumber(token: string, field: CronField): number | null {
const upper = token.toUpperCase();
if (field === "month") {
const index = MONTH_NAMES.indexOf(upper);
return index === -1 ? null : index + 1;
}
if (field === "dayOfWeek") {
const index = DAY_NAMES.indexOf(upper);
return index === -1 ? null : index;
}
return null;
}
function parseNumber(token: string, field: CronField): number {
const { min, max, label } = RANGES[field];
const named = nameToNumber(token, field);
const value = named ?? (/^\d+$/.test(token) ? Number(token) : Number.NaN);
if (Number.isNaN(value)) {
throw new CronExpressionError(
`${label}: "${token}" is not a number${field === "month" || field === "dayOfWeek" ? " or a name" : ""}.`,
field,
);
}
if (value < min || value > max) {
throw new CronExpressionError(
`${label} must be ${String(min)}–${String(max)}, not ${String(value)}.`,
field,
);
}
return value;
}
/** One field's set of values, in order and without duplicates. */
function parseField(text: string, field: CronField): { values: number[]; restricted: boolean } {
const { min, max, label } = RANGES[field];
// Day of week runs 0–7 in the source and 0–6 in the result.
const top = field === "dayOfWeek" ? 6 : max;
const values = new Set<number>();
let restricted = false;
for (const part of text.split(",")) {
if (part === "") throw new CronExpressionError(`${label}: empty item in "${text}".`, field);
const [rangeText, stepText, ...extra] = part.split("/");
if (extra.length > 0 || stepText === "") {
throw new CronExpressionError(`${label}: "${part}" has a malformed step.`, field);
}
const step = stepText === undefined ? 1 : Number(stepText);
if (stepText !== undefined && (!/^\d+$/.test(stepText) || step < 1)) {
throw new CronExpressionError(
`${label}: step must be a whole number of 1 or more, not "${stepText}".`,
field,
);
}
let start: number;
let end: number;
if (rangeText === "*") {
start = min;
end = top;
if (stepText !== undefined) restricted = true;
} else if (rangeText === undefined) {
throw new CronExpressionError(`${label}: "${part}" is empty.`, field);
} else {
restricted = true;
const [startText, endText, ...more] = rangeText.split("-");
if (more.length > 0 || startText === undefined || startText === "") {
throw new CronExpressionError(`${label}: "${rangeText}" is not a range.`, field);
}
start = parseNumber(startText, field);
if (endText === undefined) {
// A bare number with a step, `5/10`, means "from 5 onwards" in Vixie cron.
end = stepText === undefined ? start : top;
} else {
if (endText === "")
throw new CronExpressionError(`${label}: "${rangeText}" is missing its end.`, field);
end = parseNumber(endText, field);
if (end < start) {
throw new CronExpressionError(
`${label}: ${String(start)}-${String(end)} runs backwards.`,
field,
);
}
}
}
for (let value = start; value <= end; value += step) {
values.add(field === "dayOfWeek" && value === 7 ? 0 : value);
}
}
return { values: [...values].sort((a, b) => a - b), restricted };
}
export function parseCron(expression: string): CronSchedule {
const trimmed = expression.trim();
if (trimmed === "") throw new CronExpressionError("Enter a schedule.");
const expanded = trimmed.startsWith("@") ? CRON_SHORTCUTS[trimmed.toLowerCase()] : trimmed;
if (expanded === undefined) {
throw new CronExpressionError(
`"${trimmed}" is not a shortcut. Try @hourly, @daily, @weekly, @monthly or @yearly.`,
);
}
const parts = expanded.split(/\s+/);
if (parts.length !== 5) {
throw new CronExpressionError(
`Expected 5 fields — minute, hour, day of month, month, day of week — not ${String(parts.length)}.`,
);
}
const fields = FIELDS.map((field, index) => parseField(parts[index] ?? "", field));
const at = (index: number) => fields[index] ?? { values: [], restricted: false };
const [minute, hour, dayOfMonth, month, dayOfWeek] = [at(0), at(1), at(2), at(3), at(4)];
return {
minutes: minute.values,
hours: hour.values,
daysOfMonth: dayOfMonth.values,
months: month.values,
daysOfWeek: dayOfWeek.values,
dayOfMonthRestricted: dayOfMonth.restricted,
dayOfWeekRestricted: dayOfWeek.restricted,
expression: parts.join(" "),
};
}
export function isValidCron(expression: string): boolean {
try {
parseCron(expression);
return true;
} catch {
return false;
}
}
/* ------------------------------------------------------------------ */
/* Describing */
/* ------------------------------------------------------------------ */
export interface DescribeOptions {
locale?: string;
}
/** "1, 2 and 3", or "1 to 5" when the values are consecutive. */
function listOf(items: string[], consecutive: boolean): string {
if (items.length === 1) return items[0] ?? "";
if (consecutive && items.length > 2)
return `${items[0] ?? ""} to ${items[items.length - 1] ?? ""}`;
return `${items.slice(0, -1).join(", ")} and ${items[items.length - 1] ?? ""}`;
}
function isConsecutive(values: number[]): boolean {
return values.every((value, index) => index === 0 || value === (values[index - 1] ?? 0) + 1);
}
/** The step a field was written with, when it was written as a star over a number. */
function stepOf(text: string): number | null {
const match = /^\*\/(\d+)$/.exec(text);
return match ? Number(match[1]) : null;
}
function ordinal(n: number): string {
const rem10 = n % 10;
const rem100 = n % 100;
const suffix =
rem10 === 1 && rem100 !== 11
? "st"
: rem10 === 2 && rem100 !== 12
? "nd"
: rem10 === 3 && rem100 !== 13
? "rd"
: "th";
return `${String(n)}${suffix}`;
}
function formatTime(hour: number, minute: number, locale?: string): string {
// Formatted in UTC from a UTC instant, so the runtime zone cannot shift it.
return new Intl.DateTimeFormat(locale, {
hour: "2-digit",
minute: "2-digit",
timeZone: "UTC",
}).format(Date.UTC(2026, 0, 1, hour, minute));
}
function dayName(index: number, locale?: string): string {
// 4 January 2026 is a Sunday.
return new Intl.DateTimeFormat(locale, { weekday: "long" }).format(
new Date(2026, 0, 4 + index),
);
}
function monthName(index: number, locale?: string): string {
return new Intl.DateTimeFormat(locale, { month: "long" }).format(
new Date(2026, index - 1, 1),
);
}
/**
* The schedule in a sentence.
*
* Every product that has this control renders "Every Monday at 09:00" beside
* the expression, because `0 9 * * 1` is not something most people can read.
* Throws for an invalid expression; the editor turns that into the field's
* error text.
*/
export function describeCron(expression: string, options: DescribeOptions = {}): string {
const { locale } = options;
const schedule = parseCron(expression);
const [minuteText = "", hourText = "", dayText = "", monthText = ""] =
schedule.expression.split(" ");
const every = (label: string, n: number) =>
n === 1 ? `every ${label}` : `every ${ordinal(n)} ${label}`;
// Time of day.
let time: string;
const minuteStep = stepOf(minuteText);
const hourStep = stepOf(hourText);
const allMinutes = minuteText === "*";
const allHours = hourText === "*";
if (allMinutes && allHours) {
time = "Every minute";
} else if (minuteStep !== null && allHours) {
time = `Every ${minuteStep === 1 ? "minute" : `${String(minuteStep)} minutes`}`;
} else if (allMinutes) {
time = `Every minute of ${listOf(
schedule.hours.map((h) => formatTime(h, 0, locale)),
isConsecutive(schedule.hours),
)}`;
} else if (allHours || hourStep !== null) {
const hours = hourStep !== null && hourStep > 1 ? every("hour", hourStep) : "every hour";
if (minuteStep !== null) {
time = `Every ${String(minuteStep)} minutes of ${hours}`;
} else {
time = `At minute ${listOf(schedule.minutes.map(String), isConsecutive(schedule.minutes))} past ${hours}`;
}
} else if (minuteStep !== null) {
time = `Every ${String(minuteStep)} minutes during ${listOf(
schedule.hours.map((h) => formatTime(h, 0, locale)),
isConsecutive(schedule.hours),
)}`;
} else if (schedule.minutes.length === 1) {
const minute = schedule.minutes[0] ?? 0;
time = `At ${listOf(
schedule.hours.map((h) => formatTime(h, minute, locale)),
false,
)}`;
} else {
time = `At minute ${listOf(schedule.minutes.map(String), isConsecutive(schedule.minutes))} past ${listOf(
schedule.hours.map((h) => formatTime(h, 0, locale)),
isConsecutive(schedule.hours),
)}`;
}
// Days. When both day fields are restricted a day matches if either does —
// said with "or", because "and" is what readers assume and it is wrong.
const dayPhrases: string[] = [];
if (schedule.dayOfMonthRestricted) {
const step = stepOf(dayText);
dayPhrases.push(
step !== null
? `${every("day", step)} of the month`
: `on day ${listOf(schedule.daysOfMonth.map(String), isConsecutive(schedule.daysOfMonth))} of the month`,
);
}
if (schedule.dayOfWeekRestricted) {
dayPhrases.push(
`on ${listOf(
schedule.daysOfWeek.map((d) => dayName(d, locale)),
isConsecutive(schedule.daysOfWeek),
)}`,
);
}
let days = dayPhrases.join(", or ");
// A fixed time of day with no day named is every day. "Every 15 minutes"
// already says how often, and "every day" after it would be noise.
const fixedTime = !allMinutes && !allHours && minuteStep === null && hourStep === null;
if (!days && fixedTime) days = "every day";
if (schedule.months.length < 12) {
const step = stepOf(monthText);
const months =
step !== null
? every("month", step)
: listOf(
schedule.months.map((m) => monthName(m, locale)),
isConsecutive(schedule.months),
);
days = `${days ? `${days} ` : ""}in ${months}`;
}
return days ? `${time} ${days}` : time;
}
/* ------------------------------------------------------------------ */
/* Next runs */
/* ------------------------------------------------------------------ */
export interface NextRunsOptions {
/** Runs strictly after this instant. */
from: Date;
count?: number;
/** IANA zone the expression's wall-clock times are in. Defaults to the runtime zone. */
timeZone?: string;
}
interface WallClock {
year: number;
month: number;
day: number;
hour: number;
minute: number;
/** 0 = Sunday. */
weekday: number;
}
const formatters = new Map<string, Intl.DateTimeFormat>();
function formatterFor(timeZone: string | undefined): Intl.DateTimeFormat {
const key = timeZone ?? "";
let formatter = formatters.get(key);
if (!formatter) {
formatter = new Intl.DateTimeFormat("en-US", {
timeZone,
hourCycle: "h23",
year: "numeric",
month: "numeric",
day: "numeric",
hour: "numeric",
minute: "numeric",
weekday: "short",
});
formatters.set(key, formatter);
}
return formatter;
}
function wallClockOf(instant: number, timeZone: string | undefined): WallClock {
const parts: Record<string, string> = {};
for (const part of formatterFor(timeZone).formatToParts(new Date(instant))) {
parts[part.type] = part.value;
}
return {
year: Number(parts.year),
month: Number(parts.month),
day: Number(parts.day),
hour: Number(parts.hour) % 24,
minute: Number(parts.minute),
weekday: DAY_NAMES.indexOf((parts.weekday ?? "").slice(0, 3).toUpperCase()),
};
}
function offsetAt(instant: number, timeZone: string | undefined): number {
const wall = wallClockOf(instant, timeZone);
return (
Date.UTC(wall.year, wall.month - 1, wall.day, wall.hour, wall.minute) -
Math.floor(instant / 60_000) * 60_000
);
}
/**
* The instant a wall-clock time names in a zone, or null when it names none —
* the hour the clocks skip in spring. An ambiguous time, in the hour that
* repeats in autumn, resolves to its first occurrence.
*/
export function zonedTimeToInstant(
year: number,
month: number,
day: number,
hour: number,
minute: number,
timeZone: string | undefined,
): number | null {
const guess = Date.UTC(year, month - 1, day, hour, minute);
// Each pass corrects the guess by the offset in force at the previous
// answer; across a DST change the two answers are the two candidates.
const candidates = new Set<number>();
// Seeded from a day either side as well, so the offset that stopped
// applying an hour ago — the one that makes an autumn time ambiguous — is
// still tried.
const oneDay = 24 * 60 * 60_000;
for (const seed of [guess, guess - oneDay, guess + oneDay]) {
let instant = seed;
for (let pass = 0; pass < 2; pass += 1) {
instant = guess - offsetAt(instant, timeZone);
candidates.add(instant);
}
}
for (const candidate of [...candidates].sort((a, b) => a - b)) {
const wall = wallClockOf(candidate, timeZone);
if (
wall.year === year &&
wall.month === month &&
wall.day === day &&
wall.hour === hour &&
wall.minute === minute
) {
return candidate;
}
}
return null;
}
/** Roughly five years of days: enough to find the 29th of February, and to give up on the 30th. */
const MAX_DAYS = 366 * 5;
/**
* The next `count` instants the schedule fires, in order.
*
* Fewer than `count` are returned when the schedule cannot produce them —
* `0 0 30 2 *` never runs, and saying so beats a spinner.
*/
export function nextRuns(expression: string, options: NextRunsOptions): Date[] {
const { from, count = 5, timeZone } = options;
const schedule = parseCron(expression);
const runs: Date[] = [];
const start = Math.floor(from.getTime() / 60_000) * 60_000 + 60_000;
const months = new Set(schedule.months);
const daysOfMonth = new Set(schedule.daysOfMonth);
const daysOfWeek = new Set(schedule.daysOfWeek);
const dayMatches = (wall: WallClock): boolean => {
if (!months.has(wall.month)) return false;
const domMatch = daysOfMonth.has(wall.day);
const dowMatch = daysOfWeek.has(wall.weekday);
if (schedule.dayOfMonthRestricted && schedule.dayOfWeekRestricted)
return domMatch || dowMatch;
if (schedule.dayOfMonthRestricted) return domMatch;
if (schedule.dayOfWeekRestricted) return dowMatch;
return true;
};
// Walk day by day from noon, which no DST change touches, so that stepping
// 24 hours always lands in the next calendar day.
const first = wallClockOf(start, timeZone);
let noon = zonedTimeToInstant(first.year, first.month, first.day, 12, 0, timeZone);
if (noon === null) return runs;
for (let i = 0; i < MAX_DAYS && runs.length < count; i += 1, noon += 24 * 60 * 60_000) {
const wall = wallClockOf(noon, timeZone);
if (!dayMatches(wall)) continue;
for (const hour of schedule.hours) {
for (const minute of schedule.minutes) {
const instant = zonedTimeToInstant(
wall.year,
wall.month,
wall.day,
hour,
minute,
timeZone,
);
if (instant === null || instant < start) continue;
runs.push(new Date(instant));
if (runs.length >= count) return runs;
}
}
}
return runs;
}
"use client";
import {
createContext,
useContext,
useId,
useMemo,
useState,
useSyncExternalStore,
type ComponentPropsWithRef,
type ReactNode,
} from "react";
import { Input } from "@/components/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/select";
import { focusRing } from "@/lib/styles";
import { cn } from "@/lib/utils";
import {
CronExpressionError,
describeCron,
nextRuns,
parseCron,
type CronSchedule,
} from "./cron-expression";
/**
* The "run this nightly" control.
*
* GitHub Actions, Vercel, Airflow, Sentry and every admin panel with a
* schedule draws one by hand: a few frequency controls that write a cron
* expression, the expression itself for anyone who can read it, the sentence
* beside it for everyone else, and the next few runs so a mistake is visible
* before it is saved. The packages that exist are bound to Ant Design or ship
* their own stylesheet — see `cron-expression.ts` for the value model, which
* is where the two mistakes every reimplementation makes actually live.
*
* Composed from the library's own Select and Input, and split into parts so
* an app can show the expression and its reading without the builder, or the
* next runs beside a saved schedule.
*/
interface CronContextValue {
value: string;
setValue: (expression: string) => void;
schedule: CronSchedule | null;
timeZone?: string;
locale?: string;
/** Null until the clock is known, which on a server render is never. */
now: Date | null;
}
const CronContext = createContext<CronContextValue | null>(null);
function useCron(component: string): CronContextValue {
const context = useContext(CronContext);
if (!context) throw new Error(`${component} must be used inside <Cron>.`);
return context;
}
/* The clock, for callers who did not supply one — the same arrangement as
time-range-picker: a null server snapshot, so nothing clock-dependent is
rendered until the real instant arrives after hydration. */
const subscribeToNothing = () => () => undefined;
let sharedClock: Date | null = null;
const readSharedClock = () => (sharedClock ??= new Date());
const readNoClock = () => null;
export interface CronProps extends Omit<
ComponentPropsWithRef<"div">,
"defaultValue" | "onChange" | "children"
> {
/** The expression. Controlled. */
value?: string;
defaultValue?: string;
onValueChange?: (expression: string) => void;
/** IANA zone the expression's times are in. Defaults to the runtime zone. */
timeZone?: string;
locale?: string;
/** The instant next runs are counted from. Pass one that changes when you refresh. */
now?: Date;
children: ReactNode;
}
export function Cron({
className,
value: valueProp,
defaultValue = "0 9 * * 1",
onValueChange,
timeZone,
locale,
now,
children,
...props
}: CronProps) {
const [uncontrolled, setUncontrolled] = useState(defaultValue);
const value = valueProp ?? uncontrolled;
const fallbackClock = useSyncExternalStore(subscribeToNothing, readSharedClock, readNoClock);
const clock = now ?? fallbackClock;
const schedule = useMemo(() => {
try {
return parseCron(value);
} catch {
return null;
}
}, [value]);
const context = useMemo<CronContextValue>(
() => ({
value,
setValue: (expression) => {
setUncontrolled(expression);
onValueChange?.(expression);
},
schedule,
timeZone,
locale,
now: clock,
}),
[value, schedule, timeZone, locale, clock, onValueChange],
);
return (
<CronContext.Provider value={context}>
<div data-slot="cron-editor" className={cn("flex flex-col gap-4", className)} {...props}>
{children}
</div>
</CronContext.Provider>
);
}
/* ------------------------------------------------------------------ */
/* Builder */
/* ------------------------------------------------------------------ */
type Frequency = "minute" | "hour" | "day" | "week" | "month" | "year" | "custom";
const FREQUENCIES: { value: Frequency; label: string }[] = [
{ value: "minute", label: "Every minute" },
{ value: "hour", label: "Every hour" },
{ value: "day", label: "Every day" },
{ value: "week", label: "Every week" },
{ value: "month", label: "Every month" },
{ value: "year", label: "Every year" },
{ value: "custom", label: "Custom" },
];
interface BuilderState {
frequency: Frequency;
minuteStep: number;
hourStep: number;
minute: number;
hour: number;
/** 0 = Sunday. */
days: number[];
dayOfMonth: number;
month: number;
}
const isNumber = (token: string) => /^\d+$/.test(token);
const isStar = (token: string) => token === "*";
const stepOf = (token: string) => (/^\*\/\d+$/.test(token) ? Number(token.slice(2)) : null);
/**
* Which builder shape an expression fits, if any. Anything the controls
* cannot express — every 15 minutes during office hours, say — is "custom",
* and the expression field becomes the editor rather than the builder quietly
* showing something else.
*/
function classify(schedule: CronSchedule | null): BuilderState {
const state: BuilderState = {
frequency: "custom",
minuteStep: 1,
hourStep: 1,
minute: 0,
hour: 9,
days: [1],
dayOfMonth: 1,
month: 1,
};
if (!schedule) return state;
const [m = "", h = "", dom = "", mon = "", dow = ""] = schedule.expression.split(" ");
if (isNumber(m)) state.minute = schedule.minutes[0] ?? 0;
if (isNumber(h)) state.hour = schedule.hours[0] ?? 0;
const restStar = isStar(dom) && isStar(mon) && isStar(dow);
const timeFixed = isNumber(m) && isNumber(h);
if ((isStar(m) || stepOf(m) !== null) && isStar(h) && restStar) {
state.frequency = "minute";
state.minuteStep = stepOf(m) ?? 1;
} else if (isNumber(m) && (isStar(h) || stepOf(h) !== null) && restStar) {
state.frequency = "hour";
state.hourStep = stepOf(h) ?? 1;
} else if (timeFixed && restStar) {
state.frequency = "day";
} else if (timeFixed && isStar(dom) && isStar(mon) && !dow.includes("/")) {
state.frequency = "week";
state.days = schedule.daysOfWeek;
} else if (timeFixed && isNumber(dom) && isStar(mon) && isStar(dow)) {
state.frequency = "month";
state.dayOfMonth = schedule.daysOfMonth[0] ?? 1;
} else if (
timeFixed &&
isNumber(dom) &&
schedule.months.length === 1 &&
!/[-,/]/.test(mon) &&
isStar(dow)
) {
state.frequency = "year";
state.dayOfMonth = schedule.daysOfMonth[0] ?? 1;
state.month = schedule.months[0] ?? 1;
}
return state;
}
/** `1,2,3,4,5` written the way people write it: `1-5`. Runs of two stay a list. */
function compressDays(days: number[]): string {
const sorted = [...new Set(days)].sort((a, b) => a - b);
const parts: string[] = [];
for (let i = 0; i < sorted.length;) {
let j = i;
while (j + 1 < sorted.length && sorted[j + 1] === (sorted[j] ?? 0) + 1) j += 1;
const first = String(sorted[i] ?? 0);
const last = String(sorted[j] ?? 0);
parts.push(j - i >= 2 ? `${first}-${last}` : j > i ? `${first},${last}` : first);
i = j + 1;
}
return parts.join(",");
}
function build(state: BuilderState, current: string): string {
const { minute, hour, dayOfMonth, month } = state;
switch (state.frequency) {
case "minute":
return `${state.minuteStep > 1 ? `*/${String(state.minuteStep)}` : "*"} * * * *`;
case "hour":
return `${String(minute)} ${state.hourStep > 1 ? `*/${String(state.hourStep)}` : "*"} * * *`;
case "day":
return `${String(minute)} ${String(hour)} * * *`;
case "week":
return `${String(minute)} ${String(hour)} * * ${compressDays(state.days)}`;
case "month":
return `${String(minute)} ${String(hour)} ${String(dayOfMonth)} * *`;
case "year":
return `${String(minute)} ${String(hour)} ${String(dayOfMonth)} ${String(month)} *`;
case "custom":
return current;
}
}
/** Monday first, Sunday last. */
const WEEK = [1, 2, 3, 4, 5, 6, 0];
// 4 January 2026 is a Sunday; 1 January 2026 is in month 1.
const weekdayName = (day: number, locale: string | undefined, style: "long" | "short") =>
new Intl.DateTimeFormat(locale, { weekday: style }).format(new Date(2026, 0, 4 + day));
const monthName = (month: number, locale: string | undefined) =>
new Intl.DateTimeFormat(locale, { month: "long" }).format(new Date(2026, month - 1, 1));
export type CronBuilderProps = Omit<ComponentPropsWithRef<"div">, "children">;
export function CronBuilder({ className, ...props }: CronBuilderProps) {
const { value, setValue, schedule, locale } = useCron("CronBuilder");
const uid = useId();
const state = useMemo(() => classify(schedule), [schedule]);
const update = (patch: Partial<BuilderState>) => {
setValue(build({ ...state, ...patch }, value));
};
const numberField = (
key: "minuteStep" | "hourStep" | "minute" | "dayOfMonth",
label: string,
min: number,
max: number,
describedBy?: string,
) => (
<NumberField
id={`${uid}-${key}`}
label={label}
min={min}
max={max}
value={state[key]}
describedBy={describedBy}
onCommit={(next) => {
update({ [key]: next });
}}
/>
);
const hasTime = ["day", "week", "month", "year"].includes(state.frequency);
const hasDayOfMonth = state.frequency === "month" || state.frequency === "year";
const dayHintId = `${uid}-day-hint`;
const daysHintId = `${uid}-days-hint`;
return (
<div
data-slot="cron-builder"
data-frequency={state.frequency}
className={cn("flex flex-wrap items-end gap-3", className)}
{...props}
>
<div className="flex flex-col gap-1.5">
<label htmlFor={`${uid}-frequency`} className="text-xs font-medium">
Frequency
</label>
<Select
value={state.frequency}
onValueChange={(frequency) => {
update({ frequency: frequency as Frequency });
}}
>
<SelectTrigger id={`${uid}-frequency`} className="w-40">
<SelectValue />
</SelectTrigger>
<SelectContent>
{FREQUENCIES.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{state.frequency === "minute"
? numberField("minuteStep", "Every how many minutes", 1, 59)
: null}
{state.frequency === "hour" ? (
<>
{numberField("hourStep", "Every how many hours", 1, 23)}
{numberField("minute", "At minute", 0, 59)}
</>
) : null}
{hasTime ? (
<div className="flex flex-col gap-1.5">
<label htmlFor={`${uid}-time`} className="text-xs font-medium">
At
</label>
<Input
id={`${uid}-time`}
type="time"
inputSize="sm"
className="w-32"
value={`${String(state.hour).padStart(2, "0")}:${String(state.minute).padStart(2, "0")}`}
onChange={(event) => {
const [hour, minute] = event.target.value.split(":").map(Number);
if (
hour === undefined ||
minute === undefined ||
Number.isNaN(hour) ||
Number.isNaN(minute)
) {
return;
}
update({ hour, minute });
}}
/>
</div>
) : null}
{state.frequency === "week" ? (
<div className="flex flex-col gap-1.5">
<span id={`${uid}-days-label`} className="text-xs font-medium">
On
</span>
<div
role="group"
aria-labelledby={`${uid}-days-label`}
aria-describedby={daysHintId}
className="flex gap-1"
>
{WEEK.map((day) => {
const pressed = state.days.includes(day);
const last = pressed && state.days.length === 1;
return (
<button
key={day}
type="button"
aria-pressed={pressed}
aria-label={weekdayName(day, locale, "long")}
className={cn(
"h-8 min-w-9 rounded-md border px-2 text-xs font-medium transition-colors",
pressed
? "border-primary bg-primary text-primary-foreground"
: "border-input bg-background hover:bg-accent hover:text-accent-foreground",
focusRing,
)}
onClick={() => {
// The last day stays: a week schedule with no day is not a
// schedule. The group's description says so, so the refusal
// is not silent.
if (last) return;
update({
days: pressed
? state.days.filter((d) => d !== day)
: [...state.days, day],
});
}}
>
{weekdayName(day, locale, "short")}
</button>
);
})}
</div>
<p id={daysHintId} className="text-xs text-muted-foreground">
Pick at least one day.
</p>
</div>
) : null}
{hasDayOfMonth ? (
<div className="flex flex-col gap-1.5">
{numberField(
"dayOfMonth",
"On day",
1,
31,
state.dayOfMonth > 28 ? dayHintId : undefined,
)}
{state.dayOfMonth > 28 ? (
<p id={dayHintId} data-slot="cron-day-hint" className="text-xs text-warning">
Months with fewer days skip this run.
</p>
) : null}
</div>
) : null}
{state.frequency === "year" ? (
<div className="flex flex-col gap-1.5">
<label htmlFor={`${uid}-month`} className="text-xs font-medium">
In
</label>
<Select
value={String(state.month)}
onValueChange={(month) => {
update({ month: Number(month) });
}}
>
<SelectTrigger id={`${uid}-month`} className="w-36">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Array.from({ length: 12 }, (_, index) => index + 1).map((month) => (
<SelectItem key={month} value={String(month)}>
{monthName(month, locale)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
) : null}
{state.frequency === "custom" ? (
<p className="text-xs text-muted-foreground">
This schedule needs the expression. Edit it below.
</p>
) : null}
</div>
);
}
/**
* A number the builder owns, with the text the reader is typing kept apart
* from the value — so clearing the field to type a new number does not snap
* it back to the old one after the first keystroke.
*/
function NumberField({
id,
label,
min,
max,
value,
describedBy,
onCommit,
}: {
id: string;
label: string;
min: number;
max: number;
value: number;
describedBy?: string;
onCommit: (value: number) => void;
}) {
const [draft, setDraft] = useState(String(value));
const [seen, setSeen] = useState(value);
if (seen !== value) {
setSeen(value);
setDraft(String(value));
}
return (
<div className="flex flex-col gap-1.5">
<label htmlFor={id} className="text-xs font-medium">
{label}
</label>
<Input
id={id}
type="number"
inputSize="sm"
min={min}
max={max}
value={draft}
aria-describedby={describedBy}
className="w-24"
onChange={(event) => {
const text = event.target.value;
setDraft(text);
const next = Number(text);
if (text === "" || !Number.isInteger(next) || next < min || next > max) return;
if (next !== value) onCommit(next);
}}
onBlur={() => {
// Whatever was left half-typed goes back to the value in force.
if (draft !== String(value)) setDraft(String(value));
}}
/>
</div>
);
}
/* ------------------------------------------------------------------ */
/* Expression */
/* ------------------------------------------------------------------ */
export type CronExpressionProps = Omit<ComponentPropsWithRef<"div">, "children">;
/**
* The expression, editable, with its reading underneath — or the reason it is
* invalid. An invalid entry is never applied: the last valid schedule stands
* until the field says something that parses.
*/
export function CronExpression({ className, ...props }: CronExpressionProps) {
const { value, setValue, locale } = useCron("CronExpression");
const id = useId();
const [draft, setDraft] = useState(value);
// The value can change under the field — the builder, or the consumer.
const [lastValue, setLastValue] = useState(value);
if (value !== lastValue) {
setLastValue(value);
setDraft(value);
}
const feedback = useMemo(() => {
try {
return { text: describeCron(draft, { locale }), error: null as string | null };
} catch (thrown) {
return {
text: null,
error: thrown instanceof CronExpressionError ? thrown.message : "Invalid schedule.",
};
}
}, [draft, locale]);
return (
<div
data-slot="cron-expression"
className={cn("flex flex-col gap-1.5", className)}
{...props}
>
<label htmlFor={`${id}-input`} className="text-xs font-medium">
Expression
</label>
<Input
id={`${id}-input`}
inputSize="sm"
value={draft}
spellCheck={false}
autoComplete="off"
aria-invalid={feedback.error ? true : undefined}
aria-describedby={`${id}-feedback`}
className="font-mono"
onChange={(event) => {
const next = event.target.value;
setDraft(next);
try {
parseCron(next);
setValue(next.trim());
} catch {
// Said in the feedback; the last valid schedule stands.
}
}}
/>
{/* One element for both, so a reader hears the error replace the
reading rather than both at once. */}
<p
id={`${id}-feedback`}
data-slot="cron-feedback"
className={cn("text-xs", feedback.error ? "text-destructive" : "text-muted-foreground")}
>
{feedback.error ?? feedback.text}
</p>
</div>
);
}
/* ------------------------------------------------------------------ */
/* Next runs */
/* ------------------------------------------------------------------ */
export interface CronNextRunsProps extends Omit<ComponentPropsWithRef<"div">, "children"> {
count?: number;
}
/**
* When it will fire, in the zone it is defined in. The zone is in the
* heading because a time with no zone is the classic scheduling mistake, and
* a schedule that never runs says so rather than showing an empty list.
*/
export function CronNextRuns({ className, count = 5, ...props }: CronNextRunsProps) {
const { value, schedule, timeZone, locale, now } = useCron("CronNextRuns");
const headingId = useId();
const runs = useMemo(
() => (now && schedule ? nextRuns(value, { from: now, count, timeZone }) : null),
[value, schedule, now, count, timeZone],
);
// Nothing clock-dependent before the clock is known.
if (!runs) return null;
const format = new Intl.DateTimeFormat(locale, {
dateStyle: "medium",
timeStyle: "short",
timeZone,
});
return (
<div
data-slot="cron-next-runs"
className={cn("flex flex-col gap-1.5", className)}
{...props}
>
<p id={headingId} className="text-xs font-medium">
Next {count === 1 ? "run" : `${String(count)} runs`}
<span className="font-normal text-muted-foreground">
{" · "}
{timeZone ?? "local time"}
</span>
</p>
{runs.length === 0 ? (
<p data-slot="cron-never" className="text-xs text-destructive">
Never runs: no day matches this schedule.
</p>
) : (
<ol
aria-labelledby={headingId}
className="m-0 flex list-none flex-col gap-0.5 p-0 text-sm"
>
{runs.map((run) => (
<li key={run.toISOString()}>
<time dateTime={run.toISOString()} className="tabular-nums">
{format.format(run)}
</time>
</li>
))}
{runs.length < count ? (
<li className="text-xs text-muted-foreground">Then nothing within five years.</li>
) : null}
</ol>
)}
</div>
);
}