DEV Community

SEN LLC
SEN LLC

Posted on

Cron Expression to Natural Language — In English and Japanese

Cron Expression to Natural Language — In English and Japanese

30 14 * * 1-5 is "at 14:30 on weekdays". */15 * * * * is "every 15 minutes". 0 0 1 * * is "at midnight on the first of every month". Parsing cron expressions is straightforward; the interesting work is generating grammatical natural language descriptions in two languages.

cron is one of those tools where the syntax is concise but opaque. Every time I write a cron line I second-guess whether I got the day-of-week field right. A describer that says "yes, that runs at 2:30 PM on weekdays" closes the loop.

🔗 Live demo: https://sen.ltd/portfolio/cron-describe/
📦 GitHub: https://github.com/sen-ltd/cron-describe

Screenshot

Features:

  • Parse standard 5-field cron + shortcuts (@hourly, @daily, @weekly, @monthly, @yearly, @reboot)
  • Natural language description in English AND Japanese
  • Next 5 fire times
  • 16 example patterns
  • Validation with errors
  • Japanese / English UI
  • Zero dependencies, 55 tests

Parsing a cron field

Each field (minute, hour, day-of-month, month, day-of-week) supports the same grammar:

  • * — every value in range
  • N — specific value
  • N,M,O — list
  • N-M — range
  • N-M/K or */K — step
export function parseField(str, min, max) {
  if (str === '*' || str === '?') return { all: true, values: range(min, max) };

  // Step: */5 or 1-10/2
  if (str.includes('/')) {
    const [base, step] = str.split('/');
    const baseValues = base === '*' ? range(min, max) : parseField(base, min, max).values;
    return { step: parseInt(step), values: baseValues.filter((_, i) => i % parseInt(step) === 0) };
  }

  // List
  if (str.includes(',')) {
    return { list: true, values: str.split(',').flatMap(s => parseField(s, min, max).values) };
  }

  // Range
  if (str.includes('-')) {
    const [start, end] = str.split('-').map(Number);
    return { range: true, values: range(start, end) };
  }

  // Value
  return { value: parseInt(str), values: [parseInt(str)] };
}
Enter fullscreen mode Exit fullscreen mode

The result object tracks both what kind of pattern it was (for description) and the actual list of values (for fire-time calculation). The values array is what getNextFireTimes walks when finding the next match.

Describing a field

The description generator picks the highest-level pattern and produces English from it:

function describeMinuteField(field) {
  if (field.all) return 'every minute';
  if (field.step) return `every ${field.step} minutes`;
  if (field.list) return `at minute ${field.values.join(', ')}`;
  if (field.range) return `from minute ${field.values[0]} through ${field.values[field.values.length - 1]}`;
  return `at minute ${field.value}`;
}
Enter fullscreen mode Exit fullscreen mode

Combining field descriptions requires some grammar awareness:

  • "at 14:30 on Monday" (hour + minute + dow)
  • "at the start of every hour on weekdays" (special case: minute=0)
  • "every 15 minutes on weekdays" (step)
  • "at midnight on the first of every month" (multiple specific fields)

The describer has heuristics for common combinations to avoid awkward strings like "at minute 0 at hour 0 on day 1".

Japanese description

Japanese grammar is different enough that the ja describer isn't a direct translation:

function describeJa(cron) {
  // ... "毎分" / "毎時の{分}分" / "{時}時{分}分" / ...
  if (cron.minute.all && cron.hour.all) return '毎分';
  if (!cron.minute.all && cron.hour.all) return `毎時${cron.minute.value}分`;
  // ...
}
Enter fullscreen mode Exit fullscreen mode

"At 14:30 on weekdays" becomes "平日の14時30分" — the time goes after the condition, not before. Japanese time format uses 時/分 as counter particles instead of colons.

Next fire times

Finding when a cron expression will next fire is "advance time by one minute, check if all fields match, repeat until yes":

export function getNextFireTimes(cron, count = 5, from = new Date()) {
  const result = [];
  const d = new Date(from);
  d.setSeconds(0, 0);
  d.setMinutes(d.getMinutes() + 1);

  while (result.length < count) {
    if (matches(cron, d)) {
      result.push(new Date(d));
    }
    d.setMinutes(d.getMinutes() + 1);
    // Safety: don't search more than a year
    if (d.getTime() - from.getTime() > 365 * 86400 * 1000) break;
  }
  return result;
}

function matches(cron, d) {
  return (
    cron.minute.values.includes(d.getMinutes()) &&
    cron.hour.values.includes(d.getHours()) &&
    cron.dayOfMonth.values.includes(d.getDate()) &&
    cron.month.values.includes(d.getMonth() + 1) &&
    cron.dayOfWeek.values.includes(d.getDay())
  );
}
Enter fullscreen mode Exit fullscreen mode

Not the fastest algorithm, but simple and correct. For typical patterns (running within a few hours), it returns in milliseconds. For sparse patterns (e.g., "every February 29"), it searches ahead up to a year before giving up.

@reboot is special

@reboot has no "next fire time" — it only fires when the system boots. The parser returns a marker object, getNextFireTimes returns empty, and the describer says "at system reboot" / "システム起動時".

Series

This is entry #95 in my 100+ public portfolio series.

Top comments (0)