Cron Next-Run Calculator
A five-field cron expression parser supporting lists, ranges, steps, and wildcards, computing the next run after a given time with minute precision.
Project
node-cron-next-run
Entry point: cron.mjs
Install
node --versionRun
node cron.mjs "*/15 9-17 * * 1-5"Test
node --test cron.test.mjsExpected output
Given the expression */15 9-17 * * 1-5 and a Monday morning time, the CLI prints the next quarter-hour slot inside the business window.Dependencies
- node >=20
What it does
Parses five-field cron expressions with lists, ranges, steps, and wildcards, then computes the next run after a given time in UTC with minute precision.
export const FIELDS = [
{ name: 'minute', min: 0, max: 59 },
{ name: 'hour', min: 0, max: 23 },
{ name: 'dayOfMonth', min: 1, max: 31 },
{ name: 'month', min: 1, max: 12 },
{ name: 'dayOfWeek', min: 0, max: 6 },
];
export function parseField(spec, field) {
const allowed = new Set();
for (const part of spec.split(',')) {
let body = part;
let step = 1;
if (part.includes('/')) {
const [rangePart, stepPart] = part.split('/');
body = rangePart;
step = Number(stepPart);
if (!Number.isInteger(step) || step < 1) throw new Error('bad step in ' + part);
}
let from = field.min;
let to = field.max;
if (body !== '*') {
const range = body.includes('-') ? body.split('-') : [body, body];
from = Number(range[0]);
to = Number(range[1]);
for (const n of [from, to]) {
if (!Number.isInteger(n) || n < field.min || n > field.max) throw new Error('value out of range: ' + part);
}
}
for (let v = from; v <= to; v += step) allowed.add(v);
}
return allowed;
}
export function parseCron(expression) {
const parts = expression.trim().split(/\s+/);
if (parts.length !== 5) throw new Error('expected 5 fields, got ' + parts.length);
return Object.fromEntries(FIELDS.map((f, i) => [f.name, parseField(parts[i], f)]));
}
export function nextRun(expression, from) {
const cron = parseCron(expression);
const d = new Date(from.getTime());
d.setUTCSeconds(0, 0);
d.setUTCMinutes(d.getUTCMinutes() + 1);
for (let i = 0; i < 60 * 24 * 400; i++) {
if (cron.month.has(d.getUTCMonth() + 1)
&& cron.dayOfMonth.has(d.getUTCDate())
&& cron.dayOfWeek.has(d.getUTCDay())
&& cron.hour.has(d.getUTCHours())
&& cron.minute.has(d.getUTCMinutes())) {
return d;
}
d.setUTCMinutes(d.getUTCMinutes() + 1);
}
throw new Error('no matching run within 400 days');
}
const expr = process.argv[2] ?? '*/15 * * * *';
const from = process.argv[3] ? new Date(process.argv[3]) : new Date();
console.log(nextRun(expr, from).toISOString().replace('T', ' ').slice(0, 16));
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { nextRun, parseCron, parseField, FIELDS } from './cron.mjs';
const T = (s) => new Date('2026-10-19T' + s + ':00Z');
test('every-minute expression returns the next minute', () => {
const next = nextRun('* * * * *', T('09:00'));
assert.equal(next.toISOString(), '2026-10-19T09:01:00.000Z');
});
test('step expressions land on the grid', () => {
const next = nextRun('*/15 * * * *', T('09:07'));
assert.equal(next.toISOString(), '2026-10-19T09:15:00.000Z');
});
test('business hours window skips evenings and weekends', () => {
const next = nextRun('0 9-17 * * 1-5', new Date('2026-10-23T18:00:00Z')); // Friday evening
assert.equal(next.toISOString(), '2026-10-26T09:00:00.000Z'); // Monday 09:00
});
test('lists and ranges combine in one field', () => {
const allowed = parseField('1,5,10-12', FIELDS[0]);
assert.deepEqual([...allowed].sort((a, b) => a - b), [1, 5, 10, 11, 12]);
});
test('field count and range errors are explicit', () => {
assert.throws(() => parseCron('* * * *'), /expected 5 fields/);
assert.throws(() => parseCron('60 * * * *'), /out of range/);
});
Run and verify
node --test cron.test.mjs
node cron.mjs "*/15 9-17 * * 1-5"
Run the tests including the weekend-skip vector, then ask the CLI when the business-hours expression fires next.
Expected Output
The test run should print five passing tests, including the Friday-evening vector that resolves to Monday 09:00 UTC. The CLI’s console.log output should print the next matching slot in YYYY-MM-DD HH:mm form. Evaluating the business-hours expression */15 9-17 * * 1-5 from 2026-10-19 09:07 UTC (the step-grid test vector), the Output: is:
2026-10-19 09:15
The calculator advances minute by minute, so 09:07 rolls forward to the next quarter-hour grid point inside the 9–17 window; weekend gaps are skipped by the day-of-week field, and an expression with no match within 400 days exits with an explicit error.
Limitations
Minute resolution only; day-of-week 7 (Sunday alias), named months, and timezone-aware evaluation are not supported.