$sloprun.dev

Meeting Overlap Finder

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

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

▶ 5 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

Pick two to four cities and their working days stack onto one 24-hour strip, with the slice where everyone is genuinely at a desk marked in green and written out in each city's own clock. It opens on New York, London and Berlin with the answer already on screen — the only shared stretch is the tail of Berlin's afternoon, which is first thing in New York. Nothing is hardcoded: it asks your own browser for each zone's real offset on the date you pick, so daylight saving is already applied and half-hour zones like Bangalore (+5:30) and Kathmandu (+5:45) land where they should. Try the "hard one" preset — San Francisco, London, Bangalore, Sydney — and watch it refuse to invent a window that doesn't exist, naming the best 2-of-4 slot and who gets left out instead.

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/timezone-planner" width="100%" height="640" loading="lazy" allow="" style="border:1px solid #E3E2DC;border-radius:10px" title="Meeting Overlap Finder — 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/timezone-planner

Source

the code you see is the code that just ran raw ↗
Source — the code you see is the code that just ran 1310 lines 55.9 KB index.html
demos/timezone-planner/index.html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="color-scheme" content="light dark">
<title>Meeting Overlap Finder — sloprun.dev</title>
<style>
/* ── sloprun design tokens (verbatim from demos/tokens.css) ───────────────── */
: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); }
/* ── end tokens ───────────────────────────────────────────────────────────── */

/* demo-local additions: alpha values + on-color pairs the tokens don't carry */
:root {
  --on-accent: #FFFFFF;              /* on solid --accent #C05600 → 5.1 */
  --on-run: #FFFFFF;                 /* on solid --run  #1A7F37 → 5.0 */
  --shadow: 0 1px 2px rgba(26,29,33,.06), 0 8px 24px rgba(26,29,33,.05);
  --recess: #FBFBF9;                 /* track floor, just off the surface */
  --grid: rgba(26,29,33,.10);        /* 3-hour rules inside a track */
  --hair: rgba(26,29,33,.06);
  --work-fill: rgba(192,86,0,.16);
  --work-edge: rgba(192,86,0,.48);
  --run-fill: rgba(26,127,55,.16);
  --run-edge: rgba(26,127,55,.55);
  --near-fill: rgba(192,86,0,.10);
  --near-edge: rgba(192,86,0,.50);
  --night: rgba(26,29,33,.055);      /* 22:00–06:00 wash */
}
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    --on-accent: #14161A;            /* on solid --accent #FFB454 → 10.9 */
    --on-run: #071B0C;               /* on solid --run  #3FB950 → 9.8  */
    --shadow: 0 1px 2px rgba(0,0,0,.40), 0 8px 26px rgba(0,0,0,.35);
    --recess: #16181D;
    --grid: rgba(232,230,225,.10);
    --hair: rgba(232,230,225,.06);
    --work-fill: rgba(255,180,84,.15);
    --work-edge: rgba(255,180,84,.48);
    --run-fill: rgba(63,185,80,.20);
    --run-edge: rgba(63,185,80,.62);
    --near-fill: rgba(255,180,84,.12);
    --near-edge: rgba(255,180,84,.55);
    --night: rgba(0,0,0,.15);
  }
}
:root[data-theme="dark"] {
  --on-accent: #14161A;
  --on-run: #071B0C;
  --shadow: 0 1px 2px rgba(0,0,0,.40), 0 8px 26px rgba(0,0,0,.35);
  --recess: #16181D;
  --grid: rgba(232,230,225,.10);
  --hair: rgba(232,230,225,.06);
  --work-fill: rgba(255,180,84,.15);
  --work-edge: rgba(255,180,84,.48);
  --run-fill: rgba(63,185,80,.20);
  --run-edge: rgba(63,185,80,.62);
  --near-fill: rgba(255,180,84,.12);
  --near-edge: rgba(255,180,84,.55);
  --night: rgba(0,0,0,.15);
}

* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
body {
  min-height: 100%;
  line-height: 1.5;
  overflow-x: hidden;
  -webkit-text-size-adjust: 100%;
}
.wrap { max-width: 1080px; margin: 0 auto; padding: 22px 16px 28px; }

/* ── header ──────────────────────────────────────────────────────────────── */
header { margin-bottom: 16px; }
h1 { margin: 0 0 4px; font-size: 1.35rem; font-weight: 620; letter-spacing: -.015em; }
.lede { margin: 0; color: var(--muted); font-size: .95rem; max-width: 66ch; }

/* ── layout ──────────────────────────────────────────────────────────────── */
.grid { display: grid; gap: 16px; grid-template-columns: 1fr; align-items: start; }
@media (min-width: 900px) {
  .grid { grid-template-columns: minmax(0,1.4fr) minmax(288px,.6fr); gap: 18px; }
}
.side { display: grid; gap: 16px; }
.main { display: grid; gap: 16px; min-width: 0; }

.card {
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: var(--radius);
  box-shadow: var(--shadow);
  min-width: 0;
}
.card > h2 {
  margin: 0;
  padding: 10px 14px;
  border-bottom: 1px solid var(--line);
  font-family: var(--font-mono);
  font-size: .66rem;
  letter-spacing: .12em;
  text-transform: uppercase;
  color: var(--muted);
  font-weight: 600;
  display: flex;
  align-items: center;
  gap: 8px;
}
.card > h2 .spacer { flex: 1; }
.body { padding: 14px; }

/* ── verdict ─────────────────────────────────────────────────────────────── */
.verdict {
  border: 1px solid var(--line);
  border-radius: var(--radius);
  padding: 13px 14px;
  background: var(--bg);
}
.verdict.is-ok   { background: var(--run-soft);    border-color: var(--run-edge); }
.verdict.is-part { background: var(--accent-soft); border-color: var(--near-edge); }
.verdict.is-none { background: var(--bg);          border-color: var(--line); }
.vhead { display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; }
.vbig {
  font-family: var(--font-mono);
  font-size: 1.6rem;
  font-weight: 660;
  letter-spacing: -.02em;
  line-height: 1.15;
}
.is-ok   .vbig { color: var(--run); }
.is-part .vbig { color: var(--accent); }
.is-none .vbig { color: var(--muted); }
.vtag {
  font-family: var(--font-mono);
  font-size: .64rem;
  letter-spacing: .1em;
  text-transform: uppercase;
  font-weight: 700;
  padding: 2px 7px;
  border-radius: 999px;
  border: 1px solid currentColor;
}
.is-ok   .vtag { color: var(--run); }
.is-part .vtag { color: var(--accent); }
.is-none .vtag { color: var(--muted); }
.vsub { margin: 5px 0 0; font-size: .95rem; }
.vsub b { font-family: var(--font-mono); font-weight: 650; letter-spacing: -.01em; }
.vnote { margin: 6px 0 0; font-size: .82rem; color: var(--muted); }

