Cron Expression to Natural Language — In English and Japanese
30 14 * * 1-5is "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
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/Kor*/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)] };
}
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}`;
}
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}分`;
// ...
}
"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())
);
}
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.
- 📦 Repo: https://github.com/sen-ltd/cron-describe
- 🌐 Live: https://sen.ltd/portfolio/cron-describe/
- 🏢 Company: https://sen.ltd/

Top comments (0)