$sloprun.dev

Cron Translator

demo sandbox: allow-scripts · csp: default-src 'none'

sandboxed and isolated in your browser · never enter a real password in a demo

▶ 6 ran · ✓ 0 worked
i ran it — no login needed:
share: preview embed ↗
post it anywhere:
email the card:

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.

Embed this demo — put a runnable demo in your blog post

Paste this where you write. It renders a live, runnable demo inline anywhere raw HTML / iframes are allowed — Ghost, WordPress, Notion, Discourse, your own site.

<iframe src="https://sloprun.dev/embed/cron-explainer" width="100%" height="640" loading="lazy" allow="" style="border:1px solid #E3E2DC;border-radius:10px" title="Cron Translator — a runnable demo on sloprun.dev"></iframe>
preview ↗

On Medium and dev.to the plain link becomes a rich preview card that links back here — they don't run third-party iframes, so paste the URL there and the reader clicks through to run it. https://sloprun.dev/p/cron-explainer

Source

the code you see is the code that just ran raw ↗
Source — the code you see is the code that just ran 1181 lines 54.6 KB index.html
demos/cron-explainer/index.html
<!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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;"); }
  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(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&amp;/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 total

No comments yet. If you ran it, say what happened.

Report this post

Goes straight to the moderation queue. Enough independent reports and the post is suspended automatically until a human looks.

what is wrong