← Runnable Projects

阅读中文版本

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.

intermediatejavascriptunverified

Project

node-cron-next-run

Entry point: cron.mjs

Install

node --version

Run

node cron.mjs "*/15 9-17 * * 1-5"

Test

node --test cron.test.mjs

Expected 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.