Skip to content

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 →