Cron Translator
by sloprun seed
2026-08-14
0 reported it broke · the ▶ count is distinct visitors who ran it, counted once each, anonymously
Cron is five fields in a row that tell a computer when to repeat a job, and almost nobody can read one at a glance. This opens with */15 9-17 * * 1-5 already translated — "every 15 minutes from 9:00 AM to 5:45 PM, Monday through Friday" — beside the actual next five run times, worked out on real calendar dates with leap years and month lengths included, plus a seven-day map of which hours it lands on. Hover any of the five boxes to see just that field explained and every value it expands to, or click a preset: try "the OR trap" to watch a date and a weekday quietly multiply how often a job fires, and "leap day only" to watch the countdown jump to the next leap year. Type something wrong on purpose too — it points at the offending box, says which range that field actually accepts, and keeps explaining the parts it can still read.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Cron Translator — sloprun.dev</title>
<style>
/* sloprun design tokens — inline this block into every demo (self-contained rule).
Identity: instrument-panel. Machine facts in mono; human words in sans.
Green is EARNED: only for "it ran / it worked" states, never decoration. */
:root {
--bg: #F6F6F3; /* neutral paper, not cream */
--surface: #FFFFFF;
--ink: #1A1D21;
--muted: #5A6068;
--line: #E3E2DC;
--accent: #C05600; /* phosphor amber, darkened for light bg */
--accent-soft: #FFF3E6;
--run: #1A7F37; /* earned green */
--run-soft: #E7F4EA;
--danger: #C93C37;
--radius: 10px;
--font-sans: system-ui, -apple-system, "Segoe UI", sans-serif;
--font-mono: ui-monospace, "SF Mono", "Cascadia Code", Menlo, Consolas, monospace;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--bg: #14161A; --surface: #1C1F26; --ink: #E8E6E1; --muted: #9AA0A8;
--line: #2A2E36; --accent: #FFB454; --accent-soft: #2A2118;
--run: #3FB950; --run-soft: #16281B; --danger: #F47067;
}
}
:root[data-theme="dark"] {
--bg: #14161A; --surface: #1C1F26; --ink: #E8E6E1; --muted: #9AA0A8;
--line: #2A2E36; --accent: #FFB454; --accent-soft: #2A2118;
--run: #3FB950; --run-soft: #16281B; --danger: #F47067;
}
body { background: var(--bg); color: var(--ink); font-family: var(--font-sans); }
/* Native chrome — caret, text selection, scrollbars — has to follow the same three
theme states as the tokens above, or the inputs keep a light caret on a dark panel. */
:root { color-scheme: light; }
@media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) { color-scheme: dark; } }
:root[data-theme="dark"] { color-scheme: dark; }
:root[data-theme="light"] { color-scheme: light; }
/* ---------------- shell ---------------- */
* { box-sizing: border-box; }
[hidden] { display: none !important; }
html, body { margin: 0; padding: 0; }
body { font-size: 16px; line-height: 1.55; -webkit-text-size-adjust: 100%; overflow-x: hidden; }
.wrap { max-width: 1060px; margin: 0 auto; padding: 26px 18px 34px; }
h1 { font-size: 1.55rem; line-height: 1.15; margin: 0 0 7px; letter-spacing: -.02em; }
h2 { font-size: .95rem; margin: 0; letter-spacing: -.005em; }
.lede { margin: 0; color: var(--muted); font-size: .95rem; max-width: 64ch; }
.lede b { color: var(--ink); font-weight: 600; }
.sr { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden;
clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
.mono, code { font-family: var(--font-mono); }
.spec { display: flex; flex-wrap: wrap; gap: 6px 14px; margin: 13px 0 0; }
.spec b {
font-family: var(--font-mono); font-weight: 400; font-size: .67rem; letter-spacing: .07em;
text-transform: uppercase; color: var(--muted); display: inline-flex; align-items: center; gap: 6px;
}
.spec b::before { content: ""; width: 5px; height: 5px; border-radius: 50%; background: var(--line); }
.spec b.hot { color: var(--accent); }
.spec b.hot::before { background: var(--accent); }
.k-lab { font-family: var(--font-mono); font-size: .64rem; letter-spacing: .12em;
text-transform: uppercase; color: var(--muted); }
.card { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius);
padding: 14px; margin-top: 14px; }
.cardhead { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 10px; margin-bottom: 11px; }
.headright { margin-left: auto; display: flex; flex-wrap: wrap; align-items: center; gap: 6px; }
.btn { font-family: var(--font-mono); font-size: .72rem; letter-spacing: .02em; background: var(--bg);
color: var(--ink); border: 1px solid var(--line); border-radius: 999px; padding: 6px 11px;
min-height: 30px; cursor: pointer; transition: border-color .15s, color .15s, background .15s; }
.btn:hover { border-color: var(--accent); color: var(--accent); }
.btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.btn[aria-pressed="true"] { background: var(--accent-soft); border-color: var(--accent); color: var(--accent); }
.btn.ok { background: var(--run-soft); border-color: var(--run); color: var(--run); }
.pill { font-family: var(--font-mono); font-size: .66rem; letter-spacing: .09em; text-transform: uppercase;
border: 1px solid var(--line); border-radius: 999px; padding: 4px 10px; color: var(--muted); }
.pill.good { color: var(--run); border-color: var(--run); background: var(--run-soft); }
.pill.bad { color: var(--danger); border-color: var(--danger); background: transparent; }
/* ---------------- expression bar ---------------- */
.bar { display: flex; flex-wrap: wrap; gap: 7px; }
.seg { flex: 1 1 104px; min-width: 0; display: flex; flex-direction: column; gap: 2px;
background: var(--bg); border: 1px solid var(--line); border-radius: 9px; padding: 7px 9px 6px;
transition: border-color .15s, background .15s; }
.seg.on { border-color: var(--accent); background: var(--accent-soft); }
.seg.bad { border-color: var(--danger); background: transparent; }
.seg:focus-within { border-color: var(--accent); box-shadow: 0 0 0 2px var(--accent); }
.seglab { font-family: var(--font-mono); font-size: .58rem; letter-spacing: .11em; text-transform: uppercase;
color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.seg.on .seglab { color: var(--accent); }
.seg.bad .seglab { color: var(--danger); }
.segin { font-family: var(--font-mono); font-size: 1.02rem; font-weight: 600; letter-spacing: -.01em;
background: transparent; border: 0; color: var(--ink); width: 100%; min-width: 0; padding: 1px 0; }
.segin:focus { outline: none; }
.seg.bad .segin { color: var(--danger); }
.segrange { font-family: var(--font-mono); font-size: .58rem; color: var(--muted); }
.caption { font-family: var(--font-mono); font-size: .7rem; color: var(--muted); margin: 9px 0 0;
min-height: 1.5em; line-height: 1.5; }
.caption b { color: var(--accent); font-weight: 600; }
.caption i { font-style: normal; color: var(--ink); }
/* ---------------- error ---------------- */
.err { border: 1px solid var(--danger); border-left-width: 3px; border-radius: 8px; padding: 10px 12px;
margin-top: 11px; }
.err .et { font-family: var(--font-mono); font-size: .64rem; letter-spacing: .11em; text-transform: uppercase;
color: var(--danger); }
.err .em { font-size: .9rem; margin: 3px 0 0; }
.err .eh { font-size: .82rem; color: var(--muted); margin: 4px 0 0; }
.err code { background: var(--bg); border: 1px solid var(--line); border-radius: 4px; padding: 0 4px; font-size: .85em; }
.err + .err { margin-top: 8px; }
/* ---------------- the sentence ---------------- */
.say { margin-top: 14px; padding: 13px 14px; border-radius: 9px; background: var(--bg);
border: 1px solid var(--line); border-left: 3px solid var(--accent); }
.sentence { margin: 5px 0 0; font-size: 1.16rem; line-height: 1.45; letter-spacing: -.012em; }
.sentence em { font-style: normal; font-family: var(--font-mono); font-weight: 600; color: var(--accent);
font-size: .93em; letter-spacing: -.01em; }
.sentence.dim { color: var(--muted); font-size: 1rem; }
.note { margin-top: 10px; padding: 9px 12px; border-radius: 8px; background: var(--accent-soft);
border: 1px solid var(--accent); font-size: .84rem; }
.note b { font-family: var(--font-mono); font-size: .7rem; letter-spacing: .09em; text-transform: uppercase;
color: var(--accent); display: block; }
.note em { font-style: normal; font-weight: 700; color: var(--accent); }
/* ---------------- tiles ---------------- */
.tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(112px, 1fr)); gap: 7px; margin-top: 12px; }
.tile { background: var(--bg); border: 1px solid var(--line); border-radius: 8px; padding: 8px 10px; }
.tl { display: block; font-family: var(--font-mono); font-size: .58rem; letter-spacing: .11em;
text-transform: uppercase; color: var(--muted); }
.tv { display: block; font-family: var(--font-mono); font-size: 1.02rem; font-weight: 600; margin-top: 2px;
letter-spacing: -.02em; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.tv.q { color: var(--muted); font-weight: 400; }
/* ---------------- chips ---------------- */
.chiprow { margin-top: 14px; }
.chips { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 7px; }
.chip { text-align: left; font: inherit; color: var(--ink); background: var(--bg);
border: 1px solid var(--line); border-radius: 8px; padding: 6px 9px; cursor: pointer;
display: flex; flex-direction: column; gap: 1px;
transition: border-color .15s, background .15s; }
.chip:hover { border-color: var(--accent); }
.chip:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.chip[aria-pressed="true"] { border-color: var(--accent); background: var(--accent-soft); }
.chip-t { font-family: var(--font-sans); font-size: .8rem; line-height: 1.25; color: var(--ink); }
.chip-c { font-family: var(--font-mono); font-size: .66rem; color: var(--muted); }
.chip[aria-pressed="true"] .chip-t, .chip:hover .chip-t,
.chip[aria-pressed="true"] .chip-c, .chip:hover .chip-c { color: var(--accent); }
.hint { font-family: var(--font-mono); font-size: .66rem; color: var(--muted); margin: 9px 0 0; }
/* ---------------- two-column ---------------- */
.grid2 { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; align-items: start; }
.grid2 > .card { margin-top: 14px; }
@media (max-width: 780px) { .grid2 { grid-template-columns: 1fr; gap: 0; } }
/* ---------------- runs ---------------- */
.runs { list-style: none; margin: 0; padding: 0; }
.runs li { display: grid; grid-template-columns: 20px 1fr auto; gap: 1px 9px; padding: 8px 0;
border-top: 1px dashed var(--line); align-items: baseline; }
.runs li:first-child { border-top: 0; padding-top: 2px; }
.rn { font-family: var(--font-mono); font-size: .68rem; color: var(--muted); }
.rd { font-family: var(--font-mono); font-size: .9rem; letter-spacing: -.01em; }
.rt { font-family: var(--font-mono); font-size: .9rem; font-weight: 600; letter-spacing: -.01em; white-space: nowrap; }
.rsub { grid-column: 2 / -1; font-family: var(--font-mono); font-size: .66rem; color: var(--muted); }
.runs li.first .rt, .runs li.first .rd { color: var(--accent); }
.runs li.first .rn { color: var(--accent); }
.foot-note { font-family: var(--font-mono); font-size: .64rem; color: var(--muted); margin: 11px 0 0;
padding-top: 9px; border-top: 1px solid var(--line); line-height: 1.5; }
/* the empty/never message is a plain paragraph, not a run row — without this it
inherits the grid above and every word lands in its own cell */
.runs li.empty { display: block; border-top: 0; padding: 2px 0 4px; }
.empty { font-size: .88rem; color: var(--muted); margin: 6px 0; line-height: 1.5; }
/* ---------------- field cards ---------------- */
.fcards { display: flex; flex-direction: column; gap: 6px; }
.fcard { display: block; width: 100%; text-align: left; background: var(--bg); border: 1px solid var(--line);
border-radius: 8px; padding: 8px 10px; cursor: pointer; font: inherit; color: inherit;
transition: border-color .15s, background .15s; }
.fcard:hover { border-color: var(--accent); }
.fcard:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.fcard.on { border-color: var(--accent); background: var(--accent-soft); }
.fcard.bad { border-color: var(--danger); }
.fhead { display: flex; align-items: baseline; gap: 8px; }
.fname { font-family: var(--font-mono); font-size: .6rem; letter-spacing: .11em; text-transform: uppercase;
color: var(--muted); }
.fcard.on .fname { color: var(--accent); }
.fraw { font-family: var(--font-mono); font-size: .84rem; font-weight: 600; margin-left: auto;
max-width: 45%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.fcard.bad .fraw { color: var(--danger); }
.fsay { display: block; font-size: .84rem; margin: 2px 0 0; line-height: 1.4; }
.fcard.bad .fsay { color: var(--danger); }
.fvals { display: block; font-family: var(--font-mono); font-size: .66rem; color: var(--muted);
margin: 3px 0 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* ---------------- heat grid ---------------- */
.heat { display: flex; flex-direction: column; gap: 3px; }
.hrow { display: grid; grid-template-columns: 76px 1fr 40px; gap: 8px; align-items: center; }
.hlab { font-family: var(--font-mono); font-size: .66rem; color: var(--muted); white-space: nowrap;
overflow: hidden; text-overflow: clip; }
.hl-s { display: none; }
.hrow.today .hlab { color: var(--accent); }
.cells { display: grid; grid-template-columns: repeat(24, 1fr); gap: 2px; }
.cell { height: 15px; border-radius: 2px; background: var(--line); }
.cell.hit { background: var(--accent); }
/* passed hours stay legible as "was scheduled, already gone" — at .28 they vanished
into the dark surface and read as missing squares instead of spent ones */
.cell.past { opacity: .42; }
.hcount { font-family: var(--font-mono); font-size: .66rem; color: var(--muted); text-align: right; }
.hcount.z { opacity: .4; }
.ticks { display: grid; grid-template-columns: repeat(24, 1fr); gap: 2px; }
.ticks span { font-family: var(--font-mono); font-size: .56rem; color: var(--muted); }
.legend { display: flex; align-items: center; gap: 6px; margin: 11px 0 0; font-family: var(--font-mono);
font-size: .64rem; color: var(--muted); flex-wrap: wrap; }
.swatch { display: inline-flex; gap: 2px; }
.swatch i { width: 11px; height: 11px; border-radius: 2px; background: var(--accent); display: block; }
@media (max-width: 460px) {
.hl-l { display: none; }
.hl-s { display: inline; }
.hrow { grid-template-columns: 46px 1fr 30px; gap: 5px; }
.cell { height: 13px; }
.cells, .ticks { gap: 1px; }
.sentence { font-size: 1.06rem; }
}
footer { margin-top: 22px; font-family: var(--font-mono); font-size: .68rem; color: var(--muted); }
@keyframes blip { 0%, 100% { opacity: 1; } 50% { opacity: .45; } }
.live-dot { display: inline-block; width: 6px; height: 6px; border-radius: 50%; background: var(--accent);
animation: blip 2s ease-in-out infinite; vertical-align: middle; margin-right: 5px; }
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { animation: none !important; transition: none !important; scroll-behavior: auto !important; }
}
</style>
</head>
<body>
<div class="wrap">
<header>
<h1>Cron Translator</h1>
<p class="lede">Cron is the five-number code that tells a computer <b>when to repeat a job</b>.
Type one and read it back in plain English — with the exact times it fires next, worked out
on real calendar dates.</p>
<div class="spec">
<b>live as you type</b>
<b>real date maths</b>
<b>no network</b>
<b class="hot" id="specStat">next run in —</b>
</div>
</header>
<section class="card">
<div class="cardhead">
<h2>The expression</h2>
<div class="headright">
<span class="pill good" id="pillStatus">parses</span>
<button class="btn" type="button" id="btnFmt" aria-pressed="false" title="Show times as 09:00 instead of 9:00 AM">24-hour</button>
<button class="btn" type="button" id="btnCopy">copy</button>
</div>
</div>
<div class="bar" id="bar"></div>
<p class="caption" id="caption"></p>
<p class="hint" id="captionFlash" hidden></p>
<div id="errs"></div>
<div class="say">
<span class="k-lab">in plain english</span>
<p class="sentence" id="sentence">…</p>
</div>
<div class="note" id="orNote" hidden>
<b>the classic surprise</b>
You set both a date <em>and</em> a weekday. Cron fires when <em>either</em> one matches — not both
together. So this runs more often than it looks.
</div>
<div class="tiles" id="tiles">
<div class="tile"><span class="tl">next run</span><span class="tv" id="tvNext">—</span></div>
<div class="tile"><span class="tl" id="tlGap">gap between</span><span class="tv" id="tvGap">—</span></div>
<div class="tile"><span class="tl">runs ahead · 7d</span><span class="tv" id="tvWeek">—</span></div>
<div class="tile"><span class="tl">days it fires</span><span class="tv" id="tvDays">—</span></div>
</div>
<div class="chiprow">
<span class="k-lab">common patterns</span>
<div class="chips" id="chips"></div>
<p class="hint">paste a whole expression into any box — <code>@daily</code>, <code>@hourly</code>, <code>@weekly</code> and friends expand too</p>
</div>
</section>
<div class="grid2">
<section class="card">
<div class="cardhead">
<h2>Next 5 runs</h2>
<span class="k-lab headright" id="tzLab">local time</span>
</div>
<ol class="runs" id="runs"></ol>
<p class="foot-note" id="runsNote"></p>
</section>
<section class="card">
<div class="cardhead">
<h2>Field by field</h2>
<span class="k-lab headright">hover or tap a field</span>
</div>
<div class="fcards" id="fcards"></div>
</section>
</div>
<section class="card">
<div class="cardhead">
<h2>The next 7 days</h2>
<span class="k-lab headright" id="weekTotal">—</span>
</div>
<div class="heat" id="heat"></div>
<p class="legend">
<span>quiet</span>
<span class="swatch" aria-hidden="true"><i style="opacity:.16"></i><i style="opacity:.38"></i><i style="opacity:.62"></i><i style="opacity:.85"></i><i style="opacity:1"></i></span>
<span>busy · one square = one hour · faded squares already passed</span>
</p>
</section>
<footer>demo · sloprun.dev</footer>
<p class="sr" id="live" aria-live="polite"></p>
</div>
<script>
(function () {
"use strict";
/* ============================ small helpers ============================ */
function el(id) { return document.getElementById(id); }
function esc(s) { return String(s).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">"); }
function pad(n) { return n < 10 ? "0" + n : "" + n; }
/* messages are built as HTML (they carry <code> spans); this is the version that goes
into textContent — tags off, and the escapes undone so a typed "<" reads as "<" */
function plain(s) {
return String(s).replace(/<[^>]+>/g, "")
.replace(/</g, "<").replace(/>/g, ">").replace(/&/g, "&");
}
function M(s) { return '<em>' + esc(s) + '</em>'; }
function joinList(a) {
if (a.length === 0) return "";
if (a.length === 1) return a[0];
if (a.length === 2) return a[0] + " and " + a[1];
return a.slice(0, -1).join(", ") + " and " + a[a.length - 1];
}
function ord(n) {
var s = ["th", "st", "nd", "rd"], v = n % 100;
return n + (s[(v - 20) % 10] || s[v] || s[0]);
}
var DOW_FULL = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
var DOW_ABBR = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
var MON_FULL = ["January", "February", "March", "April", "May", "June", "July",
"August", "September", "October", "November", "December"];
var MON_ABBR = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
var DOW_NAMES = { sun: 0, mon: 1, tue: 2, wed: 3, thu: 4, fri: 5, sat: 6 };
var MON_NAMES = { jan: 1, feb: 2, mar: 3, apr: 4, may: 5, jun: 6, jul: 7,
aug: 8, sep: 9, oct: 10, nov: 11, dec: 12 };
var FIELDS = [
{ key: "minute", label: "minute", range: "0–59", min: 0, max: 59, span: 60 },
{ key: "hour", label: "hour", range: "0–23", min: 0, max: 23, span: 24 },
{ key: "dom", label: "day of month", range: "1–31", min: 1, max: 31, span: 31, noWrapStep: true },
{ key: "month", label: "month", range: "1–12 · jan", min: 1, max: 12, span: 12, names: MON_NAMES },
{ key: "dow", label: "day of week", range: "0–6 · sun", min: 0, max: 6, span: 7, names: DOW_NAMES, alt: 7 }
];
var SHORTHAND = {
"@yearly": "0 0 1 1 *",
"@annually": "0 0 1 1 *",
"@monthly": "0 0 1 * *",
"@weekly": "0 0 * * 0",
"@daily": "0 0 * * *",
"@midnight": "0 0 * * *",
"@hourly": "0 * * * *"
};
var PRESETS = [
{ t: "workday check-in", c: "*/15 9-17 * * 1-5" },
{ t: "top of every hour", c: "0 * * * *" },
{ t: "every 5 minutes", c: "*/5 * * * *" },
{ t: "nightly tidy-up", c: "30 3 * * *" },
{ t: "Monday morning", c: "0 9 * * 1" },
{ t: "twice a day", c: "0 8,20 * * *" },
{ t: "first of the month", c: "0 0 1 * *" },
{ t: "weekend brunch", c: "45 10 * * sat,sun" },
{ t: "quarterly", c: "0 6 1 1,4,7,10 *" },
{ t: "leap day only", c: "0 0 29 2 *" },
{ t: "the OR trap", c: "0 0 1 * 1" }
];
var state = {
v: ["*/15", "9-17", "*", "*", "1-5"],
h24: false,
hover: -1,
runs: [],
ok: false
};
/* ============================ time formatting ============================ */
function fmtTime(h, m) {
if (state.h24) return pad(h) + ":" + pad(m);
var ap = h < 12 ? "AM" : "PM", hh = h % 12;
if (hh === 0) hh = 12;
return hh + ":" + pad(m) + " " + ap;
}
function hourWord(h) {
if (state.h24) return pad(h) + ":00";
var ap = h < 12 ? "AM" : "PM", hh = h % 12;
if (hh === 0) hh = 12;
return hh + " " + ap;
}
function fmtDate(d, now) {
var s = DOW_ABBR[d.getDay()] + " " + MON_ABBR[d.getMonth()] + " " + d.getDate();
if (d.getFullYear() !== now.getFullYear()) s += " " + d.getFullYear();
return s;
}
function rel(ms) {
if (ms < 0) ms = 0;
var s = Math.floor(ms / 1000);
if (s < 60) return s + "s";
var m = Math.floor(s / 60);
if (m < 60) return m + "m " + pad(s % 60) + "s";
var h = Math.floor(m / 60);
if (h < 24) return h + "h " + pad(m % 60) + "m";
var d = Math.floor(h / 24);
if (d < 60) return d + "d " + (h % 24) + "h";
var mo = Math.round(d / 30.44);
if (mo < 22) return mo + " months";
return (d / 365.25).toFixed(1) + " years";
}
function fmtGap(ms) {
var m = Math.round(ms / 60000);
if (m < 60) return m + " min";
if (m < 1440) return (m % 60 === 0) ? (m / 60) + (m === 60 ? " hour" : " hours")
: Math.floor(m / 60) + "h " + (m % 60) + "m";
if (m % 1440 === 0) return (m / 1440) + (m === 1440 ? " day" : " days");
return (m / 1440).toFixed(1) + " days";
}
/* ============================ the parser ============================ */
function mkErr(f, msg, hint) { return { cronError: true, field: f, msg: msg, hint: hint }; }
function rangeHint(f) {
if (f.key === "dow") return "Weekdays run 0–6 (Sunday is 0). 7 also means Sunday, and names like <code>mon</code> work.";
if (f.key === "month") return "Months run 1–12, or names like <code>jan</code>.";
if (f.key === "hour") return "Hours run 0–23. Midnight is <code>0</code>, not 24.";
if (f.key === "minute") return "Minutes run 0–59.";
return "Dates run 1–31.";
}
function num(txt, f, piece) {
var t = String(txt).trim();
if (t === "") throw mkErr(f, "A number is missing in <code>" + esc(piece) + "</code>.", rangeHint(f));
if (f.names) {
var k = t.toLowerCase().slice(0, 3);
if (Object.prototype.hasOwnProperty.call(f.names, k) && /^[a-z]+$/i.test(t)) return f.names[k];
}
if (!/^\d+$/.test(t)) {
return (function () {
throw mkErr(f, "<code>" + esc(t) + "</code> isn't something the " + f.label + " field understands.",
rangeHint(f) + " Use <code>*</code> for “any”.");
})();
}
var v = parseInt(t, 10), hi = f.alt || f.max;
if (v < f.min || v > hi) {
throw mkErr(f, "<code>" + esc(t) + "</code> is outside " + f.min + "–" + f.max + " for the " + f.label + " field.",
rangeHint(f));
}
return v;
}
function parseField(raw, f) {
var src = String(raw).trim();
if (src === "") throw mkErr(f, "The " + f.label + " field is empty.", "Every field needs something. Use <code>*</code> to mean “any " + f.label + "”.");
if (src === "?" && (f.key === "dom" || f.key === "dow")) src = "*";
if (/\s/.test(src)) throw mkErr(f, "There's a space inside the " + f.label + " field.", "Each of the five boxes holds one field. Separate several values with commas, like <code>1,15</code>.");
var pieces = src.split(","), set = {}, order = [], star = false;
for (var i = 0; i < pieces.length; i++) {
var p = pieces[i].trim();
if (p === "") throw mkErr(f, "There's an empty piece in <code>" + esc(src) + "</code>.", "Two commas in a row, or a stray comma at the end — drop the extra one.");
var step = 1, body = p, slash = p.indexOf("/");
if (slash >= 0) {
if (p.indexOf("/", slash + 1) >= 0) throw mkErr(f, "<code>" + esc(p) + "</code> has more than one <code>/</code>.", "A piece takes at most one step, like <code>*/5</code> or <code>0-30/10</code>.");
body = p.slice(0, slash).trim();
var sp = p.slice(slash + 1).trim();
if (!/^\d+$/.test(sp)) throw mkErr(f, "<code>" + esc(p) + "</code> — whatever follows <code>/</code> has to be a whole number.", "The step says how far to jump: <code>" + esc(body || "*") + "/2</code> takes every second one.");
step = parseInt(sp, 10);
if (step < 1) throw mkErr(f, "<code>" + esc(p) + "</code> — a step of 0 never moves.", "Steps start at 1. Every " + f.label + " is just <code>*</code>.");
if (body === "") throw mkErr(f, "<code>" + esc(p) + "</code> doesn't say what to step through.", "Write <code>*/" + step + "</code> to step across the whole range.");
}
var lo, hi;
if (body === "*") {
lo = f.min; hi = f.max;
if (step === 1) star = true;
} else {
var dash = body.indexOf("-", 1);
if (dash > 0) {
lo = num(body.slice(0, dash), f, p);
hi = num(body.slice(dash + 1), f, p);
if (lo > hi) {
throw mkErr(f, "<code>" + esc(body) + "</code> runs backwards.",
"Ranges go low to high. To wrap past the end, write two pieces: <code>" +
lo + "-" + f.max + "," + f.min + "-" + hi + "</code>.");
}
} else {
lo = num(body, f, p);
hi = (slash >= 0) ? f.max : lo;
}
}
for (var v = lo; v <= hi; v += step) {
var n = (f.key === "dow" && v === 7) ? 0 : v;
if (!set[n]) { set[n] = true; order.push(n); }
}
}
var sorted = order.slice().sort(function (a, b) { return a - b; });
if (!sorted.length) throw mkErr(f, "The " + f.label + " field matches nothing.", "Widen the range or use <code>*</code>.");
return { raw: src, set: set, sorted: sorted, star: star && pieces.length === 1, field: f };
}
function has(p, v) { return p.set[v] === true; }
/* ============================ shape analysis ============================ */
function analyze(p) {
var s = p.sorted, f = p.field, n = s.length;
if (n === f.span) return { kind: "all" };
if (n === 1) return { kind: "single", v: s[0] };
var i, contig = true;
for (i = 1; i < n; i++) if (s[i] - s[i - 1] !== 1) { contig = false; break; }
if (contig) return { kind: "range", a: s[0], b: s[n - 1] };
var step = s[1] - s[0], uni = true;
for (i = 2; i < n; i++) if (s[i] - s[i - 1] !== step) { uni = false; break; }
if (uni) {
var wrap = (f.min + f.span) - s[n - 1] + (s[0] - f.min);
if (wrap === step || (f.noWrapStep && s[0] === f.min)) return { kind: "step", s: step, from: s[0] };
if (n >= 3) return { kind: "steprange", s: step, from: s[0], to: s[n - 1] };
}
return { kind: "list" };
}
/* ============================ next-run engine ============================ */
function dayMatches(d, spec) {
var dom = d.getDate(), dow = d.getDay();
if (spec[2].star && spec[4].star) return true;
if (spec[2].star) return has(spec[4], dow);
if (spec[4].star) return has(spec[2], dom);
return has(spec[2], dom) || has(spec[4], dow);
}
function nextAfter(from, spec) {
var d = new Date(from.getTime() + 60000);
d.setSeconds(0, 0);
var stopYear = from.getFullYear() + 8;
for (var guard = 0; guard < 500000; guard++) {
if (d.getFullYear() > stopYear) return null;
if (!has(spec[3], d.getMonth() + 1)) {
d = new Date(d.getFullYear(), d.getMonth() + 1, 1, 0, 0, 0, 0);
continue;
}
if (!dayMatches(d, spec)) {
d = new Date(d.getFullYear(), d.getMonth(), d.getDate() + 1, 0, 0, 0, 0);
continue;
}
if (!has(spec[1], d.getHours())) {
d = new Date(d.getFullYear(), d.getMonth(), d.getDate(), d.getHours() + 1, 0, 0, 0);
continue;
}
if (!has(spec[0], d.getMinutes())) {
d = new Date(d.getFullYear(), d.getMonth(), d.getDate(), d.getHours(), d.getMinutes() + 1, 0, 0);
continue;
}
return d;
}
return null;
}
function nextRuns(spec, count, from) {
var out = [], cur = from;
for (var i = 0; i < count; i++) {
var n = nextAfter(cur, spec);
if (!n) break;
out.push(n);
cur = n;
}
return out;
}
/* ============================ describing one field ============================ */
function describeField(p) {
var a = analyze(p), f = p.field, s = p.sorted;
if (f.key === "minute") {
if (a.kind === "all") return "every minute of the hour";
if (a.kind === "step") return "every " + a.s + " minutes" + (a.from ? ", offset by " + a.from : "");
if (a.kind === "steprange") return "every " + a.s + " minutes from :" + pad(a.from) + " to :" + pad(a.to) + " past each hour";
if (a.kind === "single") return s[0] === 0 ? "right on the hour" : s[0] + " minutes past the hour";
if (a.kind === "range") return "minutes " + a.a + " through " + a.b + " of each hour";
return s.length + " set minutes each hour";
}
if (f.key === "hour") {
if (a.kind === "all") return "every hour, all day";
if (a.kind === "step") return "every " + a.s + " hours";
if (a.kind === "steprange") return "every " + a.s + " hours from " + hourWord(a.from) + " to " + hourWord(a.to);
if (a.kind === "single") return "the " + hourWord(s[0]) + " hour only";
if (a.kind === "range") return hourWord(a.a) + " through " + hourWord(a.b);
return joinList(s.map(hourWord));
}
if (f.key === "dom") {
if (a.kind === "all") return "any date in the month";
if (a.kind === "step") return "every " + ord(a.s) + " date from the " + ord(a.from);
if (a.kind === "single") return "only the " + ord(s[0]);
if (a.kind === "range") return "the " + ord(a.a) + " through the " + ord(a.b);
return "the " + joinList(s.map(ord));
}
if (f.key === "month") {
if (a.kind === "all") return "all twelve months";
if (a.kind === "step") return "every " + ord(a.s) + " month from " + MON_FULL[a.from - 1];
if (a.kind === "single") return MON_FULL[s[0] - 1] + " only";
if (a.kind === "range") return MON_FULL[a.a - 1] + " through " + MON_FULL[a.b - 1];
return joinList(s.map(function (m) { return MON_ABBR[m - 1]; }));
}
if (a.kind === "all") return "any day of the week";
if (s.length === 2 && s[0] === 0 && s[1] === 6) return "weekends only";
if (a.kind === "single") return DOW_FULL[s[0]] + "s only";
if (a.kind === "range") return DOW_FULL[a.a] + " through " + DOW_FULL[a.b];
return joinList(s.map(function (d) { return DOW_ABBR[d]; }));
}
function valuesPreview(p) {
var f = p.field, s = p.sorted, show;
function one(v) {
if (f.key === "minute") return ":" + pad(v);
if (f.key === "month") return MON_ABBR[v - 1];
if (f.key === "dow") return DOW_ABBR[v];
return "" + v;
}
if (s.length > 9) show = s.slice(0, 6).map(one).concat(["…", one(s[s.length - 1])]);
else show = s.map(one);
return show.join(" ") + " (" + s.length + (s.length === 1 ? " value)" : " values)");
}
/* ============================ the sentence ============================ */
function timePhrase(spec) {
var mp = spec[0], hp = spec[1];
var Ma = analyze(mp), Ha = analyze(hp);
var ms = mp.sorted, hs = hp.sorted;
var minTxt = ms.map(function (m) { return M(":" + pad(m)); });
if (Ha.kind === "all") {
if (Ma.kind === "all") return "Every minute";
if (Ma.kind === "step") return "Every " + M(Ma.s + " minutes") + (Ma.from ? ", starting at " + M(":" + pad(Ma.from)) : "");
if (Ma.kind === "steprange") return "Every " + M(Ma.s + " minutes") + " from " + M(":" + pad(Ma.from)) + " to " + M(":" + pad(Ma.to)) + " past every hour";
if (Ma.kind === "single") return ms[0] === 0 ? "At the top of every hour" : "At " + M(":" + pad(ms[0])) + " past every hour";
if (Ma.kind === "range") return "Every minute from " + M(":" + pad(Ma.a)) + " to " + M(":" + pad(Ma.b)) + " past every hour";
return "At " + joinList(minTxt) + " past every hour";
}
var contig = (Ha.kind === "range" || Ha.kind === "single");
var stepped = (Ha.kind === "step" || Ha.kind === "steprange");
var first = fmtTime(hs[0], ms[0]), last = fmtTime(hs[hs.length - 1], ms[ms.length - 1]);
var hourTxt = hs.map(function (h) { return M(hourWord(h)); });
var hourList = "the " + joinList(hourTxt) + " hours";
/* "every N hours", plus the window it covers unless it spans the whole day */
var everyHours = "", nthHour = "", hourWindow = "";
if (stepped) {
everyHours = "every " + M(Ha.s + " hours");
nthHour = "every " + M(ord(Ha.s) + " hour");
var wholeDay = (Ha.kind === "step" && hs[0] === 0 && hs.length * Ha.s >= 24);
hourWindow = wholeDay ? "" : ", " + M(hourWord(hs[0])) + " to " + M(hourWord(hs[hs.length - 1]));
}
if (Ma.kind === "all") {
if (contig) return "Every minute from " + M(first) + " to " + M(last);
if (stepped) return "Every minute of " + nthHour + hourWindow;
return "Every minute of " + hourList;
}
if (Ma.kind === "step" || Ma.kind === "steprange") {
var everyMin = "Every " + M(Ma.s + " minutes");
var minSpan = "";
if (Ma.kind === "steprange") minSpan = " (" + M(":" + pad(Ma.from)) + " to " + M(":" + pad(Ma.to)) + " past the hour)";
else if (Ma.from) minSpan = " (from " + M(":" + pad(Ma.from)) + " past the hour)";
/* a contiguous hour window already shows the offset in its first and last time */
if (contig) return everyMin + (Ma.kind === "steprange" ? minSpan : "") + " from " + M(first) + " to " + M(last);
if (stepped) return everyMin + minSpan + " of " + nthHour + hourWindow;
return everyMin + minSpan + " during " + hourList;
}
if (ms.length * hs.length <= 6) {
var times = [];
for (var i = 0; i < hs.length; i++) for (var j = 0; j < ms.length; j++) times.push(M(fmtTime(hs[i], ms[j])));
return "At " + joinList(times);
}
if (stepped) {
var head = (Ma.kind === "single")
? (ms[0] === 0 ? "On the hour" : "At " + M(":" + pad(ms[0])) + " past the hour")
: "At " + joinList(minTxt) + " past the hour";
return head + ", " + everyHours + hourWindow;
}
if (contig) return "At " + joinList(minTxt) + " past each hour from " + M(hourWord(hs[0])) + " to " + M(hourWord(hs[hs.length - 1]));
return "At " + joinList(minTxt) + " past " + hourList;
}
function domPhrase(p) {
var a = analyze(p), s = p.sorted;
if (a.kind === "all") return "on any date of the month";
if (a.kind === "single") return "on the " + M(ord(s[0])) + " of the month";
if (a.kind === "range") return "from the " + M(ord(a.a)) + " to the " + M(ord(a.b)) + " of the month";
if (a.kind === "step") return "every " + M(ord(a.s)) + " day of the month from the " + M(ord(a.from));
return "on the " + joinList(s.map(function (d) { return M(ord(d)); })) + " of the month";
}
function dowPhrase(p) {
var a = analyze(p), s = p.sorted;
if (a.kind === "all") return "on any day of the week";
if (s.length === 2 && s[0] === 0 && s[1] === 6) return "on weekends";
if (a.kind === "single") return "on " + DOW_FULL[s[0]] + "s";
if (a.kind === "range") return DOW_FULL[a.a] + " through " + DOW_FULL[a.b];
return "on " + joinList(s.map(function (d) { return DOW_FULL[d] + "s"; }));
}
function monthPhrase(p) {
var a = analyze(p), s = p.sorted;
if (a.kind === "all") return "in any month";
if (a.kind === "single") return "in " + MON_FULL[s[0] - 1];
if (a.kind === "range") return "from " + MON_FULL[a.a - 1] + " through " + MON_FULL[a.b - 1];
if (a.kind === "step") return "every " + M(ord(a.s)) + " month (" + joinList(s.map(function (m) { return MON_FULL[m - 1]; })) + ")";
return "in " + joinList(s.map(function (m) { return MON_FULL[m - 1]; }));
}
function sentenceFor(spec) {
var parts = [timePhrase(spec)];
var domStar = spec[2].star, dowStar = spec[4].star;
if (!domStar && !dowStar) parts.push(domPhrase(spec[2]) + " — or " + dowPhrase(spec[4]) + ", whichever comes first");
else if (!domStar) parts.push(domPhrase(spec[2]));
else if (!dowStar) parts.push(dowPhrase(spec[4]));
if (!spec[3].star) parts.push(monthPhrase(spec[3]));
return parts.join(", ") + ".";
}
function plainSentence(spec) {
return plain(sentenceFor(spec));
}
/* ============================ the 7-day map ============================ */
function weekMap(spec, now) {
var rows = [], total = 0, active = 0, max = 0;
var nowH = now.getHours(), nowM = now.getMinutes();
for (var d = 0; d < 7; d++) {
var day = new Date(now.getFullYear(), now.getMonth(), now.getDate() + d);
var live = has(spec[3], day.getMonth() + 1) && dayMatches(day, spec);
var cells = [], count = 0;
for (var h = 0; h < 24; h++) {
var full = (live && has(spec[1], h)) ? spec[0].sorted.length : 0;
var future = full;
if (d === 0) {
if (h < nowH) future = 0;
else if (h === nowH && full) {
future = 0;
for (var k = 0; k < spec[0].sorted.length; k++) if (spec[0].sorted[k] > nowM) future++;
}
}
if (full > max) max = full;
cells.push({ full: full, future: future, past: (d === 0 && h < nowH) });
count += future;
}
if (count > 0) active++;
total += count;
rows.push({ day: day, cells: cells, count: count, today: d === 0 });
}
return { rows: rows, total: total, active: active, max: max };
}
/* ============================ DOM building ============================ */
var bar = el("bar"), fcards = el("fcards"), chips = el("chips");
var inputs = [], segs = [], cards = [];
FIELDS.forEach(function (f, i) {
var seg = document.createElement("div");
seg.className = "seg";
seg.innerHTML =
'<label class="seglab" for="f' + i + '">' + esc(f.label) + '</label>' +
'<input class="segin" id="f' + i + '" type="text" spellcheck="false" autocomplete="off" ' +
'autocapitalize="off" autocorrect="off" aria-describedby="caption">' +
'<span class="segrange">' + f.range + '</span>';
bar.appendChild(seg);
var input = seg.querySelector("input");
input.value = state.v[i];
inputs.push(input); segs.push(seg);
input.addEventListener("input", function () { onEdit(i, input.value); });
input.addEventListener("focus", function () { setHover(i); });
input.addEventListener("blur", function () { setHover(-1); });
seg.addEventListener("mouseenter", function () { setHover(i); });
seg.addEventListener("mouseleave", function () { if (document.activeElement !== input) setHover(-1); });
input.addEventListener("paste", function (e) {
var txt = (e.clipboardData || window.clipboardData);
if (!txt) return;
var s = txt.getData("text");
if (s && (/\s/.test(s.trim()) || s.trim().charAt(0) === "@")) {
e.preventDefault();
spread(s, i);
}
});
var card = document.createElement("button");
card.className = "fcard";
card.type = "button";
card.innerHTML = '<span class="fhead"><span class="fname">' + esc(f.label) + '</span>' +
'<span class="fraw"></span></span><span class="fsay"></span><span class="fvals"></span>';
fcards.appendChild(card);
cards.push(card);
card.addEventListener("mouseenter", function () { setHover(i); });
card.addEventListener("mouseleave", function () { setHover(-1); });
card.addEventListener("focus", function () { setHover(i); });
card.addEventListener("blur", function () { setHover(-1); });
card.addEventListener("click", function () { inputs[i].focus(); inputs[i].select(); });
});
PRESETS.forEach(function (p) {
var b = document.createElement("button");
b.className = "chip";
b.type = "button";
b.setAttribute("aria-pressed", "false");
b.dataset.cron = p.c;
b.innerHTML = '<span class="chip-t">' + esc(p.t) + '</span><span class="chip-c">' + esc(p.c) + '</span>';
b.addEventListener("click", function () { spread(p.c, 0); inputs[0].focus(); });
chips.appendChild(b);
});
/* ============================ input plumbing ============================ */
function spread(text, startAt) {
var s = String(text).trim().toLowerCase();
if (Object.prototype.hasOwnProperty.call(SHORTHAND, s)) s = SHORTHAND[s];
var parts = s.split(/\s+/).filter(function (x) { return x !== ""; });
if (parts.length === 1 && startAt !== undefined) {
state.v[startAt] = parts[0];
} else if (parts.length === 6 && /^[\d*\/,-]+$/.test(parts[0])) {
// 6-field (with seconds) — keep the last five and say so
state.v = parts.slice(1);
flashNote("that looked like a 6-field expression with seconds — kept the last five");
} else if (parts.length >= 5) {
state.v = parts.slice(0, 5);
} else {
for (var i = 0; i < parts.length && (startAt + i) < 5; i++) state.v[startAt + i] = parts[i];
}
for (var j = 0; j < 5; j++) inputs[j].value = state.v[j];
render();
}
function onEdit(i, val) {
if (/\s/.test(val) ||
(val.charAt(0) === "@" && Object.prototype.hasOwnProperty.call(SHORTHAND, val.trim().toLowerCase()))) {
spread(val, i); return;
}
state.v[i] = val;
render();
}
var noteTimer = null;
function flashNote(msg) {
var h = el("captionFlash");
if (!h) return;
h.textContent = msg;
h.hidden = false;
if (noteTimer) clearTimeout(noteTimer);
noteTimer = setTimeout(function () { h.hidden = true; }, 4200);
}
function setHover(i) {
state.hover = i;
for (var k = 0; k < 5; k++) {
segs[k].classList.toggle("on", k === i && !segs[k].classList.contains("bad"));
cards[k].classList.toggle("on", k === i && !cards[k].classList.contains("bad"));
}
paintCaption();
}
var lastParsed = [];
function paintCaption() {
var c = el("caption"), i = state.hover;
if (i < 0 || !lastParsed[i]) {
c.innerHTML = '<i>five fields</i> · minute, hour, day of month, month, day of week — ' +
'<code>*</code> means “any”, <code>*/5</code> means “every 5th”, ' +
'<code>1-5</code> a range, <code>1,15</code> a list';
return;
}
var p = lastParsed[i];
c.innerHTML = '<b>' + esc(FIELDS[i].label) + '</b> · <i>' + esc(p.raw) + '</i> → ' +
esc(describeField(p)) + ' · ' + esc(valuesPreview(p));
}
/* ============================ render ============================ */
function render() {
var parsed = [], errors = [], errAt = [-1, -1, -1, -1, -1], i;
for (i = 0; i < 5; i++) {
try {
parsed[i] = parseField(state.v[i], FIELDS[i]);
} catch (e) {
parsed[i] = null;
errAt[i] = errors.length;
errors.push(e && e.cronError ? e : mkErr(FIELDS[i], "This field couldn't be read.", "Use <code>*</code> to match anything."));
}
}
lastParsed = parsed;
state.ok = errors.length === 0;
/* field boxes + cards */
for (i = 0; i < 5; i++) {
var bad = !parsed[i];
segs[i].classList.toggle("bad", bad);
cards[i].classList.toggle("bad", bad);
inputs[i].setAttribute("aria-invalid", bad ? "true" : "false");
/* point the box itself at its own error text, so a screen reader hears what's
wrong on focus rather than only the generic five-fields caption */
inputs[i].setAttribute("aria-describedby", bad ? ("err" + errAt[i] + " caption") : "caption");
var raw = cards[i].querySelector(".fraw"),
say = cards[i].querySelector(".fsay"),
vals = cards[i].querySelector(".fvals");
raw.textContent = state.v[i] === "" ? "—" : state.v[i];
if (bad) {
var e2 = errors[errAt[i]] || null;
say.innerHTML = e2 ? e2.msg : "not readable";
vals.textContent = "";
} else {
say.textContent = describeField(parsed[i]);
vals.textContent = valuesPreview(parsed[i]);
}
}
/* errors */
var box = el("errs");
box.innerHTML = errors.map(function (e, n) {
return '<div class="err" id="err' + n + '"><div class="et">' + esc(e.field.label) + ' field</div>' +
'<p class="em">' + e.msg + '</p><p class="eh">' + e.hint + '</p></div>';
}).join("");
var pill = el("pillStatus");
pill.className = "pill " + (state.ok ? "good" : "bad");
pill.textContent = state.ok ? "parses" : (errors.length === 1 ? "1 field to fix" : errors.length + " fields to fix");
var sentence = el("sentence");
if (!state.ok) {
sentence.className = "sentence dim";
sentence.textContent = "Fix the " + joinList(errors.map(function (e) { return e.field.label; })) + " field" +
(errors.length > 1 ? "s" : "") + " above and the translation comes back.";
el("live").textContent = errors.length + (errors.length === 1 ? " field can't be read: " : " fields can't be read: ") +
joinList(errors.map(function (e) { return e.field.label + " — " + plain(e.msg); }));
el("orNote").hidden = true;
blankResults();
paintCaption();
syncChips();
return;
}
sentence.className = "sentence";
sentence.innerHTML = sentenceFor(parsed);
el("orNote").hidden = !(!parsed[2].star && !parsed[4].star);
el("live").textContent = plainSentence(parsed);
paintRuns(parsed);
paintWeek(parsed);
paintCaption();
syncChips();
}
function blankResults() {
state.runs = [];
el("runs").innerHTML = '<li class="empty">Nothing to schedule yet — one of the five fields ' +
'above can\'t be read, so there are no times to work out.</li>';
el("runsNote").textContent = "";
el("heat").innerHTML = '<p class="empty">No map to draw while a field is unreadable — ' +
'fix it above and the week fills back in.</p>';
el("weekTotal").textContent = "—";
["tvNext", "tvGap", "tvWeek", "tvDays"].forEach(function (id) {
el(id).textContent = "—"; el(id).className = "tv q";
});
el("tlGap").textContent = "gap between";
el("specStat").textContent = "expression won't parse";
}
function paintRuns(spec) {
var now = new Date();
/* One walk forward serves both jobs: the rows we list, and the gap check below.
It has to reach past a day boundary — a window like 9-17 looks perfectly even
until the overnight jump shows up. */
var probe = nextRuns(spec, 80, now);
var runs = probe.slice(0, 6);
state.runs = runs;
var list = el("runs");
if (!runs.length) {
list.innerHTML = '<li class="empty">This never fires. Nothing in the next eight years matches — that ' +
'combination of date, month and weekday simply doesn\'t happen.</li>';
el("runsNote").textContent = "";
["tvNext", "tvGap", "tvWeek", "tvDays"].forEach(function (id) { el(id).textContent = "never"; el(id).className = "tv q"; });
el("tlGap").textContent = "gap between";
el("specStat").textContent = "never fires";
el("weekTotal").textContent = "0 runs";
return;
}
var html = "";
for (var i = 0; i < Math.min(5, runs.length); i++) {
var d = runs[i];
var gap = i === 0 ? (d - now) : (d - runs[i - 1]);
html += '<li class="run' + (i === 0 ? " first" : "") + '">' +
'<span class="rn">' + (i + 1) + '</span>' +
'<span class="rd">' + esc(fmtDate(d, now)) + '</span>' +
'<span class="rt">' + esc(fmtTime(d.getHours(), d.getMinutes())) + '</span>' +
'<span class="rsub" data-at="' + d.getTime() + '">' +
(i === 0 ? '<span class="live-dot"></span>in ' + esc(rel(d - now))
: "+" + esc(fmtGap(gap)) + " later") +
'</span></li>';
}
list.innerHTML = html;
var tz = "";
try { tz = Intl.DateTimeFormat().resolvedOptions().timeZone || ""; } catch (e) { tz = ""; }
el("tzLab").textContent = tz ? tz.replace(/_/g, " ") : "local time";
el("runsNote").innerHTML = "Worked out on this device's clock and calendar — leap years, month lengths and all. " +
"A real server runs cron in <i>its own</i> time zone, so check that before you trust the hour.";
/* Gap regularity. When gaps differ, report the most common one and say so in the
label rather than claiming an exactness that isn't there. */
var gaps = [], same = true;
for (var k = 1; k < probe.length; k++) gaps.push(probe[k] - probe[k - 1]);
for (k = 1; k < gaps.length; k++) if (gaps[k] !== gaps[0]) { same = false; break; }
var gapEl = el("tvGap"), gapLab = el("tlGap");
if (!gaps.length) {
gapEl.textContent = "—"; gapEl.className = "tv q"; gapLab.textContent = "gap between";
} else if (same) {
gapEl.textContent = fmtGap(gaps[0]); gapEl.className = "tv"; gapLab.textContent = "gap between";
} else {
var tally = {}, best = gaps[0], bestN = 0;
for (k = 0; k < gaps.length; k++) {
tally[gaps[k]] = (tally[gaps[k]] || 0) + 1;
if (tally[gaps[k]] > bestN) { bestN = tally[gaps[k]]; best = gaps[k]; }
}
gapEl.textContent = fmtGap(best); gapEl.className = "tv"; gapLab.textContent = "usual gap";
}
tick();
}
function paintWeek(spec) {
var now = new Date();
var wm = weekMap(spec, now);
var html = "";
for (var r = 0; r < wm.rows.length; r++) {
var row = wm.rows[r], cells = "";
for (var h = 0; h < 24; h++) {
var c = row.cells[h];
var cls = "cell" + (c.full ? " hit" : "") + (c.past ? " past" : "");
var style = "";
if (c.full && wm.max > 0) {
var t = 0.22 + 0.78 * Math.sqrt(c.full / wm.max);
style = ' style="opacity:' + (c.past ? (t * 0.42).toFixed(2) : t.toFixed(2)) + '"';
}
var title = fmtDate(row.day, now) + " · " + hourWord(h) + " — " +
(c.full ? c.full + (c.full === 1 ? " run" : " runs") : "no runs") +
(c.past ? " (already passed)" : "");
cells += '<div class="' + cls + '"' + style + ' title="' + esc(title) + '"></div>';
}
/* the map only ever spans seven days, so the year would just crowd the label out */
var rowLab = DOW_ABBR[row.day.getDay()] + " " + MON_ABBR[row.day.getMonth()] + " " + row.day.getDate();
html += '<div class="hrow' + (row.today ? " today" : "") + '">' +
'<span class="hlab"><span class="hl-l">' + esc(row.today ? "today" : rowLab) +
'</span><span class="hl-s">' + esc(row.today ? "today" : DOW_ABBR[row.day.getDay()] + " " + row.day.getDate()) + '</span></span>' +
'<div class="cells" role="img" aria-label="' + esc(fmtDate(row.day, now) + ": " + row.count + " runs") + '">' + cells + '</div>' +
'<span class="hcount' + (row.count ? "" : " z") + '">' + row.count + '</span></div>';
}
var ticks = "";
for (var t2 = 0; t2 < 24; t2++) {
ticks += "<span>" + (t2 % 6 === 0 ? esc(state.h24 ? pad(t2) : (t2 % 12 === 0 ? 12 : t2 % 12) + (t2 < 12 ? "a" : "p")) : "") + "</span>";
}
html += '<div class="hrow"><span class="hlab"></span><div class="ticks">' + ticks + '</div><span></span></div>';
el("heat").innerHTML = html;
el("weekTotal").textContent = wm.total + (wm.total === 1 ? " run ahead" : " runs ahead");
el("tvWeek").textContent = wm.total > 9999 ? "10k+" : String(wm.total);
el("tvDays").textContent = wm.active + " of 7";
/* a zero here is a real answer, not a missing one — but it reads as the quiet kind,
matching the "never" the two tiles beside it are showing */
el("tvWeek").className = wm.total ? "tv" : "tv q";
el("tvDays").className = wm.active ? "tv" : "tv q";
}
function syncChips() {
var cur = state.v.join(" ");
var kids = chips.children;
for (var i = 0; i < kids.length; i++) {
kids[i].setAttribute("aria-pressed", kids[i].dataset.cron === cur ? "true" : "false");
}
}
/* ============================ ticking clock ============================ */
function tick() {
if (!state.ok || !state.runs.length) return;
var now = new Date();
if (now >= state.runs[0]) { render(); return; }
var subs = el("runs").querySelectorAll(".rsub[data-at]");
if (subs.length) {
var ms = state.runs[0] - now;
subs[0].innerHTML = '<span class="live-dot"></span>in ' + esc(rel(ms));
el("tvNext").textContent = rel(ms);
el("tvNext").className = "tv";
el("specStat").textContent = "next run in " + rel(ms);
}
}
setInterval(tick, 1000);
/* ============================ controls ============================ */
/* A pressed toggle, not a "switch to…" label: flipping the wording to name the *other*
mode made the button read "12-hour, pressed" to a screen reader while showing 24-hour
times. The label names the mode; the pressed state says whether it's on. */
el("btnFmt").addEventListener("click", function () {
state.h24 = !state.h24;
this.setAttribute("aria-pressed", state.h24 ? "true" : "false");
render();
el("live").textContent = state.h24 ? "Times shown on a 24-hour clock."
: "Times shown on a 12-hour clock.";
});
el("btnCopy").addEventListener("click", function () {
var self = this, text = state.v.join(" "), done = false;
try {
var ta = document.createElement("textarea");
ta.value = text;
ta.setAttribute("readonly", "");
ta.style.position = "fixed";
ta.style.top = "-1000px";
document.body.appendChild(ta);
ta.select();
done = document.execCommand("copy");
document.body.removeChild(ta);
} catch (e) { done = false; }
if (!done && navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(function () { flash(true); }, function () { flash(false); });
return;
}
flash(done);
function flash(good) {
self.textContent = good ? "copied" : "couldn't copy";
self.className = good ? "btn ok" : "btn";
setTimeout(function () { self.textContent = "copy"; self.className = "btn"; }, 1400);
}
});
/* ============================ platform theme ============================ */
window.addEventListener("message", function (e) {
var d = e.data;
if (d && d.type === "sloprun:theme" && (d.theme === "light" || d.theme === "dark")) {
document.documentElement.setAttribute("data-theme", d.theme);
}
});
/* ============================ boot ============================ */
render();
})();
</script>
</body>
</html>
source-visible by construction · nothing is published here without its code
Comments
0 totalNo comments yet. If you ran it, say what happened.