Cron Expression Explainer — JavaScript source
Translate any 5-field cron expression into plain English, build one field-by-field, and preview the next time it will fire. Supports steps, ranges, lists, and named days/months. Runs 100% in your browser.
This is the JavaScript implementation — the same logic the interactive tool runs, in a shareable, citable form.
// Pure 5-field cron parser, natural-language explainer, builder, and next-run
// calculator - JavaScript polyglot port.
//
// Language: JavaScript
// CosmoDev polyglot showcase port of the "cron-explainer" tool.
// Ported from src/lib/cron-explainer.ts - display source, part of CosmoDev's
// polyglot tool pages.
//
// Zero deps. Deterministic. Times are interpreted as UTC so results are
// unambiguous and DST-independent (the caller controls the instant).
'use strict';
// ─── Field model ─────────────────────────────────────────────────────────────
//
// The five cron fields in positional order, each with its numeric range and
// whether it accepts named tokens (JAN..DEC / SUN..SAT). `wrapMax` is true
// only for day-of-week, where 7 is treated as an alias for 0 (Sunday).
const FIELDS = [
{ name: 'minute', label: 'minute', min: 0, max: 59, named: false, wrapMax: false },
{ name: 'hour', label: 'hour', min: 0, max: 23, named: false, wrapMax: false },
{ name: 'day-of-month', label: 'day-of-month', min: 1, max: 31, named: false, wrapMax: false },
{ name: 'month', label: 'month', min: 1, max: 12, named: true, wrapMax: false },
{ name: 'day-of-week', label: 'day-of-week', min: 0, max: 7, named: true, wrapMax: true },
];
const MONTH_NAMES = [
'January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December',
];
const DOW_NAMES = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
const MONTH_TOKENS = {
JAN: 1, FEB: 2, MAR: 3, APR: 4, MAY: 5, JUN: 6,
JUL: 7, AUG: 8, SEP: 9, OCT: 10, NOV: 11, DEC: 12,
};
const DOW_TOKENS = { SUN: 0, MON: 1, TUE: 2, WED: 3, THU: 4, FRI: 5, SAT: 6 };
const pad2 = (n) => String(n).padStart(2, '0');
const monthName = (m) => MONTH_NAMES[m - 1];
const dowName = (d) => DOW_NAMES[d % 7];
// Inclusive integer range, e.g. range(1, 5) => [1, 2, 3, 4, 5].
function range(lo, hi) {
const out = [];
for (let i = lo; i <= hi; i++) out.push(i);
return out;
}
// Parse a strictly-numeric token (digits only). Rejects named tokens, signs,
// and surrounding garbage so malformed fields surface as a clear error.
function parseIntStrict(s, label) {
const t = s.trim();
if (!/^\d+$/.test(t)) throw new Error(`${label}: invalid number "${s}"`);
return Number(t);
}
/**
* Replace named tokens (JAN..DEC / SUN..SAT) with their numeric values.
* Uses global substring replacement so ranges like "JUN-AUG" and lists like
* "MON,WED,FRI" normalize in a single pass over the field.
*/
function normalize(value, meta) {
let v = value.trim().toUpperCase();
if (!meta.named) return v;
const tokens = meta.name === 'month' ? MONTH_TOKENS : DOW_TOKENS;
for (const [tok, num] of Object.entries(tokens)) v = v.split(tok).join(String(num));
return v;
}
/**
* Expand one field value into the explicit set of numbers it matches.
* Handles `*`, `*\/N`, `A-B`, `A-B/N`, `A` (single), `A/N` (A to field max),
* and comma-separated lists of any of these. Returns the deduped, sorted
* values plus a `wildcard` flag distinguishing a bare `*`.
*/
function expandField(value, meta) {
const norm = normalize(value, meta);
if (norm === '') throw new Error(`${meta.label}: empty field`);
if (norm === '*') return { values: range(meta.min, meta.max), wildcard: true };
const out = new Set();
for (const term of norm.split(',')) {
if (term === '') throw new Error(`${meta.label}: empty list item`);
const slashIdx = term.indexOf('/');
let base = term;
let step = 1;
if (slashIdx !== -1) {
base = term.slice(0, slashIdx);
step = parseIntStrict(term.slice(slashIdx + 1), meta.label);
if (step <= 0) throw new Error(`${meta.label}: step must be a positive number`);
}
let lo;
let hi;
if (base === '*') {
lo = meta.min;
hi = meta.max;
} else if (base.includes('-')) {
const dash = base.indexOf('-');
lo = parseIntStrict(base.slice(0, dash), meta.label);
hi = parseIntStrict(base.slice(dash + 1), meta.label);
} else {
lo = parseIntStrict(base, meta.label);
// "A/step" runs from A to the field max; a bare "A" is a single value.
hi = slashIdx !== -1 ? meta.max : lo;
}
if (lo > hi) throw new Error(`${meta.label}: range start ${lo} is greater than end ${hi}`);
if (lo < meta.min) throw new Error(`${meta.label}: value ${lo} is below minimum ${meta.min}`);
if (hi > meta.max) throw new Error(`${meta.label}: value ${hi} is above maximum ${meta.max}`);
for (let v = lo; v <= hi; v += step) out.add(meta.wrapMax && v === meta.max ? meta.min : v);
}
return { values: [...out].sort((a, b) => a - b), wildcard: false };
}
// Parse all five fields. Returns { ok, parts } on success or { ok:false, error }.
function parseExpr(expr) {
const tokens = expr.trim().split(/\s+/).filter(Boolean);
if (tokens.length !== 5) {
return { ok: false, error: `Expected 5 fields (minute hour day-of-month month day-of-week), got ${tokens.length}` };
}
const parts = [];
for (let i = 0; i < 5; i++) {
try {
const expanded = expandField(tokens[i], FIELDS[i]);
parts.push({ meta: FIELDS[i], raw: tokens[i], ...expanded });
} catch (e) {
return { ok: false, error: e instanceof Error ? e.message : `Invalid ${FIELDS[i].name} field` };
}
}
return { ok: true, parts };
}
// True when a sorted value list is a contiguous run (e.g. [3,4,5,6]).
function isContiguous(values) {
for (let i = 1; i < values.length; i++) {
if (values[i] - values[i - 1] !== 1) return false;
}
return true;
}
// Describe a single value in the field's own vocabulary.
function singleValue(n, meta) {
switch (meta.name) {
case 'minute': return `minute ${n}`;
case 'hour': return `hour ${n}`;
case 'day-of-month': return `day ${n} of the month`;
case 'month': return monthName(n);
case 'day-of-week': return dowName(n);
default: return String(n);
}
}
/**
* Describe a parsed field as a human phrase (no leading preposition).
* `raw` is consulted to distinguish step syntax (star/N or A-B/N) from plain
* lists, since two different raw forms can expand to the same value set.
*/
function describeField(p) {
const { meta, raw, values } = p;
if (p.wildcard) {
switch (meta.name) {
case 'minute': return 'every minute';
case 'hour': return 'every hour';
case 'day-of-month': return 'every day of the month';
case 'month': return 'every month';
case 'day-of-week': return 'every day of the week';
default: return '';
}
}
// Step syntax is reported as "every N <units>".
if (raw.includes('/') && values.length >= 1) {
const step = parseIntStrict(raw.slice(raw.indexOf('/') + 1), meta.label);
const start = values[0];
const unitPlural = meta.name === 'day-of-month' ? 'days of the month'
: meta.name === 'day-of-week' ? 'days of the week'
: `${meta.name}s`;
if (start === meta.min) return `every ${step} ${unitPlural}`;
return `every ${step} ${unitPlural} starting at ${singleValue(start, meta)}`;
}
if (values.length === 1) return singleValue(values[0], meta);
if (isContiguous(values)) {
const a = values[0];
const b = values[values.length - 1];
if (meta.name === 'month') return `${monthName(a)} through ${monthName(b)}`;
if (meta.name === 'day-of-week') return `${dowName(a)} through ${dowName(b)}`;
const unitPlural = meta.name === 'day-of-month' ? 'days' : `${meta.name}s`;
return `${unitPlural} ${a} through ${b}`;
}
// Explicit list of discrete values.
if (meta.name === 'month') return values.map(monthName).join(', ');
if (meta.name === 'day-of-week') return values.map(dowName).join(', ');
if (meta.name === 'minute') return `minutes ${values.join(', ')}`;
if (meta.name === 'hour') return `hours ${values.join(', ')}`;
return `days ${values.join(', ')} of the month`;
}
// Prepend a preposition, but never before a phrase that already leads with
// "every" (e.g. "every day of the week" reads wrong as "on every …").
function prepend(prefix, phrase) {
return phrase.startsWith('every') ? phrase : `${prefix} ${phrase}`;
}
// Compose the opening time-of-day clause from the minute and hour fields.
function timeClause(minute, hour) {
const mAll = minute.wildcard;
const hAll = hour.wildcard;
const mSingle = !mAll && minute.values.length === 1;
const hSingle = !hAll && hour.values.length === 1;
if (mAll && hAll) return 'Every minute';
if (mAll && hSingle) return `Every minute of hour ${hour.values[0]}`;
if (mSingle && hAll) return `At minute ${minute.values[0]} of every hour`;
if (mSingle && hSingle) return `At ${pad2(hour.values[0])}:${pad2(minute.values[0])}`;
// Mixed: describe each non-wildcard field, hour first.
const clauses = [];
if (!hAll) clauses.push(describeField(hour));
if (!mAll) clauses.push(describeField(minute));
const s = clauses.join(', ');
return s.charAt(0).toUpperCase() + s.slice(1);
}
function composeDescription(parts) {
const [minute, hour, dom, month, dow] = parts;
const clauses = [timeClause(minute, hour)];
if (!dom.wildcard) clauses.push(prepend('on', describeField(dom)));
if (!month.wildcard) clauses.push(prepend('in', describeField(month)));
if (!dow.wildcard) clauses.push(prepend('on', describeField(dow)));
return clauses.join(', ');
}
/**
* Parse and explain a 5-field cron expression in plain English.
*
* @example
* explainCron('30 14 * * *').description // 'At 14:30'
*/
function explainCron(expr) {
const res = parseExpr(expr);
if (!res.ok) return { valid: false, description: '', fields: [], error: res.error };
const fields = res.parts.map((p) => ({
field: p.meta.name,
value: p.raw,
meaning: describeField(p),
}));
return { valid: true, description: composeDescription(res.parts), fields };
}
/**
* Assemble a 5-field cron expression from per-field specs. Each field defaults
* to `*` when empty/omitted; invalid fields throw so callers cannot build a
* malformed expression.
*
* @example
* buildCron({ minute: '30', hour: '14' }) // '30 14 * * *'
*/
function buildCron(opts = {}) {
const specs = [
{ meta: FIELDS[0], value: opts.minute },
{ meta: FIELDS[1], value: opts.hour },
{ meta: FIELDS[2], value: opts.dom },
{ meta: FIELDS[3], value: opts.month },
{ meta: FIELDS[4], value: opts.dow },
];
const out = [];
for (const s of specs) {
const v = (s.value ?? '').trim();
if (v === '') { out.push('*'); continue; }
expandField(v, s.meta); // validates; throws on bad input
out.push(v);
}
return out.join(' ');
}
/**
* Next time the expression fires, strictly after `after`, evaluated in UTC.
* Implements standard Vixie-cron day matching: when BOTH day-of-month and
* day-of-week are restricted, a match on either suffices (OR); otherwise both
* must match (AND). Returns null if no firing occurs within ~3 years.
*/
function nextRun(expr, after) {
const res = parseExpr(expr);
if (!res.ok) return null;
const [minute, hour, dom, month, dow] = res.parts;
const mSet = new Set(minute.values);
const hSet = new Set(hour.values);
const domSet = new Set(dom.values);
const monSet = new Set(month.values);
const dowSet = new Set(dow.values);
const domWild = dom.wildcard;
const dowWild = dow.wildcard;
// Start at the top of the minute following `after` (UTC), seconds zeroed.
const cur = new Date(
Date.UTC(after.getUTCFullYear(), after.getUTCMonth(), after.getUTCDate(),
after.getUTCHours(), after.getUTCMinutes(), 0, 0),
);
cur.setUTCMinutes(cur.getUTCMinutes() + 1);
const limit = cur.getUTCFullYear() + 3; // hard stop ~3 years out
while (cur.getUTCFullYear() < limit) {
if (!monSet.has(cur.getUTCMonth() + 1)) {
cur.setUTCMonth(cur.getUTCMonth() + 1, 1);
cur.setUTCHours(0, 0, 0, 0);
continue;
}
const domOk = domSet.has(cur.getUTCDate());
const dowOk = dowSet.has(cur.getUTCDay());
const dayOk = domWild || dowWild ? domOk && dowOk : domOk || dowOk;
if (!dayOk) {
cur.setUTCDate(cur.getUTCDate() + 1);
cur.setUTCHours(0, 0, 0, 0);
continue;
}
if (!hSet.has(cur.getUTCHours())) {
cur.setUTCHours(cur.getUTCHours() + 1, 0, 0, 0);
continue;
}
if (!mSet.has(cur.getUTCMinutes())) {
cur.setUTCMinutes(cur.getUTCMinutes() + 1, 0, 0);
continue;
}
return new Date(cur.getTime());
}
return null;
}
module.exports = { explainCron, buildCron, nextRun };
Also available in 13 other languages
Every CosmoDev tool ships its pure logic in TypeScript (web) and Go (CLI), with authored implementations in a dozen-plus languages — the same contract, ported. Compare all languages side by side →