April 11, 2026

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

By SEN LLC (@sendotltd) • 4 min read
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 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

Screenshot

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 '*'
  }
}
Enter fullscreen mode Exit fullscreen mode

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(' ')
}
Enter fullscreen mode Exit fullscreen mode

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)
  }
}
Enter fullscreen mode Exit fullscreen mode

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]}`,
  // ...
}
Enter fullscreen mode Exit fullscreen mode

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')
Enter fullscreen mode Exit fullscreen mode

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'
  )
})
Enter fullscreen mode Exit fullscreen mode

Series

This is entry #12 in my 100+ public portfolio series, and together with Cron TZ Viewer (entry #1) forms a complete cron toolkit.

Feedback welcome.

Ready to build your next web project? Let's work together.