.vlist { margin: 12px 0 0; border-top: 1px dashed var(--line); padding-top: 10px; display: grid; gap: 3px; }
.vrow {
  display: grid;
  grid-template-columns: minmax(0,1fr) auto;
  gap: 10px;
  align-items: baseline;
  font-size: .86rem;
}
.vrow .vc { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.vrow .vt { font-family: var(--font-mono); font-size: .84rem; font-variant-numeric: tabular-nums; }
.vrow.out .vc, .vrow.out .vt { color: var(--muted); }
.vrow .ref {
  font-family: var(--font-mono); font-size: .58rem; letter-spacing: .08em;
  color: var(--accent); border: 1px solid var(--work-edge); border-radius: 4px;
  padding: 0 4px; margin-left: 6px; vertical-align: 1px;
}
.day { color: var(--muted); font-size: .78em; }

/* ── timeline ────────────────────────────────────────────────────────────── */
.tl { --lab: 128px; --gap: 10px; --axisH: 20px; --railH: 24px; position: relative; }
.tl .lane { display: grid; grid-template-columns: var(--lab) minmax(0,1fr); gap: var(--gap); }

.axis { height: var(--axisH); }
.tl .axl {
  font-family: var(--font-mono); font-size: .6rem; letter-spacing: .08em;
  color: var(--muted); text-transform: uppercase; align-self: end; line-height: 1;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.axtrack { position: relative; }
.axtick {
  position: absolute; bottom: 0; font-family: var(--font-mono); font-size: .62rem;
  color: var(--muted); font-variant-numeric: tabular-nums; line-height: 1; white-space: nowrap;
}

.rail { height: var(--railH); }
.railtrack { position: relative; }
.handle {
  position: absolute; top: 0;
  min-width: 52px; height: 22px; padding: 0 7px;
  display: inline-flex; align-items: center; justify-content: center;
  font-family: var(--font-mono); font-size: .72rem; font-weight: 650;
  font-variant-numeric: tabular-nums;
  color: var(--on-accent); background: var(--accent);
  border: 0; border-radius: 6px; cursor: ew-resize;
  box-shadow: 0 1px 3px rgba(0,0,0,.22);
  touch-action: none;
}
.handle::after {
  content: ""; position: absolute; left: 50%; bottom: -5px; width: 0; height: 0;
  transform: translateX(-50%);
  border-left: 5px solid transparent; border-right: 5px solid transparent;
  border-top: 5px solid var(--accent);
}

.rows { display: grid; gap: 4px; }
.row { align-items: center; padding: 3px 0; border-radius: 6px; }
.row.anchored { background: var(--hair); }

.lab { min-width: 0; position: relative; padding-right: 22px; }
.namebtn {
  display: block; width: 100%; text-align: left;
  background: none; border: 0; padding: 0; margin: 0;
  font: inherit; font-size: .82rem; font-weight: 600; color: var(--ink);
  cursor: pointer; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
  border-radius: 4px;
}
.namebtn:hover { color: var(--accent); }
.namebtn .pin { color: var(--accent); font-size: .8em; }
.labtime {
  font-family: var(--font-mono); font-size: .88rem; font-weight: 600;
  font-variant-numeric: tabular-nums; line-height: 1.25; letter-spacing: -.01em;
}
.laboff {
  font-family: var(--font-mono); font-size: .6rem; color: var(--muted);
  letter-spacing: .02em; line-height: 1.3;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.xbtn {
  position: absolute; top: -1px; right: -2px;
  width: 22px; height: 22px; display: grid; place-items: center;
  background: none; border: 1px solid transparent; border-radius: 6px;
  color: var(--muted); font-family: var(--font-mono); font-size: .8rem;
  cursor: pointer; padding: 0; line-height: 1;
}
.xbtn:hover:not(:disabled) { color: var(--danger); border-color: var(--line); background: var(--bg); }
.xbtn:disabled { opacity: .3; cursor: not-allowed; }

.track {
  position: relative; height: 30px; border-radius: 6px;
  background: var(--recess);
  border: 1px solid var(--line);
  overflow: hidden;
}
.track .grid3 {
  position: absolute; inset: 0;
  background-image: repeating-linear-gradient(to right,
    var(--grid) 0 1px, transparent 1px 12.5%);
  background-position: 0 0;
}
.seg { position: absolute; top: 0; bottom: 0; background: var(--night); }
.work {
  position: absolute; top: 0; bottom: 0; overflow: hidden;
  background: var(--work-fill);
  border-left: 1px solid var(--work-edge);
  border-right: 1px solid var(--work-edge);
  display: grid; place-items: center;
}
.work.nol { border-left-color: transparent; }
.work.nor { border-right-color: transparent; }
.worktxt {
  font-family: var(--font-mono); font-size: .58rem; font-weight: 650;
  color: var(--accent); letter-spacing: .02em; white-space: nowrap;
  font-variant-numeric: tabular-nums;
}
.hrs { position: absolute; inset: 0; pointer-events: none; }
.hr {
  position: absolute; top: 50%; transform: translate(-50%,-50%);
  font-family: var(--font-mono); font-size: .58rem; color: var(--muted);
  font-variant-numeric: tabular-nums; line-height: 1;
}

/* overlay columns, aligned to the track column */
.ovl { position: absolute; left: calc(var(--lab) + var(--gap)); right: 0; pointer-events: none; }
.ovl-band { top: calc(var(--axisH) + var(--railH)); bottom: 0; }
.ovl-cur  { top: var(--axisH); bottom: 0; }
.band {
  position: absolute; top: 0; bottom: 0; border-radius: 4px;
  background: var(--run-fill);
  border-left: 2px solid var(--run-edge);
  border-right: 2px solid var(--run-edge);
}
.band.partial { background: var(--near-fill); border-left-style: dashed; border-right-style: dashed; border-color: var(--near-edge); }
/* a window that runs past local midnight is drawn as two pieces; the seam
   between them gets no border and no rounding so it reads as one band */
.band.nol { border-left-color: transparent; border-top-left-radius: 0; border-bottom-left-radius: 0; }
.band.nor { border-right-color: transparent; border-top-right-radius: 0; border-bottom-right-radius: 0; }
.band.hidden { display: none; }
.cline { position: absolute; top: 0; bottom: 0; width: 0; border-left: 1px dashed var(--accent); opacity: .85; }

.scrub { touch-action: pan-y; cursor: crosshair; }

.legend {
  margin-top: 12px; padding-top: 10px; border-top: 1px dashed var(--line);
  display: flex; flex-wrap: wrap; gap: 6px 14px;
  font-family: var(--font-mono); font-size: .64rem; color: var(--muted);
  align-items: center;
}
.legend span { display: inline-flex; align-items: center; gap: 6px; }
.sw { width: 16px; height: 10px; border-radius: 2px; display: inline-block; }
.sw.w { background: var(--work-fill); border: 1px solid var(--work-edge); }
.sw.o { background: var(--run-fill);  border: 1px solid var(--run-edge); }
.sw.c { width: 0; height: 12px; border-left: 1px dashed var(--accent); border-radius: 0; }

/* ── controls ────────────────────────────────────────────────────────────── */
.field { display: grid; gap: 5px; }
.field + .field { margin-top: 12px; }
.flabel {
  font-family: var(--font-mono); font-size: .62rem; letter-spacing: .1em;
  text-transform: uppercase; color: var(--muted); font-weight: 600;
}
input[type="date"], input[type="text"], select {
  font: inherit; font-size: .88rem; color: var(--ink);
  background: var(--surface); border: 1px solid var(--line);
  border-radius: 8px; padding: 7px 9px; width: 100%; min-width: 0;
}
input[type="date"], select { font-family: var(--font-mono); font-size: .84rem; }
select { cursor: pointer; }
.duo { display: grid; grid-template-columns: 1fr auto 1fr; gap: 6px; align-items: center; }
.duo .to { font-family: var(--font-mono); font-size: .72rem; color: var(--muted); }

.btnrow { display: flex; flex-wrap: wrap; gap: 6px; }
.btn {
  font: inherit; font-size: .8rem; font-weight: 550;
  color: var(--ink); background: var(--surface);
  border: 1px solid var(--line); border-radius: 999px;
  padding: 5px 11px; cursor: pointer; line-height: 1.35;
}
.btn:hover:not(:disabled) { border-color: var(--accent); color: var(--accent); }
.btn:disabled { opacity: .45; cursor: not-allowed; }
.btn.on { background: var(--accent-soft); border-color: var(--accent); color: var(--accent); }
.btn.mono { font-family: var(--font-mono); font-size: .74rem; }

.picker {
  margin-top: 8px; max-height: 268px; overflow-y: auto;
  border: 1px solid var(--line); border-radius: 8px; background: var(--bg);
  padding: 4px;
}
.cbtn {
  display: grid; grid-template-columns: 16px minmax(0,1fr) auto;
  gap: 8px; align-items: center; width: 100%;
  font: inherit; font-size: .84rem; text-align: left;
  background: none; border: 1px solid transparent; border-radius: 6px;
  padding: 5px 7px; color: var(--ink); cursor: pointer;
}
.cbtn:hover:not(:disabled) { background: var(--surface); border-color: var(--line); }
.cbtn:disabled { opacity: .42; cursor: not-allowed; }
.cbtn.sel { background: var(--accent-soft); border-color: var(--work-edge); }
.cbtn .tick { font-family: var(--font-mono); font-size: .8rem; color: var(--accent); text-align: center; }
.cbtn .nm { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.cbtn .nm small { color: var(--muted); font-size: .78em; }
.cbtn .off {
  font-family: var(--font-mono); font-size: .68rem; color: var(--muted);
  font-variant-numeric: tabular-nums; white-space: nowrap;
}
.you {
  font-family: var(--font-mono); font-size: .55rem; letter-spacing: .08em;
  border: 1px solid var(--work-edge); color: var(--accent);
  border-radius: 4px; padding: 0 3px; margin-left: 5px; vertical-align: 1px;
}
.empty { padding: 14px 8px; text-align: center; color: var(--muted); font-size: .82rem; }

.status {
  margin-top: 10px; font-family: var(--font-mono); font-size: .66rem;
  color: var(--muted); line-height: 1.5;
}
.status b { color: var(--ink); font-weight: 600; }
.status.ok b { color: var(--run); }
.status.warn b { color: var(--accent); }
.caveat { margin: 10px 0 0; font-size: .78rem; color: var(--muted); line-height: 1.5; }

kbd {
  font-family: var(--font-mono); font-size: .72em; border: 1px solid var(--line);
  border-bottom-width: 2px; border-radius: 4px; padding: 0 4px;
  background: var(--bg); color: var(--ink);
}

footer {
  margin-top: 18px; padding-top: 12px; border-top: 1px solid var(--line);
  display: flex; justify-content: space-between; gap: 12px; flex-wrap: wrap;
  font-family: var(--font-mono); font-size: .7rem; color: var(--muted);
}

:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 4px; }
.mono { font-family: var(--font-mono); }
.vcap {
  font-family: var(--font-mono); font-size: .58rem; letter-spacing: .09em;
  text-transform: uppercase; color: var(--muted); margin-bottom: 5px;
}
[hidden] { display: none !important; }
.sr {
  position: absolute; width: 1px; height: 1px; overflow: hidden;
  clip: rect(0 0 0 0); clip-path: inset(50%); white-space: nowrap;
}

@media (max-width: 560px) {
  .tl { --lab: 92px; --gap: 8px; }
  .hrs { display: none; }
  .axtick.minor { display: none; }
}
@media (min-width: 561px) {
  .worktxt { display: none; }
}
@media (max-width: 400px) {
  .wrap { padding: 16px 12px 22px; }
  .body { padding: 12px; }
  h1 { font-size: 1.2rem; }
  .vbig { font-size: 1.35rem; }
}
@media (prefers-reduced-motion: no-preference) {
  .band, .work { transition: left .16s ease, width .16s ease, background-color .16s ease; }
  .btn, .cbtn, .namebtn { transition: background-color .12s ease, border-color .12s ease, color .12s ease; }
}
</style>
</head>
<body>
<div class="wrap">
  <header>
    <h1>Meeting Overlap Finder</h1>
    <p class="lede">Pick two to four cities. Their working days stack onto one 24-hour strip, and the slice where
      everyone is actually at a desk gets marked — with the answer written out in each city's own clock.</p>
  </header>

  <div class="grid">
    <div class="main">

      <section class="card" aria-labelledby="vh">
        <h2 id="vh">Verdict<span class="spacer"></span><span id="dateTag" class="mono"></span></h2>
        <div class="body">
          <div class="verdict" id="verdict" role="status" aria-live="polite">
            <div class="vhead">
              <span class="vbig" id="vbig">—</span>
              <span class="vtag" id="vtag">overlap</span>
            </div>
            <p class="vsub" id="vsub"></p>
            <p class="vnote" id="vnote"></p>
            <div class="vlist" id="vlist"></div>
          </div>
          <div class="btnrow" style="margin-top:12px">
            <button type="button" class="btn" id="copyBtn">Copy summary</button>
            <button type="button" class="btn mono" id="jumpBtn">Cursor → best window</button>
          </div>
          <div class="status" id="copyStatus" aria-live="polite"></div>
        </div>
      </section>

      <section class="card" aria-labelledby="th">
        <h2 id="th">The day, side by side<span class="spacer"></span><span id="anchorTag"></span></h2>
        <div class="body">
          <div class="tl" id="tl">
            <div class="lane axis">
              <div class="axl" id="axLabel">local hour</div>
              <div class="axtrack scrub" id="axTrack"></div>
            </div>
            <div class="lane rail" id="railLane">
              <div class="axl">cursor</div>
              <div class="railtrack scrub" id="railTrack">
                <button type="button" class="handle" id="handle" role="slider"
                        aria-valuemin="0" aria-valuemax="1439" aria-valuenow="720"
                        aria-label="Time cursor">12:00</button>
              </div>
            </div>
            <div class="rows" id="rows"></div>
            <div class="ovl ovl-band"><div class="band hidden" id="band"></div><div class="band hidden" id="band2"></div></div>
            <div class="ovl ovl-cur"><div class="cline" id="cline"></div></div>
          </div>
          <div class="legend">
            <span><i class="sw w"></i> working hours <b id="legWork" style="font-weight:600">09:00–17:00</b></span>
            <span><i class="sw o"></i> everyone free</span>
            <span><i class="sw c"></i> cursor</span>
            <span>drag the strip · <kbd>←</kbd><kbd>→</kbd> to nudge</span>
          </div>
          <p class="caveat">Click a city's name to read the whole strip in that city's clock.</p>
        </div>
      </section>

    </div>

    <div class="side">

      <section class="card" aria-labelledby="sh">
        <h2 id="sh">Setup</h2>
        <div class="body">
          <div class="field">
            <label class="flabel" for="dateIn">Date</label>
            <input type="date" id="dateIn" min="1970-01-01" max="2100-12-31">
          </div>
          <div class="field">
            <span class="flabel" id="whLabel">Working hours (local, everywhere)</span>
            <div class="duo" role="group" aria-labelledby="whLabel">
              <select id="wsIn" aria-label="Working day starts"></select>
              <span class="to">to</span>
              <select id="weIn" aria-label="Working day ends"></select>
            </div>
          </div>
          <div class="field">
            <span class="flabel" id="presetLabel">Presets</span>
            <div class="btnrow" role="group" aria-labelledby="presetLabel" id="presets"></div>
          </div>
          <div class="status" id="tzStatus"></div>
        </div>
      </section>

      <section class="card" aria-labelledby="ch">
        <h2 id="ch">Cities<span class="spacer"></span><span id="countTag">3 / 4</span></h2>
        <div class="body">
          <label class="sr" for="searchIn">Filter cities</label>
          <input type="text" id="searchIn" placeholder="Filter — try tokyo, india, +5" autocomplete="off" spellcheck="false">
          <div class="picker" id="picker"></div>
          <div class="status" id="pickStatus"></div>
        </div>
      </section>

    </div>
  </div>

  <footer>
    <span>demo · sloprun.dev</span>
    <span id="engineTag">overlap finder · 24-hour clock · no storage</span>
  </footer>
</div>

<script>
(function () {
  "use strict";

  /* ---------- platform theme bridge ---------- */
  addEventListener("message", function (e) {
    var d = e && e.data;
    if (d && d.type === "sloprun:theme" && (d.theme === "light" || d.theme === "dark")) {
      document.documentElement.setAttribute("data-theme", d.theme);
    }
  });

  var $ = function (id) { return document.getElementById(id); };

  /* ---------- city table ----------------------------------------------------
     base = standard-time offset in minutes from UTC, used only when the browser
     cannot answer time-zone questions itself. When it can, the real offset for
     the chosen date is read from the browser's own data instead.            */
  var CITIES = [
    { id: "honolulu",     name: "Honolulu",      region: "United States",  zone: "Pacific/Honolulu",               base: -600 },
    { id: "anchorage",    name: "Anchorage",     region: "United States",  zone: "America/Anchorage",              base: -540 },
    { id: "san-francisco",name: "San Francisco", region: "United States",  zone: "America/Los_Angeles",            base: -480 },
    { id: "denver",       name: "Denver",        region: "United States",  zone: "America/Denver",                 base: -420 },
    { id: "mexico-city",  name: "Mexico City",   region: "Mexico",         zone: "America/Mexico_City",            base: -360 },
    { id: "chicago",      name: "Chicago",       region: "United States",  zone: "America/Chicago",                base: -360 },
    { id: "new-york",     name: "New York",      region: "United States",  zone: "America/New_York",               base: -300 },
    { id: "toronto",      name: "Toronto",       region: "Canada",         zone: "America/Toronto",                base: -300 },
    { id: "sao-paulo",    name: "São Paulo",region: "Brazil",         zone: "America/Sao_Paulo",              base: -180 },
    { id: "buenos-aires", name: "Buenos Aires",  region: "Argentina",      zone: "America/Argentina/Buenos_Aires", base: -180 },
    { id: "reykjavik",    name: "Reykjavík",region: "Iceland",        zone: "Atlantic/Reykjavik",             base: 0 },
    { id: "london",       name: "London",        region: "United Kingdom", zone: "Europe/London",                  base: 0 },
    { id: "dublin",       name: "Dublin",        region: "Ireland",        zone: "Europe/Dublin",                  base: 0 },
    { id: "lisbon",       name: "Lisbon",        region: "Portugal",       zone: "Europe/Lisbon",                  base: 0 },
    { id: "paris",        name: "Paris",         region: "France",         zone: "Europe/Paris",                   base: 60 },
    { id: "berlin",       name: "Berlin",        region: "Germany",        zone: "Europe/Berlin",                  base: 60 },
    { id: "madrid",       name: "Madrid",        region: "Spain",          zone: "Europe/Madrid",                  base: 60 },
    { id: "lagos",        name: "Lagos",         region: "Nigeria",        zone: "Africa/Lagos",                   base: 60 },
    { id: "cairo",        name: "Cairo",         region: "Egypt",          zone: "Africa/Cairo",                   base: 120 },
    { id: "johannesburg", name: "Johannesburg",  region: "South Africa",   zone: "Africa/Johannesburg",            base: 120 },
    { id: "athens",       name: "Athens",        region: "Greece",         zone: "Europe/Athens",                  base: 120 },
    { id: "istanbul",     name: "Istanbul",      region: "Türkiye",   zone: "Europe/Istanbul",                base: 180 },
    { id: "moscow",       name: "Moscow",        region: "Russia",         zone: "Europe/Moscow",                  base: 180 },
    { id: "nairobi",      name: "Nairobi",       region: "Kenya",          zone: "Africa/Nairobi",                 base: 180 },
    { id: "dubai",        name: "Dubai",         region: "UAE",            zone: "Asia/Dubai",                     base: 240 },
    { id: "karachi",      name: "Karachi",       region: "Pakistan",       zone: "Asia/Karachi",                   base: 300 },
    { id: "bangalore",    name: "Bangalore",     region: "India",          zone: "Asia/Kolkata",                    base: 330 },
    { id: "kathmandu",    name: "Kathmandu",     region: "Nepal",          zone: "Asia/Kathmandu",                 base: 345 },
    { id: "dhaka",        name: "Dhaka",         region: "Bangladesh",     zone: "Asia/Dhaka",                     base: 360 },
    { id: "bangkok",      name: "Bangkok",       region: "Thailand",       zone: "Asia/Bangkok",                   base: 420 },
    { id: "jakarta",      name: "Jakarta",       region: "Indonesia",      zone: "Asia/Jakarta",                   base: 420 },
    { id: "singapore",    name: "Singapore",     region: "Singapore",      zone: "Asia/Singapore",                 base: 480 },
    { id: "hong-kong",    name: "Hong Kong",     region: "Hong Kong",      zone: "Asia/Hong_Kong",                 base: 480 },
    { id: "shanghai",     name: "Shanghai",      region: "China",          zone: "Asia/Shanghai",                  base: 480 },
    { id: "seoul",        name: "Seoul",         region: "South Korea",    zone: "Asia/Seoul",                     base: 540 },
    { id: "tokyo",        name: "Tokyo",         region: "Japan",          zone: "Asia/Tokyo",                     base: 540 },
    { id: "adelaide",     name: "Adelaide",      region: "Australia",      zone: "Australia/Adelaide",             base: 570 },
    { id: "sydney",       name: "Sydney",        region: "Australia",      zone: "Australia/Sydney",               base: 600 },
    { id: "auckland",     name: "Auckland",      region: "New Zealand",    zone: "Pacific/Auckland",               base: 720 }
  ];

  var PRESETS = [
    { label: "US ↔ Europe", sel: ["new-york", "london", "berlin"],                      anchor: "berlin" },
    { label: "The hard one",     sel: ["san-francisco", "london", "bangalore", "sydney"],    anchor: "london" },
    { label: "Asia–Pacific",sel: ["singapore", "tokyo", "sydney"],                      anchor: "singapore" },
    { label: "Follow the sun",   sel: ["san-francisco", "berlin", "tokyo"],                  anchor: "berlin" }
  ];

  /* ---------- small helpers ---------- */
  function pad2(n) { return (n < 10 ? "0" : "") + n; }
  function mod(n, m) { return ((n % m) + m) % m; }
  function hhmm(mins) { var v = mod(mins, 1440); return pad2(Math.floor(v / 60)) + ":" + pad2(v % 60); }
  function endLabel(m) { return m === 1440 ? "24:00" : hhmm(m); }
  function offLabel(m) {
    if (m === 0) return "UTC±00:00";
    var s = m < 0 ? "−" : "+", a = Math.abs(m);
    return "UTC" + s + pad2(Math.floor(a / 60)) + ":" + pad2(a % 60);
  }
  function durLabel(m) {
    var h = Math.floor(m / 60), mm = m % 60;
    if (h && mm) return h + "h " + pad2(mm) + "m";
    if (h) return h + "h";
    return mm + "m";
  }
  /* "09–17" — the compact form that fits inside a narrow working block */
  function shortHours() {
    return pad2(Math.floor(st.ws / 60)) + "–" + pad2(Math.floor(st.we / 60));
  }
  function windowLabel(a, b, len) {
    return len >= 1440 ? "all day" : hhmm(a) + "–" + hhmm(b);
  }
  function dayTag(shift) { return shift === 0 ? "" : (shift > 0 ? "+" + shift + "d" : shift + "d"); }
  function popcount(x) { var c = 0; while (x) { x &= x - 1; c++; } return c; }
  function esc(s) {
    return String(s).replace(/[&<>"]/g, function (c) {
      return { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c];
    });
  }
  function listNames(arr) {
    if (arr.length === 0) return "";
    if (arr.length === 1) return arr[0];
    return arr.slice(0, -1).join(", ") + " and " + arr[arr.length - 1];
  }

  /* ---------- time-zone plumbing ---------- */
  var fmtCache = {};
  function zoneOffset(zone, ms) {
    try {
      var f = fmtCache[zone];
      if (!f) {
        f = new Intl.DateTimeFormat("en-US", {
          timeZone: zone, hour12: false,
          year: "numeric", month: "2-digit", day: "2-digit",
          hour: "2-digit", minute: "2-digit", second: "2-digit"
        });
        fmtCache[zone] = f;
      }
      var parts = f.formatToParts(new Date(ms)), m = {}, i;
      for (i = 0; i < parts.length; i++) m[parts[i].type] = parts[i].value;
      var h = +m.hour; if (h === 24) h = 0;
      var asUTC = Date.UTC(+m.year, +m.month - 1, +m.day, h, +m.minute, +m.second);
      if (isNaN(asUTC)) return null;
      return Math.round((asUTC - ms) / 60000);
    } catch (err) { return null; }
  }

  var TZ_OK = false;
  try { TZ_OK = zoneOffset("Asia/Kolkata", Date.UTC(2024, 0, 15, 12, 0, 0)) === 330; } catch (err) { TZ_OK = false; }

  /* your own clock, if the browser will name it */
  var youId = null;
  (function () {
    var tz = null;
    try { tz = Intl.DateTimeFormat().resolvedOptions().timeZone || null; } catch (err) { tz = null; }
    if (!tz) return;
    for (var i = 0; i < CITIES.length; i++) {
      if (CITIES[i].zone === tz) { youId = CITIES[i].id; return; }
    }
    var leaf = tz.split("/").pop().replace(/_/g, " ");
    var here = { id: "__you", name: leaf, region: "your clock", zone: tz, you: true,
                 base: -new Date().getTimezoneOffset() };
    CITIES.unshift(here);
    youId = "__you";
  })();

  var byId = {};
  CITIES.forEach(function (c) { byId[c.id] = c; });

  /* ---------- state ---------- */
  function todayStr() {
    var d = new Date();
    return d.getFullYear() + "-" + pad2(d.getMonth() + 1) + "-" + pad2(d.getDate());
  }
  var st = {
    date: todayStr(),
    sel: PRESETS[0].sel.slice(),
    anchor: PRESETS[0].anchor,
    ws: 9 * 60,
    we: 17 * 60,
    cursor: 720
  };

  function sampleMs() {
    var p = /^(\d{4})-(\d{2})-(\d{2})$/.exec(st.date);
    if (!p) return Date.UTC(2026, 0, 1, 12, 0, 0);
    return Date.UTC(+p[1], +p[2] - 1, +p[3], 12, 0, 0);
  }
  function offsetOf(id) {
    var c = byId[id];
    if (!c) return 0;
    if (TZ_OK) {
      var o = zoneOffset(c.zone, sampleMs());
      if (o !== null) return o;
    }
    return c.base;
  }
  function dateLabel(shift) {
    var ms = sampleMs() + (shift || 0) * 86400000;
    try {
      return new Intl.DateTimeFormat(undefined, {
        weekday: "short", day: "numeric", month: "short", timeZone: "UTC"
      }).format(new Date(ms));
    } catch (err) {
      var d = new Date(ms);
      return ["Sun","Mon","Tue","Wed","Thu","Fri","Sat"][d.getUTCDay()] + " " +
             d.getUTCDate() + " " +
             ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"][d.getUTCMonth()];
    }
  }

  /* ---------- the actual maths -------------------------------------------
     Axis position p = minutes since local midnight in the anchor city.
     A city whose offset differs from the anchor by dc minutes reads
     (p + dc) on its own clock. Working iff that lands inside [ws, we).   */
  function arcSegs(dc, from, to) {
    var len = mod(to - from, 1440); if (len === 0) len = 1440;
    var s = mod(from - dc, 1440), out = [];
    if (s + len <= 1440) out.push({ a: s, b: s + len, head: true, tail: true });
    else { out.push({ a: s, b: 1440, head: true, tail: false });
           out.push({ a: 0, b: s + len - 1440, head: false, tail: true }); }
    return out;
  }

  function circularRuns(mask) {
    var n = 1440, i, same = true;
    for (i = 1; i < n; i++) { if (mask[i] !== mask[0]) { same = false; break; } }
    if (same) return [{ start: 0, len: n, mask: mask[0] }];
    var b = 0;
    for (i = 0; i < n; i++) { if (mask[i] !== mask[mod(i - 1, n)]) { b = i; break; } }
    var runs = [], cs = b, cm = mask[b], k, idx;
    for (k = 1; k <= n; k++) {
      idx = (b + k) % n;
      if (k === n || mask[idx] !== cm) {
        runs.push({ start: cs, len: k - mod(cs - b, n), mask: cm });
        cs = idx; cm = mask[idx];
      }
    }
    return runs;
  }

  function solve() {
    var ids = st.sel.slice();
    var offs = {}, i, p;
    ids.forEach(function (id) { offs[id] = offsetOf(id); });
    ids.sort(function (a, b) {
      if (offs[a] !== offs[b]) return offs[a] - offs[b];
      return byId[a].name.localeCompare(byId[b].name);
    });
    if (ids.indexOf(st.anchor) < 0) st.anchor = ids[0];
    var A = offs[st.anchor];
    var dc = {};
    ids.forEach(function (id) { dc[id] = offs[id] - A; });

    var n = ids.length;
    var mask = new Int32Array(1440);
    for (i = 0; i < n; i++) {
      var d = dc[ids[i]], bit = 1 << i;
      var segs = arcSegs(d, st.ws, st.we);
      for (var s = 0; s < segs.length; s++) {
        for (p = segs[s].a; p < segs[s].b; p++) mask[p] |= bit;
      }
    }
    var maxCount = 0;
    for (p = 0; p < 1440; p++) { var pc = popcount(mask[p]); if (pc > maxCount) maxCount = pc; }

    var band = null;
    if (maxCount >= 2) {
      var runs = circularRuns(mask), best = null;
      var abit = 1 << ids.indexOf(st.anchor);
      for (i = 0; i < runs.length; i++) {
        var rr = runs[i];
        if (popcount(rr.mask) !== maxCount) continue;
        if (!best) { best = rr; continue; }
        /* equally good windows are common; prefer the one the reference city
           is actually inside, then the longer one */
        var ra = (rr.mask & abit) ? 1 : 0, ba = (best.mask & abit) ? 1 : 0;
        if (ra !== ba) { if (ra > ba) best = rr; continue; }
        if (rr.len > best.len) best = rr;
      }
      if (best) {
        var members = [];
        for (i = 0; i < n; i++) if (best.mask & (1 << i)) members.push(ids[i]);
        band = { start: best.start, len: best.len, count: maxCount, members: members,
                 full: maxCount === n };
      }
    }
    return { ids: ids, offs: offs, dc: dc, anchorOff: A, band: band, maxCount: maxCount };
  }

  /* ---------- element handles ---------- */
  var tl = $("tl"), axTrack = $("axTrack"), rowsEl = $("rows"), bandEl = $("band"),
      bandEl2 = $("band2"),
      clineEl = $("cline"), handle = $("handle"), axLabel = $("axLabel"),
      anchorTag = $("anchorTag"), dateTag = $("dateTag"),
      verdict = $("verdict"), vbig = $("vbig"), vtag = $("vtag"), vsub = $("vsub"),
      vnote = $("vnote"), vlist = $("vlist"),
      dateIn = $("dateIn"), wsIn = $("wsIn"), weIn = $("weIn"), presetsEl = $("presets"),
      searchIn = $("searchIn"), picker = $("picker"), pickStatus = $("pickStatus"),
      tzStatus = $("tzStatus"), countTag = $("countTag"), legWork = $("legWork"),
      copyBtn = $("copyBtn"), copyStatus = $("copyStatus"), jumpBtn = $("jumpBtn");

  var current = null;      /* last solve() result */
  var pickRefs = {};       /* id -> { btn, tick, off } */
  var labTimes = [];       /* live clock readouts, one per visible row */

  /* ---------- build the fixed controls ---------- */
  (function buildControls() {
    dateIn.value = st.date;

    var h, o;
    for (h = 0; h <= 22; h++) {
      o = document.createElement("option");
      o.value = String(h * 60); o.textContent = pad2(h) + ":00";
      wsIn.appendChild(o);
    }
    for (h = 1; h <= 24; h++) {
      o = document.createElement("option");
      o.value = String(h * 60); o.textContent = pad2(h % 24) + ":00";
      if (h === 24) o.textContent = "24:00";
      weIn.appendChild(o);
    }
    wsIn.value = String(st.ws); weIn.value = String(st.we);

    PRESETS.forEach(function (p, i) {
      var b = document.createElement("button");
      b.type = "button"; b.className = "btn"; b.textContent = p.label;
      b.dataset.preset = String(i);
      presetsEl.appendChild(b);
    });

    CITIES.forEach(function (c) {
      var b = document.createElement("button");
      b.type = "button"; b.className = "cbtn"; b.dataset.city = c.id;
      var tick = document.createElement("span"); tick.className = "tick"; tick.setAttribute("aria-hidden", "true");
      var nm = document.createElement("span"); nm.className = "nm";
      nm.innerHTML = esc(c.name) + " <small>" + esc(c.region) + "</small>" +
                     (c.id === youId ? ' <span class="you">YOU</span>' : "");
      var off = document.createElement("span"); off.className = "off";
      b.appendChild(tick); b.appendChild(nm); b.appendChild(off);
      picker.appendChild(b);
      pickRefs[c.id] = { btn: b, tick: tick, off: off };
    });

    tzStatus.className = "status" + (TZ_OK ? "" : " warn");
    tzStatus.innerHTML = TZ_OK
      ? "offsets: <b>your browser's time-zone data</b><br>daylight saving is already applied for the date above."
      : "offsets: <b>fixed standard values</b><br>this browser can't do time-zone lookups, so daylight saving is not applied.";
  })();

  /* ---------- rendering ---------- */
  function placeBand(el, a, b, partial, nol, nor) {
    el.className = "band" + (partial ? " partial" : "") + (nol ? " nol" : "") + (nor ? " nor" : "");
    el.style.left = (a / 14.4) + "%";
    el.style.width = ((b - a) / 14.4) + "%";
  }

  function render() {
    var r = solve();
    current = r;
    var ids = r.ids, dc = r.dc, band = r.band;

    /* header bits */
    var anchorCity = byId[st.anchor];
    axLabel.textContent = anchorCity.name.length > 12 ? "local hour" : anchorCity.name.toLowerCase();
    anchorTag.textContent = "ref · " + anchorCity.name;
    dateTag.textContent = dateLabel(0);
    legWork.textContent = hhmm(st.ws) + "–" + endLabel(st.we);
    countTag.textContent = ids.length + " / 4";

    /* axis ticks */
    var ax = "", h, f;
    for (h = 0; h <= 24; h += 3) {
      f = h / 24 * 100;
      var tx = h === 0 ? "translateX(0)" : (h === 24 ? "translateX(-100%)" : "translateX(-50%)");
      ax += '<span class="axtick' + (h % 6 ? " minor" : "") +
            '" style="left:' + f + '%;transform:' + tx + '">' + (h === 24 ? "24" : pad2(h)) + "</span>";
    }
    axTrack.innerHTML = ax;

    /* rows */
    var html = "", i;
    for (i = 0; i < ids.length; i++) {
      var id = ids[i], c = byId[id], d = dc[id], isA = id === st.anchor;
      var loc = st.cursor + d, shift = Math.floor(loc / 1440), tag = dayTag(shift);

      var track = '<div class="grid3"></div>';
      arcSegs(d, 22 * 60, 6 * 60).forEach(function (s) {
        track += '<div class="seg" style="left:' + (s.a / 14.4) + '%;width:' + ((s.b - s.a) / 14.4) + '%"></div>';
      });
      var wsegs = arcSegs(d, st.ws, st.we), widest = 0;
      wsegs.forEach(function (s, k) { if (s.b - s.a > wsegs[widest].b - wsegs[widest].a) widest = k; });
      wsegs.forEach(function (s, k) {
        var w = (s.b - s.a) / 1440;
        var cls = "work" + (s.head ? "" : " nol") + (s.tail ? "" : " nor");
        track += '<div class="' + cls + '" style="left:' + (s.a / 14.4) + '%;width:' + (w * 100) + '%">' +
                 (k === widest && w >= 0.24
                   ? '<span class="worktxt">' + shortHours() + "</span>" : "") +
                 "</div>";
      });
      var hrs = "";
      for (h = 0; h < 24; h += 3) {
        var p = mod(h * 60 - d, 1440), fr = p / 1440;
        if (fr < 0.022 || fr > 0.978) continue;
        hrs += '<span class="hr" style="left:' + (fr * 100) + '%">' + pad2(h) + "</span>";
      }
      track += '<div class="hrs">' + hrs + "</div>";

      html +=
        '<div class="lane row' + (isA ? " anchored" : "") + '">' +
          '<div class="lab">' +
            '<button type="button" class="namebtn" data-anchor="' + id + '" aria-pressed="' + isA + '" ' +
              'title="Read the strip in ' + esc(c.name) + '’s clock">' + esc(c.name) +
              (isA ? ' <span class="pin" aria-hidden="true">◉</span>' : "") + "</button>" +
            '<div class="labtime">' + hhmm(loc) + (tag ? ' <span class="day">' + tag + "</span>" : "") + "</div>" +
            '<div class="laboff">' + offLabel(r.offs[id]) + "</div>" +
            '<button type="button" class="xbtn" data-remove="' + id + '" aria-label="Remove ' + esc(c.name) + '"' +
              (ids.length <= 2 ? " disabled" : "") + ">✕</button>" +
          "</div>" +
          '<div class="track scrub">' + track + "</div>" +
        "</div>";
    }
    rowsEl.innerHTML = html;

    labTimes = [];
    var timeEls = rowsEl.querySelectorAll(".labtime");
    for (i = 0; i < timeEls.length && i < ids.length; i++) {
      labTimes.push({ el: timeEls[i], d: dc[ids[i]] });
    }

    /* overlay band — a window that crosses the anchor's midnight is drawn as
       two pieces, one running to the right edge and one resuming at 00:00 */
    if (band) {
      var bEnd = band.start + band.len;
      if (bEnd <= 1440) {
        placeBand(bandEl, band.start, bEnd, !band.full, false, false);
        bandEl2.className = "band hidden";
      } else {
        placeBand(bandEl, band.start, 1440, !band.full, false, true);
        placeBand(bandEl2, 0, bEnd - 1440, !band.full, true, false);
      }
    } else {
      bandEl.className = "band hidden";
      bandEl2.className = "band hidden";
    }
    jumpBtn.disabled = !band;

    anchorName = anchorCity.name;
    updateCursorUI();
    renderVerdict(r);
    renderPicker(r);
  }

  /* the cursor is the only thing that moves during a drag, so it gets its own
     cheap pass instead of rebuilding the strip on every pointer sample */
  var anchorName = "";
  function updateCursorUI() {
    var cf = st.cursor / 1440 * 100;
    clineEl.style.left = cf + "%";
    handle.style.left = cf + "%";
    handle.style.transform = cf < 4 ? "translateX(0)" : (cf > 96 ? "translateX(-100%)" : "translateX(-50%)");
    handle.textContent = hhmm(st.cursor);
    handle.setAttribute("aria-valuenow", String(st.cursor));
    handle.setAttribute("aria-valuetext", hhmm(st.cursor) + " in " + anchorName);
    for (var i = 0; i < labTimes.length; i++) {
      var loc = st.cursor + labTimes[i].d, tag = dayTag(Math.floor(loc / 1440));
      labTimes[i].el.innerHTML = hhmm(loc) + (tag ? ' <span class="day">' + tag + "</span>" : "");
    }
  }

  function renderVerdict(r) {
    var band = r.band, ids = r.ids, dc = r.dc, anchorName = byId[st.anchor].name;

    if (band && band.full) {
      verdict.className = "verdict is-ok";
      vbig.textContent = durLabel(band.len) + " overlap";
      vtag.textContent = "all " + ids.length;
      vsub.innerHTML = "<b>" + windowLabel(band.start, band.start + band.len, band.len) +
        "</b> in " + esc(anchorName) + ", " + esc(dateLabel(0));
      vnote.textContent = "Every city on the strip is inside its own " +
        hhmm(st.ws) + "–" + endLabel(st.we) + " for that whole stretch.";
    } else if (band) {
      verdict.className = "verdict is-part";
      vbig.textContent = "no full overlap";
      vtag.textContent = "best " + band.count + " of " + ids.length;
      vsub.innerHTML = "<b>" + durLabel(band.len) + "</b> for " + band.count + " of " + ids.length +
        " · " + windowLabel(band.start, band.start + band.len, band.len) + " in " + esc(anchorName);
      var out = ids.filter(function (id) { return band.members.indexOf(id) < 0; })
                   .map(function (id) { return byId[id].name; });
      vnote.textContent = listNames(out) + (out.length === 1 ? " is" : " are") +
        " off the clock then. Widen the working hours, or split into two calls.";
    } else {
      verdict.className = "verdict is-none";
      vbig.textContent = "no overlap";
      vtag.textContent = "none";
      vsub.textContent = "No two of these cities share a single working minute on this day.";
      vnote.textContent = "The strip below shows why — the shaded blocks never touch.";
    }

    var cap = band
      ? (band.full ? "the shared window, in each city’s clock"
                   : "that window, in each city’s clock")
      : "each working day, in " + anchorName + "’s clock";
    var rows = '<div class="vcap">' + esc(cap) + "</div>";

    ids.forEach(function (id) {
      var c = byId[id], d = dc[id], txt, out = false;
      if (band) {
        var a = band.start + d, b = band.start + band.len + d;
        out = band.members.indexOf(id) < 0;
        txt = windowLabel(a, b, band.len);
        var sh = band.len >= 1440 ? "" : dayTag(Math.floor(a / 1440));
        if (sh) txt += ' <span class="day">' + sh + "</span>";
        if (out) txt += ' <span class="day">off</span>';
      } else {
        var s0 = mod(st.ws - d, 1440), s1 = s0 + (st.we - st.ws);
        txt = hhmm(s0) + "–" + endLabel(s1);
        if (s1 > 1440) txt += ' <span class="day">+1d</span>';
      }
      rows += '<div class="vrow' + (out ? " out" : "") + '">' +
                '<span class="vc">' + esc(c.name) +
                  (id === st.anchor ? '<span class="ref">REF</span>' : "") + "</span>" +
                '<span class="vt">' + txt + "</span>" +
              "</div>";
    });
    vlist.innerHTML = rows;
  }

  function renderPicker(r) {
    var q = searchIn.value.trim().toLowerCase();
    var full = st.sel.length >= 4, shown = 0;
    CITIES.forEach(function (c) {
      var ref = pickRefs[c.id], on = st.sel.indexOf(c.id) >= 0;
      var off = offsetOf(c.id);
      ref.off.textContent = offLabel(off).replace("UTC", "");
      ref.tick.textContent = on ? "✓" : "";
      ref.btn.className = "cbtn" + (on ? " sel" : "");
      ref.btn.setAttribute("aria-pressed", String(on));
      ref.btn.disabled = (!on && full) || (on && st.sel.length <= 2);
      /* offsets are searchable both as printed (UTC+05:30) and as typed
         (+5, +5:30, -8) — nobody types the zero pad or the minus sign U+2212 */
      var sg = off < 0 ? "-" : "+", oh = Math.floor(Math.abs(off) / 60), om = Math.abs(off) % 60;
      var hay = (c.name + " " + c.region + " " + c.zone + " " + offLabel(off) +
                 " " + sg + oh + " " + sg + oh + ":" + pad2(om)).toLowerCase();
      var hit = !q || hay.indexOf(q) >= 0;
      ref.btn.hidden = !hit;
      if (hit) shown++;
    });
    var empty = picker.querySelector(".empty");
    if (shown === 0 && !empty) {
      var e = document.createElement("div");
      e.className = "empty"; e.textContent = "No city matches “" + searchIn.value.trim() + "”";
      picker.appendChild(e);
    } else if (shown > 0 && empty) {
      empty.parentNode.removeChild(empty);
    } else if (empty) {
      empty.textContent = "No city matches “" + searchIn.value.trim() + "”";
    }
    pickStatus.textContent = full
      ? "4 of 4 chosen · drop one to swap it out"
      : st.sel.length + " chosen · room for " + (4 - st.sel.length) + " more";

    var sig = st.sel.slice().sort().join(",");
    Array.prototype.forEach.call(presetsEl.children, function (b, i) {
      var on = PRESETS[i].sel.slice().sort().join(",") === sig;
      b.className = "btn" + (on ? " on" : "");
      b.setAttribute("aria-pressed", String(on));
    });
  }

  /* ---------- cursor ---------- */
  function centreCursor() {
    var b = current && current.band;
    st.cursor = b ? mod(b.start + Math.floor(b.len / 2), 1440) : 12 * 60;
  }
  function setCursor(p) {
    st.cursor = Math.max(0, Math.min(1439, Math.round(p)));
    updateCursorUI();
  }
  function xToP(clientX) {
    var rect = axTrack.getBoundingClientRect();
    if (!rect.width) return st.cursor;
    var f = (clientX - rect.left) / rect.width;
    f = Math.max(0, Math.min(0.999999, f));
    return Math.round(f * 1440 / 5) * 5;
  }

  var dragging = false;
  tl.addEventListener("pointerdown", function (e) {
    var onHandle = e.target === handle;
    if (!onHandle) {
      if (e.target.closest("button")) return;
      if (!e.target.closest(".scrub")) return;
    }
    dragging = true;
    e.preventDefault();
    try { tl.setPointerCapture(e.pointerId); } catch (err) { /* capture optional */ }
    handle.focus();
    if (!onHandle) setCursor(xToP(e.clientX));
  });
  tl.addEventListener("pointermove", function (e) {
    if (!dragging) return;
    e.preventDefault();
    setCursor(xToP(e.clientX));
  });
  function endDrag(e) {
    if (!dragging) return;
    dragging = false;
    try { tl.releasePointerCapture(e.pointerId); } catch (err) { /* fine */ }
  }
  tl.addEventListener("pointerup", endDrag);
  tl.addEventListener("pointercancel", endDrag);

  handle.addEventListener("keydown", function (e) {
    var step = e.shiftKey ? 60 : 15, p = st.cursor, used = true;
    switch (e.key) {
      case "ArrowLeft":  case "ArrowDown":  p -= step; break;
      case "ArrowRight": case "ArrowUp":    p += step; break;
      case "PageDown":   p -= 180; break;
      case "PageUp":     p += 180; break;
      case "Home":       p = 0; break;
      case "End":        p = 1439; break;
      default: used = false;
    }
    if (!used) return;
    e.preventDefault();
    setCursor(Math.max(0, Math.min(1439, p)));
  });

  /* ---------- controls ---------- */
  dateIn.addEventListener("change", function () {
    /* an empty or out-of-range date (typed years like 0002 are possible in
       some browsers) would push Intl into pre-standard-time offsets */
    var p = /^(\d{4})-(\d{2})-(\d{2})$/.exec(dateIn.value);
    var yr = p ? +p[1] : 0;
    if (!p || yr < 1970 || yr > 2100) { dateIn.value = st.date; return; }
    st.date = dateIn.value;
    current = solve(); centreCursor(); render();
  });

  function hoursChanged(which) {
    var ws = +wsIn.value, we = +weIn.value;
    if (we <= ws) {
      if (which === "start") { we = Math.min(1440, ws + 60); weIn.value = String(we); }
      else { ws = Math.max(0, we - 60); wsIn.value = String(ws); }
    }
    st.ws = ws; st.we = we;
    current = solve(); centreCursor(); render();
  }
  wsIn.addEventListener("change", function () { hoursChanged("start"); });
  weIn.addEventListener("change", function () { hoursChanged("end"); });

  presetsEl.addEventListener("click", function (e) {
    var b = e.target.closest("button[data-preset]");
    if (!b) return;
    var p = PRESETS[+b.dataset.preset];
    st.sel = p.sel.slice(); st.anchor = p.anchor;
    current = solve(); centreCursor(); render();
  });

  picker.addEventListener("click", function (e) {
    var b = e.target.closest("button[data-city]");
    if (!b || b.disabled) return;
    var id = b.dataset.city, i = st.sel.indexOf(id);
    if (i >= 0) {
      if (st.sel.length <= 2) return;
      st.sel.splice(i, 1);
      if (st.anchor === id) st.anchor = st.sel[0];
    } else {
      if (st.sel.length >= 4) return;
      st.sel.push(id);
    }
    current = solve(); centreCursor(); render();
  });

  searchIn.addEventListener("input", function () { renderPicker(current); });

  /* the rows are rebuilt wholesale, so the button that was clicked no longer
     exists afterwards — put the keyboard back on its replacement */
  function refocusRow(sel, fallbackIdx) {
    var el = sel ? rowsEl.querySelector(sel) : null;
    if (!el || el.disabled) {
      var all = rowsEl.querySelectorAll("button[data-anchor]");
      el = all[Math.min(fallbackIdx || 0, Math.max(0, all.length - 1))];
    }
    if (el) el.focus();
  }

  rowsEl.addEventListener("click", function (e) {
    var rem = e.target.closest("button[data-remove]");
    if (rem) {
      if (st.sel.length <= 2) return;
      var id = rem.dataset.remove, i = st.sel.indexOf(id);
      var rowIdx = Array.prototype.indexOf.call(
        rowsEl.querySelectorAll("button[data-remove]"), rem);
      if (i >= 0) st.sel.splice(i, 1);
      if (st.anchor === id) st.anchor = st.sel[0];
      current = solve(); centreCursor(); render();
      refocusRow(null, rowIdx);
      return;
    }
    var an = e.target.closest("button[data-anchor]");
    if (an) {
      var nid = an.dataset.anchor;
      if (nid === st.anchor) return;
      /* keep the cursor pinned to the same instant while the frame moves */
      st.cursor = mod(st.cursor + (offsetOf(nid) - offsetOf(st.anchor)), 1440);
      st.anchor = nid;
      render();
      refocusRow('button[data-anchor="' + nid + '"]', 0);
    }
  });

  jumpBtn.addEventListener("click", function () { centreCursor(); render(); });

  /* ---------- copy ---------- */
  function summaryText() {
    var r = current, band = r.band, lines = [];
    lines.push("Meeting overlap · " + dateLabel(0));
    if (band && band.full) {
      lines.push("Everyone free for " + durLabel(band.len) + ":");
    } else if (band) {
      lines.push("No full overlap. Best is " + band.count + " of " + r.ids.length +
                 " for " + durLabel(band.len) + ":");
    } else {
      lines.push("No shared working time at all.");
    }
    var width = 0;
    r.ids.forEach(function (id) { width = Math.max(width, byId[id].name.length); });
    r.ids.forEach(function (id) {
      var c = byId[id], d = r.dc[id], name = c.name, pad = "";
      while (name.length + pad.length < width) pad += " ";
      var txt;
      if (band) {
        var a = band.start + d, b = band.start + band.len + d;
        txt = band.len >= 1440 ? "all day" : hhmm(a) + "-" + hhmm(b);
        var sh = band.len >= 1440 ? "" : dayTag(Math.floor(a / 1440));
        if (sh) txt += " " + sh;
        if (band.members.indexOf(id) < 0) txt += "  (off hours)";
      } else {
        txt = hhmm(st.ws) + "-" + endLabel(st.we) + " local";
      }
      lines.push("  " + name + pad + "  " + txt + "   " + offLabel(r.offs[id]).replace("−", "-"));
    });
    lines.push("");
    lines.push("working hours " + hhmm(st.ws) + "-" + endLabel(st.we) + " local · sloprun.dev");
    return lines.join("\n");
  }

  var copyTimer = null;
  function flash(msg, good) {
    copyStatus.className = "status" + (good ? " ok" : " warn");
    copyStatus.innerHTML = "<b>" + esc(msg) + "</b>";
    if (copyTimer) clearTimeout(copyTimer);
    copyTimer = setTimeout(function () { copyStatus.innerHTML = ""; copyStatus.className = "status"; }, 3200);
  }
  copyBtn.addEventListener("click", function () {
    var text = summaryText();
    function fallback() {
      try {
        var ta = document.createElement("textarea");
        ta.value = text;
        ta.setAttribute("readonly", "");
        ta.style.position = "fixed"; ta.style.top = "-1000px"; ta.style.opacity = "0";
        document.body.appendChild(ta);
        ta.select();
        var ok = document.execCommand("copy");
        document.body.removeChild(ta);
        if (ok) flash("copied to clipboard", true);
        else flash("clipboard blocked here", false);
      } catch (err) { flash("clipboard blocked here", false); }
    }
    if (navigator.clipboard && navigator.clipboard.writeText) {
      navigator.clipboard.writeText(text).then(function () {
        flash("copied to clipboard", true);
      }, fallback);
    } else fallback();
  });

  /* ---------- go ---------- */
  current = solve();
  centreCursor();
  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