▶ TuneStatus_

The source

1394 lines · 62 KB · MIT license · this exact file is what you install

Everything TuneStatus does is in this one file — the setup wizard, the dashboard, and the 30-second sync loop. Search it for fetch( and you'll find every network call it can ever make: spotify.com and slack.com, nothing else. Download it and it's yours.

// TuneStatus — your music, in your Slack status. https://tunestatus.com
//
// MIT License · Copyright (c) 2026 Brent (tunestatus.com)
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software, to deal in the Software without restriction. THE SOFTWARE
// IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND. Full text:
// https://opensource.org/license/mit
//
// TuneStatus (local edition) — Spotify → Slack status sync, 100% on this machine.
//
// You bring your own Slack app (paste its token) and your own Spotify app
// (created via the guided wizard) — everything is scoped to your own status
// and currently-playing track, and stored only in ~/.tunestatus-local.json.
//
// Web UI (http://127.0.0.1:8898):
//   GET  /                 — setup wizard, live status, settings
//   POST /slack-token      — save & verify your Slack token
//   POST /spotify-credentials, /reset-spotify — your Spotify app credentials
//   GET  /auth/spotify, /callback/spotify — Spotify authorization
//   POST /settings, /autostart, /disconnect
// Sync: every 30 seconds while this app is running.

const SPOTIFY_SCOPE = "user-read-currently-playing";
const STATUS_EXPIRATION_S = 300; // refreshed before expiry; clears within 5 min if the app stops


// The real Spotify logo PNG, served at /spotify.png and used in the UI.

const EMOJI_CHOICES = [":spotify:", ":musical_note:", ":headphones:", ":notes:", ":guitar:", ":cd:", ":radio:"];
const DEFAULT_EMOJI = ":musical_note:"; // most workspaces lack a custom :spotify: emoji


// ---------- helpers ----------

function pngResponse(base64, contentType) {
  const bytes = Uint8Array.from(atob(base64), (c) => c.charCodeAt(0));
  return new Response(bytes, {
    headers: { "Content-Type": contentType, "Cache-Control": "public, max-age=86400" },
  });
}

function baseUrl(request) {
  const url = new URL(request.url);
  return `${url.protocol}//${url.host}`;
}

function getUserId(request) {
  const cookie = request.headers.get("Cookie") || "";
  const match = cookie.match(/(?:^|;\s*)uid=([a-f0-9-]+)/);
  return match ? match[1] : null;
}

function withUserCookie(response, userId) {
  response.headers.append(
    "Set-Cookie",
    `uid=${userId}; Path=/; Max-Age=31536000; HttpOnly; SameSite=Lax`
  );
  return response;
}

async function getUser(env, userId) {
  if (!userId) return null;
  return env.USERS.get(`user:${userId}`, "json");
}

async function putUser(env, userId, user) {
  await env.USERS.put(`user:${userId}`, JSON.stringify(user));
}

function redirect(location, status = 302) {
  return new Response(null, { status, headers: { Location: location } });
}

// CSRF protection for the OAuth flows: a random state in a short-lived cookie,
// echoed back by the provider and compared in the callback.
function redirectWithState(location, state) {
  const res = redirect(location);
  res.headers.append(
    "Set-Cookie",
    `oauth_state=${state}; Path=/; Max-Age=600; HttpOnly; SameSite=Lax`
  );
  return res;
}

function stateMatches(request) {
  const url = new URL(request.url);
  const returned = url.searchParams.get("state");
  const cookie = (request.headers.get("Cookie") || "").match(/(?:^|;\s*)oauth_state=([a-f0-9]+)/);
  return Boolean(returned && cookie && returned === cookie[1]);
}

// The user's own Spotify app credentials.
function spotifyCreds(env, user) {
  return {
    id: user?.spotifyClientId || env.SPOTIFY_CLIENT_ID,
    secret: user?.spotifyClientSecret || env.SPOTIFY_CLIENT_SECRET,
  };
}

function esc(s) {
  return String(s).replace(/[&<>"']/g, (c) =>
    ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
}

// Current hour (0-23) in an IANA timezone; null if the tz string is invalid.
function hourIn(tz) {
  try {
    return parseInt(
      new Intl.DateTimeFormat("en-US", { hour: "numeric", hourCycle: "h23", timeZone: tz })
        .format(new Date()),
      10
    );
  } catch {
    return null;
  }
}

// Is this user's sync currently active per their work-hours window?
function withinWorkHours(user) {
  const wh = user.workHours;
  if (!wh) return true;
  const hour = hourIn(wh.tz);
  if (hour === null) return true; // bad tz — fail open rather than go silent
  // Window may wrap midnight (e.g. 22 → 6).
  return wh.start <= wh.end
    ? hour >= wh.start && hour < wh.end
    : hour >= wh.start || hour < wh.end;
}


// ---------- user index ----------

async function getUserIndex(env) {
  const idx = await env.USERS.get("index:users", "json");
  if (Array.isArray(idx)) return idx;
  // Self-heal: build once via list() if quota allows, else start empty and let
  // ensureInIndex repopulate as users touch the site.
  try {
    const list = await env.USERS.list({ prefix: "user:" });
    const built = list.keys.map((k) => k.name);
    await env.USERS.put("index:users", JSON.stringify(built));
    return built;
  } catch {
    return [];
  }
}

async function ensureInIndex(env, userId) {
  const key = `user:${userId}`;
  const idx = await getUserIndex(env);
  if (!idx.includes(key)) {
    idx.push(key);
    await env.USERS.put("index:users", JSON.stringify(idx));
  }
}

async function removeFromIndex(env, userId) {
  const key = `user:${userId}`;
  const idx = await getUserIndex(env);
  if (idx.includes(key)) {
    await env.USERS.put("index:users", JSON.stringify(idx.filter((k) => k !== key)));
  }
}

// ---------- landing page ----------

async function countConnected(env) {
  const keys = await getUserIndex(env);
  const users = await Promise.all(keys.map((name) => env.USERS.get(name, "json")));
  return users.filter((u) => u?.slackUserToken && u?.spotifyRefreshToken).length;
}

async function userCount(env) {
  return new Response(String(await countConnected(env)), {
    headers: { "Content-Type": "text/plain", "Cache-Control": "no-store" },
  });
}

async function home(request, env) {
  const saved = new URL(request.url).searchParams.get("saved") === "1";
  const userId = getUserId(request);
  const user = await getUser(env, userId);
  const slackDone = Boolean(user?.slackUserToken);
  const credsDone = Boolean(user?.spotifyClientId);
  const spotifyDone = Boolean(user?.spotifyRefreshToken);
  const allDone = slackDone && spotifyDone;
  const callbackUrl = `${baseUrl(request)}/callback/spotify`;
  const nUsers = await countConnected(env).catch(() => null);
  const spotifyEmoji = allDone ? await checkSpotifyEmoji(user.slackUserToken) : null;

  const stepState = (done, locked) => (done ? "done" : locked ? "locked" : "");

  const body = `
  <header class="site">
    <a class="brand" href="/" aria-label="TuneStatus">▶ Tune<span class="accentt">Status</span><span class="cursor accentt">_</span></a>
    <nav>
      <a href="#setup">Setup</a>
      <a href="#privacy">Privacy</a>
      <div class="themeseg" role="group" aria-label="Theme">
        <button type="button" data-theme="dark">Dark</button><button type="button" data-theme="light">Light</button>
      </div>
    </nav>
  </header>

  <main>
    <section class="hero">
      <div class="hero-left">
        <div class="eyebrow"><span class="dot"></span> LIVE SYNC</div>
        <h1>Your music,<br>in your Slack status.</h1>
        <p class="lede">While Spotify plays, your Slack status shows the track. When it
        stops, it clears. Runs entirely on this computer — one short setup (about 8 minutes), then forget it exists.</p>
        <div class="cta-row">
          ${allDone
            ? `<a class="btn-primary" href="#sync">You're live — view status</a>`
            : `<a class="btn-primary" href="#setup">Set up once (~8 min)</a>`}
          <a class="btn-secondary" href="#privacy">How it works</a>
        </div>
        <div class="trust">◇ Free forever&nbsp;&nbsp;·&nbsp;&nbsp;◇ Your keys stay yours&nbsp;&nbsp;·&nbsp;&nbsp;◇ No account needed${nUsers >= 3 ? `&nbsp;&nbsp;·&nbsp;&nbsp;◇ Loved by <span id="usercount">${nUsers}</span>&nbsp;<span id="userword">users</span>` : ""}</div>
      </div>
      <div class="hero-right">
        <div class="statuscard">
          <div class="statuscard-label">YOUR SLACK STATUS, LIVE</div>
          <div class="statuscard-body" id="livecard">
            ${allDone ? statusPanel(user) : `
            <div class="mockstatus">
              <div class="mockavatar"></div>
              <div>
                <div class="mockname">you</div>
                <div class="mocktrack">♪ Mirror Talk — Nic D</div>
              </div>
            </div>
            <div class="mocknote">— what your teammates will see</div>`}
          </div>
        </div>
      </div>
    </section>

    ${allDone ? syncSection(user, saved, spotifyEmoji) : ""}

    <section id="privacy" class="privacystrip">
      <span class="privlabel">PRIVACY</span>
      <span class="privtext">Everything runs on this computer. You create your own Slack and Spotify
      apps, and your keys are saved to a file in your home folder — they never leave this
      machine. Disconnect deletes them.</span>
    </section>

    <section id="setup" class="setup">
      <div class="setup-head"><h2>Setup</h2><span class="setup-time">~8 min · one-time</span></div>
      <div class="setup-grid">

        <div class="card ${stepState(slackDone, false)}">
          <div class="step-eyebrow">STEP 01</div>
          <h3>Create your free Slack app</h3>
          ${slackDone ? "" : `<p class="cardnote">Don't worry — it's just a permission slip
          that lets TuneStatus update your status. Five clicks on Slack's website,
          nothing to build.</p>`}
          ${slackDone
            ? `<p class="okline">✓ Connected${user.slackName ? " as <b>" + esc(user.slackName) + "</b>" : ""}</p>`
            : `
          <ol>
            <li>Open <a href="https://api.slack.com/apps" target="_blank" rel="noopener">api.slack.com/apps</a>
                → <b>Create New App</b> → <b>From scratch</b>.</li>
            <li>Name it <code>TuneStatus</code>, pick your workspace, <b>Create App</b>.</li>
            <li>In the left sidebar, open <b>OAuth &amp; Permissions</b>. Scroll down to
                <b>User Token Scopes</b> (the <u>lower</u> scopes box — not Bot Token Scopes)
                and click <b>Add an OAuth Scope</b>. Add these three, one at a time:
                <code>users.profile:write</code>, <code>users.profile:read</code>,
                <code>emoji:read</code>.</li>
            <li>Scroll back to the top of that page and click <b>Install to Workspace</b>
                (it may show your workspace's name instead) → <b>Allow</b>.</li>
            <li>You land back on the same page — copy the <b>User OAuth Token</b>
                (it starts with <code>xoxp-</code>; if yours starts with <code>xoxb-</code>,
                that's the wrong one) and paste it below.</li>
          </ol>
          <form method="POST" action="/slack-token">
            <input name="slack_token" type="password" required pattern="xoxp-.+"
              placeholder="xoxp-…" autocomplete="off" aria-label="Slack user token">
            <button class="btn-card" type="submit">Save &amp; verify</button>
          </form>`}
        </div>

        <div class="card ${stepState(credsDone || spotifyDone, !slackDone)}">
          <div class="step-eyebrow">STEP 02</div>
          <h3>Create your free Spotify app</h3>
          ${credsDone || spotifyDone ? "" : `<p class="cardnote">Also just a permission slip —
          a two-minute form on Spotify's website. <b>Made one before?</b> Skip the form:
          open your existing app on the dashboard and copy its Client ID and secret
          straight into the boxes below.</p>`}
          ${credsDone || spotifyDone
            ? `<p class="okline">✓ ${credsDone ? "Credentials saved" : "Using original shared app"}</p>
               ${!spotifyDone ? `<form method="POST" action="/reset-spotify">
                 <button class="linkish" type="submit">Typo? Re-enter credentials</button></form>` : ""}`
            : `
          <ol>
            <li>Open <a href="https://developer.spotify.com/dashboard" target="_blank" rel="noopener">developer.spotify.com/dashboard</a>
                and log in with <b>the Spotify account that plays your music</b>.
                (First time here? Spotify shows its Developer Terms — accept and continue.)
                Then click <b>Create app</b>.</li>
            <li>Name/description: anything. <b>Redirect URI</b> — paste exactly, click <b>Add</b>:
              <span class="chip"><code id="cb">${callbackUrl}</code>
              <button type="button" class="copy" onclick="copyCb(this)">Copy</button></span></li>
            <li>Tick <b>Web API</b>, accept terms, <b>Save</b>.</li>
            <li>Copy the <b>Client ID</b>; click <b>View client secret</b>, copy that too.</li>
          </ol>
          <form method="POST" action="/spotify-credentials">
            <input name="client_id" required placeholder="Client ID"
              autocomplete="off" aria-label="Spotify client ID">
            <input name="client_secret" type="password" required
              placeholder="Client secret" autocomplete="off" aria-label="Spotify client secret">
            <button class="btn-card" type="submit">Save &amp; continue</button>
          </form>`}
        </div>

        <div class="card ${stepState(spotifyDone, !credsDone && !spotifyDone)}">
          <div class="step-eyebrow">STEP 03</div>
          <h3>Connect Spotify</h3>
          ${spotifyDone
            ? `<p class="okline">✓ Connected — you're done</p>`
            : `<p class="cardnote">One authorization click, and you're done — your status
               updates itself from now on.</p>
               ${credsDone
                 ? `<a class="btn-primary" href="/auth/spotify">Connect Spotify →</a>`
                 : `<p class="finenote">Complete steps 1 &amp; 2 first.</p>
                    <span class="btn-primary disabled">Connect Spotify →</span>`}`}
        </div>

      </div>
    </section>

    <section class="faq">
      <h2>FAQ</h2>
      <details><summary>Is this safe? What can it see?</summary>
        <p>It can see the song you're playing and your own Slack status — nothing else.
        No messages, no files, nothing about anyone but you. And because you create the
        connections in your own accounts, you can shut it all off yourself, anytime.</p></details>
      <details><summary>Where do my keys go?</summary>
        <p>Into <code>~/.tunestatus-local.json</code> on this computer — nowhere else.
        No server, no cloud, no third party. Disconnect deletes them. There's no
        listening history either; each song overwrites the last.</p></details>
      <details><summary>What if I set my own status, like "In a meeting"?</summary>
        <p>Your status wins. TuneStatus never overwrites something you set by hand — it
        waits, and your music comes back after you clear it.</p></details>
      <details><summary>Can I hide my music sometimes?</summary>
        <p>Yep — there's a pause switch in your sync settings, plus an optional "only
        during work hours" schedule.</p></details>
      <details><summary>How fast does it update?</summary>
        <p>Within ~30 seconds. If this app stops running (or your laptop sleeps), your
        status clears itself automatically a few minutes later — walking away from your
        desk politely clears your status. (Podcasts are ignored, by the way.)</p></details>
      <details><summary>What does it cost?</summary>
        <p>Nothing. If it made you smile, there's a Dr Pepper button below. 🥤</p></details>
    </section>
  </main>

  <footer class="site">
    <span>Free forever · made by Brent · if it made your day 1% more musical…</span>
    <a class="donate" href="https://www.buymeacoffee.com/brentdev" target="_blank" rel="noopener">Buy me a Dr Pepper 🥤</a>
  </footer>

  <script>
    function copyCb(btn) {
      var el = document.getElementById("cb");
      if (el) navigator.clipboard.writeText(el.textContent)
        .then(() => { btn.textContent = "Copied!"; setTimeout(() => btn.textContent = "Copy", 1500); });
    }
    var tzInput = document.getElementById("tz");
    if (tzInput) tzInput.value = Intl.DateTimeFormat().resolvedOptions().timeZone || "";
    // "Settings saved" toast: clean the URL and auto-fade.
    (function () {
      var toast = document.querySelector(".toast");
      if (!toast) return;
      history.replaceState(null, "", location.pathname + location.hash);
      setTimeout(function () {
        toast.style.transition = "opacity .6s"; toast.style.opacity = "0";
        setTimeout(function () { toast.remove(); }, 700);
      }, 3500);
    })();
    // Warn before leaving with unsaved form changes.
    (function () {
      var dirty = false;
      document.querySelectorAll("form").forEach(function (f) {
        f.addEventListener("input", function () { dirty = true; });
        f.addEventListener("change", function () { dirty = true; });
        f.addEventListener("submit", function () { dirty = false; });
      });
      window.addEventListener("beforeunload", function (e) {
        if (dirty) { e.preventDefault(); e.returnValue = ""; }
      });
    })();
    // Live user counter.
    (function () {
      var c = document.getElementById("usercount"), w = document.getElementById("userword");
      if (!c) return;
      setInterval(function () {
        fetch("/count").then(function (r) { return r.ok ? r.text() : null; }).then(function (n) {
          if (n === null || n === c.textContent) return;
          c.textContent = n;
          if (w) w.textContent = n === "1" ? "user" : "users";
        }).catch(function () {});
      }, 10000);
    })();
    // Wizard mode: in the active setup card, show one instruction at a time.
    (function () {
      var card = Array.prototype.find.call(
        document.querySelectorAll(".setup-grid .card"),
        function (c) { return !c.classList.contains("done") && !c.classList.contains("locked") && c.querySelector("ol"); }
      );
      if (!card) return;
      var items = Array.prototype.slice.call(card.querySelectorAll("ol > li"));
      var form = card.querySelector("form");
      if (items.length < 2) return;

      var i = 0;
      var nav = document.createElement("div");
      nav.className = "wiznav";
      nav.innerHTML = '<button type="button" class="btn-card" id="wizback">← Back</button>' +
        '<span class="wizprog"></span>' +
        '<button type="button" class="btn-card wiznext" id="wiznext">Done, next →</button>';
      card.querySelector("ol").after(nav);

      function render() {
        items.forEach(function (li, n) { li.style.display = n === i ? "" : "none"; });
        var atForm = i >= items.length;
        if (atForm) items.forEach(function (li) { li.style.display = "none"; });
        nav.querySelector(".wizprog").textContent = atForm
          ? "last thing:" : (i + 1) + " of " + items.length;
        nav.querySelector("#wizback").style.visibility = i === 0 ? "hidden" : "visible";
        nav.querySelector("#wiznext").style.display = atForm ? "none" : "";
        if (form) form.style.display = atForm ? "" : "none";
      }
      nav.querySelector("#wizback").addEventListener("click", function () { if (i > 0) { i--; render(); } });
      nav.querySelector("#wiznext").addEventListener("click", function () { i++; render(); });
      render();
    })();
    // Keep the live status card fresh.
    (function () {
      var el = document.querySelector("#livecard section.status");
      if (!el) return;
      setInterval(function () {
        fetch("/panel").then(function (r) { return r.ok && r.status !== 204 ? r.text() : null; })
          .then(function (html) {
            if (!html) return;
            var live = document.querySelector("#livecard section.status");
            if (live) { live.outerHTML = html; }
          }).catch(function () {});
      }, 30000);
    })();
  </script>`;

  return withUserCookie(
    new Response(page(body), { headers: { "Content-Type": "text/html; charset=utf-8" } }),
    userId || crypto.randomUUID()
  );
}

// Post-setup control center: live status + settings.
// Does this workspace have a custom :spotify: emoji? Needs the emoji:read scope.
// Returns { available, url } or { scopeMissing: true } or null on any failure.
async function checkSpotifyEmoji(token) {
  try {
    const res = await slackApi(token, "emoji.list", {});
    if (!res.ok) return res.error === "missing_scope" ? { scopeMissing: true } : null;
    let url = res.emoji && res.emoji["spotify"];
    if (typeof url === "string" && url.startsWith("alias:")) url = res.emoji[url.slice(6)];
    return url ? { available: true, url } : { available: false };
  } catch {
    return null;
  }
}

function syncSection(user, saved, spotifyEmoji) {
  const wh = user.workHours;
  const hourOptions = (sel) =>
    Array.from({ length: 24 }, (_, h) =>
      `<option value="${h}" ${h === sel ? "selected" : ""}>${String(h).padStart(2, "0")}:00</option>`).join("");
  const emoji = user.emoji || DEFAULT_EMOJI;
  const labels = { ":musical_note:": "🎵", ":headphones:": "🎧", ":notes:": "🎶", ":guitar:": "🎸", ":cd:": "💿", ":radio:": "📻" };
  if (spotifyEmoji?.available) {
    labels[":spotify:"] = `<img src="${spotifyEmoji.url}" alt="Spotify" width="20" height="20" style="display:block">`;
  }
  const emojiRadios = EMOJI_CHOICES.filter((e) => labels[e]).map((e) =>
    `<label class="emoji"><input type="radio" name="emoji" value="${e}" ${e === emoji ? "checked" : ""}>
     ${labels[e]}</label>`).join("");
  const emojiTip = spotifyEmoji?.available ? "" : spotifyEmoji?.scopeMissing ? `
      <p class="finenote">Want the Spotify logo as an option here? Add the <code>emoji:read</code>
      scope to your Slack app (User Token Scopes), reinstall, and paste the new token.</p>` : `
      <p class="finenote">Want the Spotify logo here? Your workspace doesn't have a
      <code>:spotify:</code> emoji yet — takes 2 minutes to add:</p>
      <ol class="finenote-list">
        <li>Download it from <a href="https://slackmojis.com/emojis/1358-spotify" target="_blank" rel="noopener">slackmojis.com</a></li>
        <li>In Slack: your workspace name → <b>Customize workspace</b> → <b>Emoji</b> → <b>Add Custom Emoji</b></li>
        <li>Upload the image and name it exactly <code>spotify</code></li>
        <li>Refresh this page — the logo appears here automatically</li>
      </ol>`;

  return `
  <section id="sync" class="setup">
    <div class="setup-head"><h2>Your sync</h2><span class="setup-time">checked every 30 seconds</span></div>
    ${saved ? `<p class="toast">✓ Settings saved</p>` : ""}
    <div class="card">
      <div class="step-eyebrow">SETTINGS</div>
      <form method="POST" action="/settings" class="settings-form">
        <input type="hidden" name="tz" id="tz" value="${esc(wh?.tz || "")}">
        <label class="inline"><input type="checkbox" name="paused" ${user.paused ? "checked" : ""}>
          Pause sync (hide my music for now)</label>
        <div class="field"><span class="fieldname">Status emoji</span><div class="emojirow">${emojiRadios}</div>${emojiTip}</div>
        <label class="inline"><input type="checkbox" name="use_hours" ${wh ? "checked" : ""}>
          Only sync between
          <select name="start">${hourOptions(wh?.start ?? 9)}</select> and
          <select name="end">${hourOptions(wh?.end ?? 18)}</select> (your local time)</label>
        <div><button class="btn-card" type="submit">Save settings</button></div>
      </form>
      ${typeof process !== "undefined" && process.platform === "darwin" ? `
      <form method="POST" action="/autostart">
        ${isAutostartInstalled()
          ? `<p class="okline">✓ Starts automatically at login</p>
             <input type="hidden" name="action" value="uninstall">
             <button class="linkish" type="submit">Turn off</button>`
          : `<input type="hidden" name="action" value="install">
             <button class="btn-card" type="submit">Start automatically at login</button>
             <p class="finenote">So you never have to think about TuneStatus again.</p>`}
      </form>` : ""}
      <form method="POST" action="/disconnect">
        <button class="linkish danger-link" type="submit">Disconnect me completely</button>
      </form>
    </div>
  </section>`;
}

// Admin-only usage overview. Requires ?key= matching the STATS_KEY secret.
async function stats(request, env) {
  const key = new URL(request.url).searchParams.get("key");
  if (!env.STATS_KEY || key !== env.STATS_KEY) {
    return new Response("Not found", { status: 404 }); // don't advertise the route
  }

  const keys = await getUserIndex(env);
  const users = (
    await Promise.all(keys.map((name) => env.USERS.get(name, "json")))
  ).filter(Boolean);

  const now = Date.now();
  const day = 24 * 60 * 60 * 1000;
  const fullyConnected = users.filter((u) => u.slackUserToken && u.spotifyRefreshToken);
  const activeWeek = fullyConnected.filter((u) => u.lastPlayedAt && now - u.lastPlayedAt < 7 * day);
  const playingNow = fullyConnected.filter((u) => u.lastTrack);

  const ago = (ts) => {
    if (!ts) return "never";
    const m = Math.round((now - ts) / 60000);
    if (m < 60) return `${m}m ago`;
    if (m < 1440) return `${Math.round(m / 60)}h ago`;
    return `${Math.round(m / 1440)}d ago`;
  };

  const rows = fullyConnected.map((u) => `<tr>
    <td>${esc(u.slackName || "(no name)")}</td>
    <td>${u.lastTrack ? "▶️ " + esc(u.lastTrack) : u.paused ? "⏸ paused" : "—"}</td>
    <td>${ago(u.lastPlayedAt)}</td>
    <td>${u.lastError ? "⚠️ " + esc(u.lastError) : "ok"}</td>
  </tr>`).join("");

  const body = `<main>
    <h1>📊 Usage</h1>
    <section class="status"><p>
      <b>${fullyConnected.length}</b> connected user(s)
      · <b>${activeWeek.length}</b> active this week
      · <b>${playingNow.length}</b> playing right now
      ${users.length > fullyConnected.length ? `· ${users.length - fullyConnected.length} partial signup(s)` : ""}
    </p></section>
    <section><table style="width:100%;border-collapse:collapse" border="0">
      <tr><th align="left">Who</th><th align="left">Status</th><th align="left">Last played</th><th align="left">Health</th></tr>
      ${rows || "<tr><td colspan=4>No connected users yet.</td></tr>"}
    </table></section>
  </main>`;
  return new Response(page(body), { headers: { "Content-Type": "text/html; charset=utf-8" } });
}

// A fixed-size 1200x630 card for screenshotting as a Slack promo image.
function promo() {
  const html = `<!doctype html>
<html><head><meta charset="utf-8"><style>
  * { margin: 0; box-sizing: border-box; }
  body { width: 1200px; height: 630px; background: #000; overflow: hidden;
         font-family: ui-monospace, "SF Mono", Menlo, monospace; color: #00e63c;
         display: flex; align-items: center; padding: 0 64px; gap: 56px; }
  .left { flex: 1; }
  .prompt { color: #0f5a26; font-size: 20px; margin-bottom: 22px; }
  h1 { color: #00ff41; font-size: 52px; line-height: 1.15; text-shadow: 0 0 14px rgba(0,255,65,.5);
       margin-bottom: 22px; }
  .sub { font-size: 23px; line-height: 1.5; margin-bottom: 30px; color: #00e63c; }
  .status { display: inline-flex; align-items: center; gap: 12px; background: #050d05;
            border: 1px solid #0f5a26; padding: 14px 22px; font-size: 22px; color: #00ff41;
            box-shadow: 0 0 24px rgba(0,255,65,.15); margin-bottom: 30px; }
  .status img { width: 28px; height: 28px; }
  .url { font-size: 21px; color: #000; background: #00ff41; display: inline-block;
         padding: 10px 18px; font-weight: 700; }
  .right { width: 420px; }
  .right img { width: 100%; border: 1px solid #0f5a26;
               filter: grayscale(1) sepia(1) hue-rotate(70deg) saturate(4) brightness(.85); }
  .caption { text-align: center; color: #0f5a26; font-size: 17px; margin-top: 10px; }
</style></head><body>
  <div class="left">
    <div class="prompt">you@work:~$ ./tunestatus</div>
    <h1>Your music.<br>Your Slack status.<br>Automatically.</h1>
    <div class="sub">Play music on Spotify → your Slack status shows the song.
    Hit pause → it clears. Set up once, forget forever.</div>
    <div class="status"><img src="/spotify.png"> Mirror Talk — Nic D</div><br>
    <div class="url">tunestatus.com</div>
  </div>
  <div class="right">
    <img src="/heybro.jpg">
    <div class="caption">// never get asked again</div>
  </div>
</body></html>`;
  return new Response(html, { headers: { "Content-Type": "text/html; charset=utf-8" } });
}

// Fresh HTML for the status section — polled by the page every 30s.
async function panel(request, env) {
  const user = await getUser(env, getUserId(request));
  if (!user?.slackUserToken || !user?.spotifyRefreshToken) {
    return new Response("", { status: 204 });
  }
  return new Response(statusPanel(user), {
    headers: { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store" },
  });
}

// "What is the sync doing right now?" — shown at the top once set up.
// "What is the sync doing right now?" — rendered into the hero status card.
function statusPanel(user) {
  const rows = [];
  if (user.lastError) {
    rows.push(`<p class="warn">⚠ Last sync error: <code>${esc(user.lastError)}</code> —
      if this persists, disconnect &amp; reconnect below.</p>`);
  }
  if (user.paused) {
    rows.push(`<p class="statusline"><span class="mono-tag">PAUSED</span> Your music stays private until you resume.</p>`);
  } else if (user.manualStatus) {
    rows.push(`<p class="statusline"><span class="mono-tag">STANDING BY</span> You set
      <b>${esc(user.manualStatus)}</b> yourself — TuneStatus won't touch it. Clear it and
      your music returns within a minute.</p>`);
  } else if (user.lastTrack) {
    rows.push(`<p class="statusline nowplaying"><span class="playdot"></span> <b>${esc(user.lastTrack)}</b></p>`);
  } else {
    rows.push(`<p class="statusline"><span class="mono-tag">IDLE</span> Nothing playing —
      your status is clear. Put a song on and check back in a minute.</p>`);
  }
  return `<section class="status">${rows.join("")}</section>`;
}

function page(body) {
  return `<!doctype html>
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>TuneStatus (local) — your music, in your Slack status</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;700&family=IBM+Plex+Mono:wght@400;500;600&display=swap" rel="stylesheet">
<script>
  // Apply saved theme before first paint. Dark is the default.
  (function () {
    if (localStorage.getItem("ts-theme") === "light") document.documentElement.className = "light";
  })();
</script>
<style>
  :root {
    --bg: #0b0d0f; --fg: #e8eaec; --card: #111417; --inputBg: #0b0d0f; --btnBg: #1a1e22;
    --line: #1a1e22; --line2: #23282d; --muted: #9aa3ab; --faint: #5f6870;
    --accent: #2fd071; --onAccent: #08150c; --err: #f87171; --donate: #550212;
    --shadow: 0 24px 60px rgba(0,0,0,.35);
  }
  html.light {
    --accent: #1db956;
    --bg: #f7f8f9; --fg: #171b1f; --card: #ffffff; --inputBg: #f2f4f5; --btnBg: #eceff1;
    --line: #e4e8eb; --line2: #dde2e6; --muted: #5a646d; --faint: #8b959e;
    --err: #dc2626; --shadow: 0 24px 60px rgba(0,0,0,.08);
  }
  * { box-sizing: border-box; }
  body { margin: 0; background: var(--bg); color: var(--fg);
         font-family: "Space Grotesk", -apple-system, sans-serif;
         transition: background .25s, color .25s; }
  .mono, .eyebrow, .trust, .step-eyebrow, .setup-time, .statuscard-label, .privlabel,
  code, .chip, .finenote, .mono-tag, input, .mocktrack, .mocknote { font-family: "IBM Plex Mono", ui-monospace, monospace; }
  a { color: var(--fg); }
  a:hover, .donate:hover, .btn-primary:hover, .btn-secondary:hover { opacity: .85; }

  header.site, footer.site { display: flex; justify-content: space-between; align-items: center;
    padding: 20px 48px; max-width: 1280px; margin: 0 auto; }
  header.site { border-bottom: 1px solid var(--line); }
  footer.site { border-top: 1px solid var(--line); padding: 24px 48px; color: var(--faint); font-size: 13px; }
  :root { --logoFg: #e9e9ee; --logoAccent: #2fd071; }
  html.light { --logoFg: #17171b; --logoAccent: #1db956; }
  .brand { font-family: "IBM Plex Mono", ui-monospace, monospace; font-weight: 600; font-size: 26px;
    color: var(--logoFg); text-decoration: none; }
  .brand .accentt { color: var(--logoAccent); }
  .cursor { animation: blink 1s step-end infinite; }
  @keyframes blink { 0%,49% { opacity: 1 } 50%,100% { opacity: 0 } }
  @media (prefers-reduced-motion: reduce) { .cursor { animation: none; } }
  nav { display: flex; align-items: center; gap: 22px; }
  nav a { font-size: 14px; color: var(--muted); text-decoration: none; }
  .themeseg { border: 1px solid var(--line2); border-radius: 6px; overflow: hidden; display: flex; }
  .themeseg button { font-size: 12px; padding: 5px 12px; border: 0; background: transparent;
    color: var(--muted); cursor: pointer; font-family: inherit; }
  .themeseg button.active { background: var(--btnBg); color: var(--fg); }

  main { max-width: 1280px; margin: 0 auto; }
  .hero { display: grid; grid-template-columns: 1.1fr 1fr; gap: 48px; padding: 72px 48px 64px; align-items: center; }
  .hero-left { display: flex; flex-direction: column; gap: 20px; }
  .eyebrow { font-size: 12px; font-weight: 500; color: var(--accent); display: flex; align-items: center; gap: 8px; }
  .dot { width: 7px; height: 7px; border-radius: 50%; background: var(--accent);
         animation: pulse 1.6s ease-in-out infinite; }
  @keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: .35; } }
  h1 { font-size: 52px; font-weight: 700; line-height: 1.08; letter-spacing: -0.03em; margin: 0; }
  .lede { font-size: 17px; line-height: 1.6; color: var(--muted); max-width: 44ch; margin: 0; }
  .cta-row { display: flex; gap: 12px; }
  .btn-primary { background: var(--accent); color: var(--onAccent); font-size: 15px; font-weight: 600;
    padding: 12px 22px; border-radius: 8px; text-decoration: none; border: 0; cursor: pointer;
    display: inline-block; font-family: inherit; }
  .btn-primary.disabled { opacity: .45; cursor: not-allowed; }
  .btn-secondary { border: 1px solid var(--line2); color: var(--fg); font-size: 15px;
    padding: 12px 22px; border-radius: 8px; text-decoration: none; display: inline-block; }
  .trust { font-size: 12px; color: var(--faint); }
  .hero-right .statuscard { background: var(--card); border: 1px solid var(--line2); border-radius: 12px;
    padding: 12px; box-shadow: var(--shadow); }
  .statuscard-label { font-size: 11px; color: var(--faint); padding: 6px 8px 10px; letter-spacing: .08em; }
  .statuscard-body { min-height: 120px; border-radius: 8px; background: var(--inputBg);
    border: 1px solid var(--line); padding: 18px; display: flex; flex-direction: column;
    justify-content: center; gap: 8px; }
  .mockstatus { display: flex; align-items: center; gap: 12px; }
  .mockavatar { width: 38px; height: 38px; border-radius: 8px; background: var(--btnBg); }
  .mockname { font-weight: 700; font-size: 15px; }
  .mocktrack { font-size: 13px; color: var(--accent); margin-top: 2px; }
  .mocknote { font-size: 11px; color: var(--faint); }
  section.status { display: flex; flex-direction: column; gap: 6px; }
  .statusline { margin: 0; font-size: 14px; color: var(--muted); line-height: 1.5; }
  .statusline b { color: var(--fg); }
  .nowplaying { font-size: 16px; display: flex; align-items: center; gap: 10px; }
  .playdot { width: 8px; height: 8px; border-radius: 50%; background: var(--accent);
    animation: pulse 1.6s ease-in-out infinite; flex-shrink: 0; }
  .mono-tag { font-size: 11px; color: var(--accent); letter-spacing: .08em; margin-right: 6px; }
  .warn { color: var(--err); font-size: 13px; margin: 0; }

  .privacystrip { margin: 0 48px; background: var(--card); border: 1px solid var(--line2);
    border-radius: 12px; padding: 20px 24px; display: flex; gap: 16px; align-items: baseline; }
  .privlabel { font-size: 12px; color: var(--accent); letter-spacing: .08em; flex-shrink: 0; }
  .privtext { font-size: 14px; color: var(--muted); line-height: 1.6; }

  .setup { padding: 56px 48px 8px; }
  .setup-head { display: flex; align-items: baseline; gap: 14px; margin-bottom: 18px; }
  .setup-head h2 { font-size: 24px; margin: 0; }
  .setup-time { font-size: 12px; color: var(--faint); }
  .setup-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; align-items: start; }
  .card { background: var(--card); border: 1px solid var(--line2); border-radius: 12px;
    padding: 24px; display: flex; flex-direction: column; gap: 12px; }
  .card.locked { opacity: .6; }
  .card.done { border-color: color-mix(in srgb, var(--accent) 40%, var(--line2)); }
  .step-eyebrow { font-size: 12px; font-weight: 500; color: var(--accent); letter-spacing: .08em; }
  .card h3 { font-size: 18px; margin: 0; }
  .card ol { margin: 0; padding-left: 20px; font-size: 14px; line-height: 1.7; color: var(--muted);
    display: flex; flex-direction: column; gap: 6px; }
  .card ol b { color: var(--fg); }
  .card a { color: var(--fg); }
  code { font-size: 13px; background: var(--btnBg); border-radius: 4px; padding: 1px 6px; word-break: break-all; }
  .chip { display: inline-flex; align-items: center; gap: 8px; margin-top: 4px; }
  .copy { border: 1px solid var(--line2); background: var(--btnBg); color: var(--fg); border-radius: 6px;
    padding: 3px 10px; cursor: pointer; font-size: 12px; font-family: inherit; }
  .card form { display: flex; flex-direction: column; gap: 10px; margin: 0; }
  .card input[type=password], .card input:not([type]), .card input[type=text] {
    background: var(--inputBg); border: 1px solid var(--line2); border-radius: 6px;
    padding: 11px 12px; font-size: 13px; color: var(--fg); width: 100%; }
  .card input::placeholder { color: var(--faint); }
  .btn-card { background: var(--btnBg); border: 1px solid var(--line2); color: var(--fg);
    font-size: 13px; font-weight: 500; padding: 9px 16px; border-radius: 6px; cursor: pointer;
    font-family: inherit; align-self: flex-start; }
  .cardnote { font-size: 14px; color: var(--muted); line-height: 1.6; margin: 0; }
  .finenote { font-size: 12px; color: var(--faint); margin: 0; }
  .finenote-list { font-size: 12px; color: var(--faint); margin: 4px 0 0; padding-left: 18px;
    font-family: "IBM Plex Mono", ui-monospace, monospace; line-height: 1.7; }
  .finenote-list a { color: var(--muted); }
  .okline { color: var(--accent); font-size: 14px; font-weight: 500; margin: 0; }
  .okline b { color: var(--fg); }
  .linkish { background: none; border: 0; padding: 0; color: var(--muted); text-decoration: underline;
    cursor: pointer; font-size: 13px; align-self: flex-start; font-family: inherit; }
  .danger-link { color: var(--err); margin-top: 6px; }
  .toast { color: var(--accent); font-size: 13px; font-family: "IBM Plex Mono", monospace; margin: 0 0 12px; }

  .settings-form { gap: 14px !important; }
  .settings-form .inline { display: flex; align-items: center; gap: 8px; font-size: 14px;
    color: var(--muted); flex-wrap: wrap; }
  .fieldname { font-size: 12px; color: var(--faint); display: block; margin-bottom: 6px;
    font-family: "IBM Plex Mono", monospace; letter-spacing: .08em; }
  .emojirow { display: flex; gap: 6px; flex-wrap: wrap; }
  label.emoji { display: inline-flex; align-items: center; gap: 4px; font-size: 18px;
    border: 1px solid var(--line2); border-radius: 8px; padding: 5px 9px; cursor: pointer; }
  label.emoji:has(input:checked) { border-color: var(--accent); }
  label.emoji input { accent-color: var(--accent); }
  select { background: var(--inputBg); color: var(--fg); border: 1px solid var(--line2);
    border-radius: 6px; padding: 5px 8px; font-family: "IBM Plex Mono", monospace; font-size: 13px; }

  .faq { padding: 48px 48px 40px; max-width: 760px; }
  .faq h2 { font-size: 24px; margin: 0 0 10px; }
  .faq details { border-bottom: 1px solid var(--line); padding: 12px 0; }
  .faq details:last-of-type { border-bottom: 0; }
  .faq summary { cursor: pointer; font-weight: 500; font-size: 15px; }
  .faq p { color: var(--muted); font-size: 14px; line-height: 1.65; margin: 10px 0 2px; }

  .wiznav { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-top: 4px; }
  .wizprog { font-family: "IBM Plex Mono", monospace; font-size: 12px; color: var(--faint); }
  .wiznext { background: var(--accent); color: var(--onAccent); border: 0; font-weight: 600; }
  .setup-grid .card ol > li { min-height: 3.2em; }
  .donate { background: var(--donate); color: #fff; font-size: 13px; padding: 9px 18px;
    border-radius: 6px; text-decoration: none; }

  /* Stats page reuse */
  table { color: var(--muted); font-size: 14px; } th { color: var(--fg); }

  @media (max-width: 900px) {
    .hero { grid-template-columns: 1fr; padding: 48px 24px 40px; }
    .setup-grid { grid-template-columns: 1fr; }
    header.site, footer.site, .setup, .faq { padding-left: 24px; padding-right: 24px; }
    .privacystrip { margin: 0 24px; flex-direction: column; gap: 8px; }
    h1 { font-size: 38px; }
    footer.site { flex-direction: column; gap: 14px; text-align: center; }
  }
</style></head><body>
${body}
<script>
  (function () {
    var current = localStorage.getItem("ts-theme") === "light" ? "light" : "dark";
    document.querySelectorAll(".themeseg button").forEach(function (b) {
      if (b.dataset.theme === current) b.classList.add("active");
      b.addEventListener("click", function () {
        localStorage.setItem("ts-theme", b.dataset.theme);
        document.documentElement.className = b.dataset.theme === "light" ? "light" : "";
        document.querySelectorAll(".themeseg button").forEach(function (x) { x.classList.remove("active"); });
        b.classList.add("active");
      });
    });
  })();
</script>
</body></html>`;
}

// ---------- per-user Spotify credentials ----------

async function saveSpotifyCredentials(request, env) {
  if (request.method !== "POST") return new Response("Method not allowed", { status: 405 });
  const form = await request.formData();
  const clientId = (form.get("client_id") || "").trim();
  const clientSecret = (form.get("client_secret") || "").trim();

  if (!/^[0-9a-f]{32}$/.test(clientId) || !/^[0-9a-f]{32}$/.test(clientSecret)) {
    return new Response(page(`<main><h1>Hmm, that doesn't look right</h1>
      <p>Both the Client ID and Client secret are exactly 32 characters of
      letters and numbers. Double-check you copied the whole thing (and not the
      app name), then <a href="/">go back and try again</a>.</p></main>`),
      { status: 400, headers: { "Content-Type": "text/html; charset=utf-8" } });
  }

  const userId = getUserId(request) || crypto.randomUUID();
  const user = (await getUser(env, userId)) || {};
  user.spotifyClientId = clientId;
  user.spotifyClientSecret = clientSecret;
  delete user.spotifyRefreshToken; // new creds invalidate any old connection
  await putUser(env, userId, user);
  await ensureInIndex(env, userId);

  // Straight into the OAuth flow — one less click.
  return withUserCookie(redirect("/auth/spotify"), userId);
}

// Lets a stuck user re-enter mistyped credentials (step 2 reopens).
async function resetSpotify(request, env) {
  if (request.method !== "POST") return new Response("Method not allowed", { status: 405 });
  const userId = getUserId(request);
  const user = await getUser(env, userId);
  if (user) {
    delete user.spotifyClientId;
    delete user.spotifyClientSecret;
    delete user.spotifyRefreshToken;
    await putUser(env, userId, user);
  }
  return redirect("/");
}

// ---------- settings ----------

async function saveSettings(request, env) {
  if (request.method !== "POST") return new Response("Method not allowed", { status: 405 });
  const userId = getUserId(request);
  const user = await getUser(env, userId);
  if (!user) return redirect("/");

  const form = await request.formData();
  user.paused = form.get("paused") === "on";

  const emoji = form.get("emoji");
  user.emoji = EMOJI_CHOICES.includes(emoji) ? emoji : DEFAULT_EMOJI;

  if (form.get("use_hours") === "on") {
    const start = parseInt(form.get("start"), 10);
    const end = parseInt(form.get("end"), 10);
    const tz = (form.get("tz") || "").trim();
    const valid =
      Number.isInteger(start) && Number.isInteger(end) &&
      start >= 0 && start < 24 && end >= 0 && end < 24 &&
      start !== end && hourIn(tz) !== null;
    user.workHours = valid ? { start, end, tz } : null;
  } else {
    user.workHours = null;
  }

  await putUser(env, userId, user);
  return redirect("/?saved=1");
}

// ---------- Spotify OAuth (against the user's own app) ----------

async function authSpotify(request, env) {
  const user = await getUser(env, getUserId(request));
  if (!user?.spotifyClientId) return redirect("/"); // step 2 not done yet
  const state = crypto.randomUUID().replaceAll("-", "");
  const params = new URLSearchParams({
    response_type: "code",
    client_id: user.spotifyClientId,
    scope: SPOTIFY_SCOPE,
    redirect_uri: `${baseUrl(request)}/callback/spotify`,
    show_dialog: "true",
    state,
  });
  return redirectWithState(`https://accounts.spotify.com/authorize?${params}`, state);
}

async function callbackSpotify(request, env) {
  const code = new URL(request.url).searchParams.get("code");
  if (!code) return new Response("Spotify authorization was denied.", { status: 400 });
  if (!stateMatches(request)) return new Response("OAuth state mismatch — please start again from the main page.", { status: 400 });

  const userId = getUserId(request);
  const user = await getUser(env, userId);
  if (!user?.spotifyClientId) return redirect("/");
  const { id, secret } = spotifyCreds(env, user);

  const res = await fetch("https://accounts.spotify.com/api/token", {
    method: "POST",
    headers: {
      "Content-Type": "application/x-www-form-urlencoded",
      Authorization: "Basic " + btoa(`${id}:${secret}`),
    },
    body: new URLSearchParams({
      grant_type: "authorization_code",
      code,
      redirect_uri: `${baseUrl(request)}/callback/spotify`,
    }),
  });
  const tokens = await res.json();
  if (!tokens.refresh_token) {
    return new Response(page(`<main><h1>Spotify connection failed</h1>
      <p>Spotify said: <code>${esc(JSON.stringify(tokens.error || tokens))}</code></p>
      <p>The two common causes: the <b>Client secret</b> was copied wrong (use the
      "Typo?" link on the main page to re-enter it), or the <b>Redirect URI</b> in
      your Spotify app doesn't match exactly. Fix whichever it is, then
      <a href="/">try again</a>.</p></main>`),
      { status: 500, headers: { "Content-Type": "text/html; charset=utf-8" } });
  }

  user.spotifyRefreshToken = tokens.refresh_token;
  await putUser(env, userId, user);
  await ensureInIndex(env, userId);
  return withUserCookie(redirect("/"), userId);
}

// ---------- Slack: bring-your-own-app token ----------

async function saveSlackToken(request, env) {
  if (request.method !== "POST") return new Response("Method not allowed", { status: 405 });
  const userId = getUserId(request) || crypto.randomUUID();
  const form = await request.formData();
  const token = (form.get("slack_token") || "").trim();

  const fail = (msg) =>
    new Response(page(`<main><h1>Slack token check failed</h1><p>${msg}</p>
      <p><a href="/">Go back and try again</a>.</p></main>`),
      { status: 400, headers: { "Content-Type": "text/html; charset=utf-8" } });

  if (!/^xoxp-[A-Za-z0-9-]+$/.test(token)) {
    return fail(`That doesn't look like a user token — it must start with <code>xoxp-</code>.
      Make sure you copied the <b>User</b> OAuth Token, not the Bot token (<code>xoxb-</code>).`);
  }

  // Verify the token is alive and has both scopes before saving.
  const auth = await slackApi(token, "auth.test", {});
  if (!auth.ok) return fail(`Slack rejected the token: <code>${esc(auth.error)}</code>.`);
  const prof = await slackApi(token, "users.profile.get", {});
  if (!prof.ok) {
    return fail(prof.error === "missing_scope"
      ? `The token is missing the <code>users.profile:read</code> scope. Add both scopes under
         <b>User Token Scopes</b>, click <b>Reinstall to Workspace</b>, and paste the new token.`
      : `Slack error: <code>${esc(prof.error)}</code>.`);
  }
  const probe = await slackApi(token, "users.profile.set", { profile: {} });
  if (!probe.ok && probe.error === "missing_scope") {
    return fail(`The token is missing the <code>users.profile:write</code> scope. Add both scopes under
      <b>User Token Scopes</b>, click <b>Reinstall to Workspace</b>, and paste the new token.`);
  }

  const user = (await getUser(env, userId)) || {};
  user.slackUserToken = token;
  user.slackName = prof.profile?.display_name || prof.profile?.real_name || auth.user || "";
  user.reauthNeeded = false;
  await putUser(env, userId, user);
  await ensureInIndex(env, userId);
  return withUserCookie(redirect("/"), userId);
}

// ---------- disconnect ----------

async function disconnect(request, env) {
  const userId = getUserId(request);
  if (userId) {
    const user = await getUser(env, userId);
    if (user?.slackUserToken) {
      // Best effort: clear their status before forgetting them — but only if
      // it's one of ours. A hand-set status survives disconnecting.
      try {
        const current = await getOwnStatus(user.slackUserToken).catch(() => null);
        const isOurs = current && EMOJI_CHOICES.includes(current.emoji);
        const unknown = current === null; // old token — keep previous behavior
        if (isOurs || unknown) await setSlackStatus(user.slackUserToken, null);
      } catch {}
    }
    await env.USERS.delete(`user:${userId}`);
    await removeFromIndex(env, userId);
  }
  return redirect("/");
}

// ---------- the cron sync ----------

// Free-plan budget: ~50 subrequests per invocation, ~5 per user. To keep the
// every-minute cadence at any roster size, the cron fans out: each batch of
// users is dispatched as its own invocation (self-request), and every
// invocation gets a fresh subrequest budget.
const USERS_PER_BATCH = 8;

async function syncAllUsers(env) {
  const keys = await getUserIndex(env);

  if (keys.length <= USERS_PER_BATCH) {
    await Promise.allSettled(keys.map((name) => syncOneUser(env, name)));
    return;
  }

  const batches = [];
  for (let i = 0; i < keys.length; i += USERS_PER_BATCH) {
    batches.push(keys.slice(i, i + USERS_PER_BATCH));
  }
  await Promise.allSettled(
    batches.map((batch) =>
      fetch(`${env.SELF_URL}/sync-batch`, {
        method: "POST",
        headers: { "X-Sync-Key": env.STATS_KEY, "Content-Type": "application/json" },
        body: JSON.stringify(batch),
      })
    )
  );
}

// Internal endpoint the cron fans out to — one invocation per batch.
async function syncBatch(request, env) {
  if (request.method !== "POST" || request.headers.get("X-Sync-Key") !== env.STATS_KEY) {
    return new Response("Not found", { status: 404 }); // indistinguishable from unknown routes
  }
  const names = await request.json();
  if (!Array.isArray(names)) return new Response("bad request", { status: 400 });
  await Promise.allSettled(names.slice(0, USERS_PER_BATCH).map((n) => syncOneUser(env, String(n))));
  return new Response("ok");
}

async function syncOneUser(env, name) {
  const user = await env.USERS.get(name, "json");
  if (!user?.spotifyRefreshToken || !user?.slackUserToken) return;

  let dirty = false;
  try {
    // Paused or outside work hours behaves exactly like "nothing playing".
    const active = !user.paused && withinWorkHours(user);
    const track = active
      ? await getCurrentTrack(env, user, () => (dirty = true))
      : null;

    // Manual-status protection: never touch a status a human set by hand.
    let current = null; // null = can't read (old token)
    try {
      current = await getOwnStatus(user.slackUserToken);
    } catch (err) {
      if (err.slackError !== "missing_scope") throw err;
    }
    const canRead = current !== null;
    if (user.reauthNeeded !== !canRead) {
      user.reauthNeeded = !canRead;
      dirty = true;
    }
    const isEmpty = canRead && !current.emoji && !current.text;
    const isOurs = canRead && EMOJI_CHOICES.includes(current.emoji);
    const isManual = canRead && !isEmpty && !isOurs;

    // Skip the Slack write when the status is already correct and its
    // expiration isn't close — the single biggest subrequest saver.
    const wantText = track ? track.slice(0, 100) : null;
    const secondsLeft = canRead ? current.expiration - Math.floor(Date.now() / 1000) : 0;
    const alreadyCorrect =
      canRead && isOurs && wantText !== null &&
      current.text === wantText && secondsLeft > 120;

    if (!isManual) {
      if (track && !alreadyCorrect) {
        await setSlackStatus(user.slackUserToken, track, user.emoji);
      } else if (!track && (canRead ? isOurs : user.lastTrack)) {
        await setSlackStatus(user.slackUserToken, null);
      }
    }

    // Remember the manual status so the page can explain why we're standing by.
    const manualStatus = isManual ? `${current.emoji} ${current.text}`.trim() : null;
    if ((user.manualStatus ?? null) !== manualStatus) {
      user.manualStatus = manualStatus;
      dirty = true;
    }

    if (track !== (user.lastTrack ?? null)) {
      user.lastTrack = track;
      if (track) user.lastPlayedAt = Date.now();
      dirty = true;
    }
    if (user.lastError) {
      user.lastError = null;
      dirty = true;
    }
  } catch (err) {
    console.log("sync failed for", name, "-", err.message);
    if (user.lastError !== err.message) {
      user.lastError = err.message;
      dirty = true;
    }
  }

  if (dirty) await env.USERS.put(name, JSON.stringify(user));
}

async function getCurrentTrack(env, user, onTokenRotated) {
  const { id, secret } = spotifyCreds(env, user);
  const tokenRes = await fetch("https://accounts.spotify.com/api/token", {
    method: "POST",
    headers: {
      "Content-Type": "application/x-www-form-urlencoded",
      Authorization: "Basic " + btoa(`${id}:${secret}`),
    },
    body: new URLSearchParams({
      grant_type: "refresh_token",
      refresh_token: user.spotifyRefreshToken,
    }),
  });
  const tokenData = await tokenRes.json();
  const access_token = tokenData.access_token;
  if (!access_token) {
    throw new Error("Spotify sign-in expired: " + (tokenData.error_description || tokenData.error || "unknown"));
  }

  // Spotify refresh tokens have a 180-day lifetime and may be rotated on use —
  // keep the newest one or the connection dies at the expiry mark.
  if (tokenData.refresh_token && tokenData.refresh_token !== user.spotifyRefreshToken) {
    user.spotifyRefreshToken = tokenData.refresh_token;
    onTokenRotated?.();
  }

  const res = await fetch("https://api.spotify.com/v1/me/player/currently-playing", {
    headers: { Authorization: `Bearer ${access_token}` },
  });
  if (res.status === 204) return null;
  if (!res.ok) throw new Error(`Spotify currently-playing returned ${res.status}`);

  const data = await res.json();
  if (!data.is_playing || !data.item || data.item.type !== "track") return null;

  const artists = data.item.artists.map((a) => a.name).join(", ");
  return `${data.item.name} — ${artists}`;
}

async function setSlackStatus(token, text, emoji) {
  const profile = text
    ? {
        status_emoji: emoji || DEFAULT_EMOJI,
        status_text: text.slice(0, 100),
        status_expiration: Math.floor(Date.now() / 1000) + STATUS_EXPIRATION_S,
      }
    : { status_emoji: "", status_text: "", status_expiration: 0 };
  const result = await slackApi(token, "users.profile.set", { profile });
  if (!result.ok) {
    throw new Error("Slack status update failed: " + result.error);
  }
}

async function getOwnStatus(token) {
  const res = await slackApi(token, "users.profile.get", {});
  if (!res.ok) {
    const err = new Error("Slack profile read failed: " + res.error);
    err.slackError = res.error;
    throw err;
  }
  return {
    text: res.profile?.status_text || "",
    emoji: res.profile?.status_emoji || "",
    expiration: res.profile?.status_expiration || 0,
  };
}

async function slackApi(token, method, body) {
  const res = await fetch(`https://slack.com/api/${method}`, {
    method: "POST",
    headers: {
      "Content-Type": "application/json; charset=utf-8",
      Authorization: `Bearer ${token}`,
    },
    body: JSON.stringify(body),
  });
  return res.json();
}

// ============================================================================
// Local runtime — plain Node (18+): storage file, web server, sync timer.
// Storage: ~/.tunestatus-local.json  ·  Sync: every 30s  ·  UI: http://127.0.0.1:8898
// ============================================================================

const nodeHttp = require("node:http");
const nodeFs = require("node:fs");
const nodeOs = require("node:os");
const nodePath = require("node:path");
const { execFileSync, execFile } = require("node:child_process");

const DATA_PATH = nodePath.join(nodeOs.homedir(), ".tunestatus-local.json");
const PORT = 8898;
const SYNC_INTERVAL_MS = 30_000;

function loadStore() {
  try { return JSON.parse(nodeFs.readFileSync(DATA_PATH, "utf8")); } catch { return {}; }
}
function persistStore(store) {
  nodeFs.writeFileSync(DATA_PATH, JSON.stringify(store, null, 2), { mode: 0o600 });
}

// Simple key-value storage over a local JSON file.
const store = loadStore();
const env = {
  USERS: {
    async get(key, type) {
      const v = store[key];
      if (v === undefined) return null;
      return type === "json" ? v : JSON.stringify(v);
    },
    async put(key, value) {
      store[key] = JSON.parse(value);
      persistStore(store);
    },
    async delete(key) {
      delete store[key];
      persistStore(store);
    },
    async list({ prefix } = {}) {
      return { keys: Object.keys(store).filter((k) => !prefix || k.startsWith(prefix)).map((name) => ({ name })) };
    },
  },
  SELF_URL: `http://127.0.0.1:${PORT}`,
  STATS_KEY: undefined, // /stats and /sync-batch stay disabled locally
};

async function handleFetch(request) {
  const url = new URL(request.url);
  try {
    switch (url.pathname) {
      case "/":                    return home(request, env);
      case "/spotify-credentials": return saveSpotifyCredentials(request, env);
      case "/reset-spotify":       return resetSpotify(request, env);
      case "/auth/spotify":        return authSpotify(request, env);
      case "/callback/spotify":    return callbackSpotify(request, env);
      case "/slack-token":         return saveSlackToken(request, env);
      case "/settings":            return saveSettings(request, env);
      case "/disconnect":          return disconnect(request, env);
      case "/panel":               return panel(request, env);
      case "/count":               return userCount(env);
      case "/autostart":           return autostartRoute(request);
      default:                     return new Response("Not found", { status: 404 });
    }
  } catch (err) {
    return new Response("Error: " + err.message, { status: 500 });
  }
}

const server = nodeHttp.createServer(async (req, res) => {
  const chunks = [];
  for await (const c of req) chunks.push(c);
  const body = chunks.length ? Buffer.concat(chunks) : undefined;
  const request = new Request(`http://127.0.0.1:${PORT}${req.url}`, {
    method: req.method,
    headers: req.headers,
    body: body && req.method !== "GET" && req.method !== "HEAD" ? body : undefined,
  });
  const response = await handleFetch(request);
  const headers = {};
  response.headers.forEach((v, k) => { if (k !== "set-cookie") headers[k] = v; });
  const cookies = response.headers.getSetCookie ? response.headers.getSetCookie() : [];
  res.writeHead(response.status, { ...headers, ...(cookies.length ? { "Set-Cookie": cookies } : {}) });
  const buf = Buffer.from(await response.arrayBuffer());
  res.end(buf);
});

// ---- autostart helpers (macOS launchd; prints instructions elsewhere) ----

const PLIST_PATH = nodePath.join(nodeOs.homedir(), "Library/LaunchAgents/com.tunestatus.local.plist");

function installAutostart() {
  if (process.platform !== "darwin") {
    console.log("Autostart install is automated on macOS only.");
    console.log("Windows: Task Scheduler → run `node " + __filename + "` at logon.");
    console.log("Linux: create a systemd user service running `node " + __filename + "`.");
    return;
  }
  const plist = `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"><dict>
  <key>Label</key><string>com.tunestatus.local</string>
  <key>ProgramArguments</key><array>
    <string>${process.execPath}</string>
    <string>${__filename}</string>
  </array>
  <key>RunAtLoad</key><true/>
  <key>KeepAlive</key><true/>
</dict></plist>
`;
  nodeFs.mkdirSync(nodePath.dirname(PLIST_PATH), { recursive: true });
  nodeFs.writeFileSync(PLIST_PATH, plist);
  try { execFileSync("launchctl", ["unload", PLIST_PATH], { stdio: "ignore" }); } catch {}
  execFileSync("launchctl", ["load", PLIST_PATH]);
  console.log("✓ TuneStatus will now start automatically at login.");
  console.log("  (undo anytime with: node tunestatus.js uninstall-autostart)");
}

function uninstallAutostart() {
  if (process.platform !== "darwin") { console.log("Nothing to do on this platform."); return; }
  try { execFileSync("launchctl", ["unload", PLIST_PATH], { stdio: "ignore" }); } catch {}
  try { nodeFs.unlinkSync(PLIST_PATH); } catch {}
  console.log("✓ Autostart removed.");
}

function isAutostartInstalled() {
  try { return nodeFs.existsSync(PLIST_PATH); } catch { return false; }
}

async function autostartRoute(request) {
  if (request.method !== "POST") return new Response("Method not allowed", { status: 405 });
  const form = await request.formData();
  try {
    if (form.get("action") === "uninstall") uninstallAutostart();
    else installAutostart();
  } catch (e) {
    return new Response("Could not change autostart: " + e.message, { status: 500 });
  }
  return redirect("/#sync");
}

// ---- main ----

const cmd = process.argv[2];
if (cmd === "install-autostart") { installAutostart(); process.exit(0); }
if (cmd === "uninstall-autostart") { uninstallAutostart(); process.exit(0); }

server.listen(PORT, "127.0.0.1", () => {
  const url = `http://127.0.0.1:${PORT}`;
  console.log(`♪ TuneStatus (local) — ${url}`);
  console.log(`  Config: ${DATA_PATH}`);
  console.log(`  Tip: \`node tunestatus.js install-autostart\` starts it at login (macOS).`);
  // Open the browser on first run (no config yet).
  if (!Object.keys(store).some((k) => k.startsWith("user:"))) {
    try {
      if (process.platform === "win32") execFile("cmd", ["/c", "start", "", url]);
      else execFile(process.platform === "darwin" ? "open" : "xdg-open", [url]);
    } catch {}
  }
});

setInterval(() => syncAllUsers(env).catch((e) => console.log("sync error:", e.message)), SYNC_INTERVAL_MS);
syncAllUsers(env).catch(() => {});