A GUI Cron Builder — And Why Building Is Way Easier Than Parsing
A GUI Cron Builder — And Why Building Is Way Easier Than Parsing My first portfolio...
A GUI Cron Builder — And Why Building Is Way Easier Than Parsing
My first portfolio entry, Cron TZ Viewer, parses cron expressions and shows the next fire time across timezones. The most common feedback I got wasn't about that tool — it was "I don't know how to write the cron expression in the first place." So here's the reverse direction, and I learned that going from structure to string is dramatically simpler than the other way around.
The first tool I built in this series was a cron parser that shows the next fire time in multiple timezones. Useful, but it assumes you already have a cron expression. The more common problem: "I want this to run every weekday at 9am — what does that look like in cron?" Unless you're already fluent, you always end up looking it up.
🔗 Live demo: https://sen.ltd/portfolio/cron-builder/
📦 GitHub: https://github.com/sen-ltd/cron-builder
Five fields (minute / hour / day / month / day-of-week), six modes each (any, every, value, list, range, range-with-step), live preview of both the cron expression and an English/Japanese description. Click the "View next fire time →" button and it hands the expression off to Cron TZ Viewer via URL query. Vanilla JS, zero deps, no build.
The inverse direction is genuinely simpler
Writing a cron parser means taking 0 9-17 * * 1-5 and figuring out which of */5, 1-10, 1,3,5, 1-20/3, *, or a plain number each field is. That's six patterns per field × five fields, plus error reporting for malformed input. Even a minimal parser gets regex-heavy fast.
Writing the builder side is much simpler because the user has already told you the mode. A single switch does it:
export function buildField(config) {
if (!config || typeof config !== 'object') return '*'
switch (config.mode) {
case 'any': return '*'
case 'every': return `*/${config.step}`
case 'value': return String(config.value)
case 'list': return config.values.join(',')
case 'range': return `${config.from}-${config.to}`
case 'rangeStep': return `${config.from}-${config.to}/${config.step}`
default: return '*'
}
}
The user clicked "range" in the UI, so you don't have to infer it from the string. That's the whole deal. Whenever you're writing a bidirectional tool — parser + generator over the same grammar — the generator side is usually 3-5× shorter. It's a nice illustration of an asymmetry that shows up in language work everywhere: reconstructing structure from a flat string is hard; the opposite direction is trivial.
buildCron is a one-liner that composes the five fields
export function buildCron({ minute, hour, dom, month, dow }) {
return [
buildField(minute),
buildField(hour),
buildField(dom),
buildField(month),
buildField(dow),
].join(' ')
}
Destructure the config object, buildField each entry, join with spaces. That's it. This only works because POSIX crontab treats its five fields as a pure product — any combination is valid. AWS EventBridge and quartz cron dialects have cross-field constraints (day-of-month and day-of-week can't both be specified), which would force additional validation here. Plain crontab gives us a free pass.
The explanation text is a 2D field × mode table
The real UX win isn't the cron string — it's the human-readable description next to it. "Every weekday at 9:00 AM" reassures the user their UI state actually matches their intent. That description is built by looking up a label table indexed by field name and mode:
const LABELS = {
en: {
minute: {
any: 'every minute',
every: (n) => `every ${n} minutes`,
value: (n) => `at minute ${n}`,
list: (vals) => `at minutes ${vals.join(', ')}`,
range: (a, b) => `minutes ${a} through ${b}`,
rangeStep: (a, b, s) => `minutes ${a} through ${b}, every ${s}`,
},
hour: { /* ... */ },
dom: { /* ... */ },
month: { /* ... */ },
dow: { /* ... */ },
},
ja: { /* Japanese equivalents */ },
}
export function explainField(config, field, lang = 'en') {
const L = LABELS[lang]
switch (config.mode) {
case 'any': return L[field].any
case 'every': return L[field].every(config.step)
case 'value': return L[field].value(config.value)
case 'list': return L[field].list(config.values)
case 'range': return L[field].range(config.from, config.to)
case 'rangeStep': return L[field].rangeStep(config.from, config.to, config.step)
}
}
Fields that deserve named output (months and weekdays) get their own string arrays so value: 1 on the month field renders as January rather than at month 1:
const MONTH_EN = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
month: {
value: (n) => `in ${MONTH_EN[n - 1]}`,
// ...
}
Translating the numbers into names doubles the educational value of the tool for users who don't yet know cron by heart. "At minute 0 of hour 9 on day 1 of the week" vs "at 9:00 AM on Monday" is the difference between useful and opaque.
Linking the two tools via URL query
Once you've built an expression, clicking "View next fire time →" opens the sibling tool with the cron string pre-filled:
const viewerUrl = new URL('https://sen.ltd/portfolio/cron-tz-viewer/')
viewerUrl.searchParams.set('cron', cronString)
window.open(viewerUrl.toString(), '_blank')
Cron TZ Viewer already supported ?cron= in its query string from day one, so integrating the two was literally a single line. Static tools linking via URL query strings is a nice low-effort pattern — no backend, no shared state, no component library to agree on. Whichever tool is older just needs to handle the query param, and future tools can deep-link in.
Tests
14 cases on node --test. Input/output pairs for the core cases:
test('every 5 minutes', () => {
assert.equal(
buildCron({
minute: { mode: 'every', step: 5 },
hour: { mode: 'any' },
dom: { mode: 'any' },
month: { mode: 'any' },
dow: { mode: 'any' },
}),
'*/5 * * * *'
)
})
test('weekdays 9 to 17', () => {
assert.equal(
buildCron({
minute: { mode: 'value', value: 0 },
hour: { mode: 'range', from: 9, to: 17 },
dom: { mode: 'any' },
month: { mode: 'any' },
dow: { mode: 'range', from: 1, to: 5 },
}),
'0 9-17 * * 1-5'
)
})
test('explain range in en', () => {
assert.equal(
explainField({ mode: 'range', from: 9, to: 17 }, 'hour', 'en'),
'hours 9 through 17'
)
})
Series
This is entry #12 in my 100+ public portfolio series, and together with Cron TZ Viewer (entry #1) forms a complete cron toolkit.
- 📦 Repo: https://github.com/sen-ltd/cron-builder
- 🌐 Live: https://sen.ltd/portfolio/cron-builder/
- 🏢 Company: https://sen.ltd/
Feedback welcome.
