Bonus
Cron schedule builder
Plain-English controls that emit a cron expression
Plain-English controls that emit a cron expression
Build a schedule builder with Tailwind CSS that produces a cron expression. Use a max-w-xl card with a heading and a muted line saying the expression updates as you choose. The controls are a frequency select with hourly, daily, weekly and monthly options; a minute select; an hour select hidden for hourly; a row of seven weekday toggle chips shown only for weekly; and a day-of-month select shown only for monthly. Under the controls, a dark result panel showing the cron expression in large monospaced text with a copy button, and beneath it a plain-English summary sentence and a muted line listing the next three run times. Then add JavaScript that shows and hides the relevant controls per frequency, assembles the five cron fields, writes the human summary, computes three upcoming runs from the current time, and copies the expression with a brief confirmation. <section class="mx-auto max-w-xl px-6 py-14">
<div class="rounded-2xl border border-neutral-200 bg-white p-8 shadow-sm">
<h1 class="text-xl font-semibold tracking-tight text-neutral-900">Schedule builder</h1>
<p class="mt-1.5 text-sm text-neutral-500">Choose when it should run — the expression writes itself.</p>
<div class="mt-7 space-y-4">
<div>
<label class="mb-1.5 block text-xs font-medium text-neutral-700">Frequency</label>
<select id="cronFreq" class="w-full rounded-xl border border-neutral-200 bg-white px-3.5 py-2.5 text-sm outline-none transition focus:border-neutral-900">
<option value="hourly">Every hour</option>
<option value="daily" selected>Every day</option>
<option value="weekly">Every week</option>
<option value="monthly">Every month</option>
</select>
</div>
<div class="grid gap-4 sm:grid-cols-2">
<div id="cronHourWrap">
<label class="mb-1.5 block text-xs font-medium text-neutral-700">Hour</label>
<select id="cronHour" class="w-full rounded-xl border border-neutral-200 bg-white px-3.5 py-2.5 text-sm outline-none transition focus:border-neutral-900"></select>
</div>
<div>
<label class="mb-1.5 block text-xs font-medium text-neutral-700">Minute</label>
<select id="cronMinute" class="w-full rounded-xl border border-neutral-200 bg-white px-3.5 py-2.5 text-sm outline-none transition focus:border-neutral-900"></select>
</div>
</div>
<div id="cronDaysWrap" class="hidden">
<p class="mb-2 text-xs font-medium text-neutral-700">Days</p>
<div id="cronDays" class="flex flex-wrap gap-1.5">
<button data-day="1" class="cron-day rounded-lg border border-neutral-900 bg-neutral-900 px-3 py-1.5 text-xs font-medium text-white">Mon</button>
<button data-day="2" class="cron-day rounded-lg border border-neutral-200 px-3 py-1.5 text-xs text-neutral-600">Tue</button>
<button data-day="3" class="cron-day rounded-lg border border-neutral-200 px-3 py-1.5 text-xs text-neutral-600">Wed</button>
<button data-day="4" class="cron-day rounded-lg border border-neutral-200 px-3 py-1.5 text-xs text-neutral-600">Thu</button>
<button data-day="5" class="cron-day rounded-lg border border-neutral-200 px-3 py-1.5 text-xs text-neutral-600">Fri</button>
<button data-day="6" class="cron-day rounded-lg border border-neutral-200 px-3 py-1.5 text-xs text-neutral-600">Sat</button>
<button data-day="0" class="cron-day rounded-lg border border-neutral-200 px-3 py-1.5 text-xs text-neutral-600">Sun</button>
</div>
</div>
<div id="cronDomWrap" class="hidden">
<label class="mb-1.5 block text-xs font-medium text-neutral-700">Day of month</label>
<select id="cronDom" class="w-full rounded-xl border border-neutral-200 bg-white px-3.5 py-2.5 text-sm outline-none transition focus:border-neutral-900"></select>
</div>
</div>
<div class="mt-7 rounded-xl bg-neutral-900 p-5">
<div class="flex items-center justify-between gap-4">
<code id="cronOut" class="font-mono text-xl text-white">0 9 * * *</code>
<button id="cronCopy" class="shrink-0 rounded-lg border border-neutral-700 px-3 py-1.5 text-xs font-medium text-neutral-300 transition hover:bg-neutral-800">Copy</button>
</div>
<p id="cronHuman" class="mt-3 text-sm text-neutral-400">Runs every day at 09:00.</p>
</div>
<p id="cronNext" class="mt-3 font-mono text-xs text-neutral-400"></p>
</div>
</section> var DAY_NAMES = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
var freq = document.getElementById("cronFreq");
var hour = document.getElementById("cronHour");
var minute = document.getElementById("cronMinute");
var dom = document.getElementById("cronDom");
var hourWrap = document.getElementById("cronHourWrap");
var daysWrap = document.getElementById("cronDaysWrap");
var domWrap = document.getElementById("cronDomWrap");
var out = document.getElementById("cronOut");
var human = document.getElementById("cronHuman");
var next = document.getElementById("cronNext");
var copy = document.getElementById("cronCopy");
var days = Array.prototype.slice.call(document.querySelectorAll(".cron-day"));
var selectedDays = [1];
function pad(n) {
return String(n).padStart(2, "0");
}
function fill(select, count, step) {
for (var i = 0; i < count; i += step || 1) {
var option = document.createElement("option");
option.value = i;
option.textContent = pad(i);
select.appendChild(option);
}
}
fill(hour, 24, 1);
fill(minute, 60, 5);
for (var d = 1; d <= 28; d++) {
var option = document.createElement("option");
option.value = d;
option.textContent = d;
dom.appendChild(option);
}
hour.value = 9;
minute.value = 0;
function build() {
var f = freq.value;
var m = minute.value;
var h = hour.value;
hourWrap.classList.toggle("hidden", f === "hourly");
daysWrap.classList.toggle("hidden", f !== "weekly");
domWrap.classList.toggle("hidden", f !== "monthly");
var expression;
var sentence;
if (f === "hourly") {
expression = m + " * * * *";
sentence = "Runs every hour at " + pad(m) + " minutes past.";
} else if (f === "daily") {
expression = m + " " + h + " * * *";
sentence = "Runs every day at " + pad(h) + ":" + pad(m) + ".";
} else if (f === "weekly") {
// An empty selection would silently mean "every day" in cron — avoid that.
var list = selectedDays.length ? selectedDays.slice().sort() : [1];
expression = m + " " + h + " * * " + list.join(",");
sentence =
"Runs at " + pad(h) + ":" + pad(m) + " on " +
list.map(function (n) { return DAY_NAMES[n]; }).join(", ") + ".";
} else {
expression = m + " " + h + " " + dom.value + " * *";
sentence = "Runs at " + pad(h) + ":" + pad(m) + " on day " + dom.value + " of every month.";
}
out.textContent = expression;
human.textContent = sentence;
preview(f, Number(m), Number(h));
}
function preview(f, m, h) {
var runs = [];
var cursor = new Date();
cursor.setSeconds(0, 0);
var LIMIT = 60 * 24 * 100;
for (var i = 0; runs.length < 3 && i < LIMIT; i++) {
if (f === "hourly") {
cursor = new Date(cursor.getTime() + 60000);
if (cursor.getMinutes() === m) runs.push(new Date(cursor));
} else {
cursor = new Date(cursor.getTime() + 60000);
if (cursor.getHours() !== h || cursor.getMinutes() !== m) continue;
if (f === "weekly" && selectedDays.indexOf(cursor.getDay()) === -1) continue;
if (f === "monthly" && cursor.getDate() !== Number(dom.value)) continue;
runs.push(new Date(cursor));
}
}
next.textContent = runs.length
? "Next runs · " + runs.map(function (r) {
return r.toLocaleString("en-GB", { day: "2-digit", month: "short", hour: "2-digit", minute: "2-digit" });
}).join(" · ")
: "";
}
days.forEach(function (btn) {
btn.addEventListener("click", function () {
var n = Number(btn.dataset.day);
var at = selectedDays.indexOf(n);
if (at === -1) selectedDays.push(n);
else selectedDays.splice(at, 1);
var on = selectedDays.indexOf(n) !== -1;
btn.classList.toggle("bg-neutral-900", on);
btn.classList.toggle("border-neutral-900", on);
btn.classList.toggle("text-white", on);
btn.classList.toggle("font-medium", on);
btn.classList.toggle("border-neutral-200", !on);
btn.classList.toggle("text-neutral-600", !on);
build();
});
});
[freq, hour, minute, dom].forEach(function (el) {
el.addEventListener("change", build);
});
copy.addEventListener("click", function () {
navigator.clipboard.writeText(out.textContent).then(function () {
copy.textContent = "Copied";
copy.classList.add("text-green-400");
setTimeout(function () {
copy.textContent = "Copy";
copy.classList.remove("text-green-400");
}, 1500);
});
});
build();