"use strict"; const TOKEN_KEY = "thebutton-token"; const $ = (id) => document.getElementById(id); let game = null; // latest /api/state payload let clockOffset = 0; // serverTime - clientTime let token = localStorage.getItem(TOKEN_KEY); let statsLoaded = false; let subscribed = false; let currentShareText = ""; async function api(method, path, body) { const headers = { "Content-Type": "application/json" }; if (token) headers.Authorization = `Bearer ${token}`; const res = await fetch(path, { method, headers, body: body ? JSON.stringify(body) : undefined }); const data = await res.json().catch(() => ({})); if (!res.ok) throw Object.assign(new Error(data.error || res.statusText), { status: res.status }); return data; } function serverNow() { return Date.now() + clockOffset; } function formatMs(ms) { ms = Math.max(0, ms); const h = Math.floor(ms / 3600000); const m = Math.floor((ms % 3600000) / 60000); const s = Math.floor((ms % 60000) / 1000); const cs = Math.floor((ms % 1000) / 10); const pad = (n) => String(n).padStart(2, "0"); return `${pad(h)}:${pad(m)}:${pad(s)}.${pad(cs)}`; } function formatPrecise(ms) { return `${formatMs(ms).slice(0, -3)}.${String(Math.max(0, ms) % 1000).padStart(3, "0")}`; } function updateShareLinks() { if (!currentShareText) return; const gameUrl = new URL(location.pathname, location.origin).href; const facebookUrl = new URL("https://www.facebook.com/sharer/sharer.php"); facebookUrl.searchParams.set("u", gameUrl); facebookUrl.searchParams.set("quote", currentShareText); $("share-facebook").href = facebookUrl.href; const xUrl = new URL("https://twitter.com/intent/tweet"); xUrl.searchParams.set("text", currentShareText); xUrl.searchParams.set("url", gameUrl); $("share-x").href = xUrl.href; const blueskyUrl = new URL("https://bsky.app/intent/compose"); blueskyUrl.searchParams.set("text", `${currentShareText} ${gameUrl}`); $("share-bluesky").href = blueskyUrl.href; const instanceInput = $("mastodon-instance"); const mastodonLink = $("share-mastodon"); const shareError = $("share-error"); try { const instance = new URL(instanceInput.value.trim()); if (instance.protocol !== "https:" || !instance.hostname || instance.username || instance.password) { throw new Error("Enter an HTTPS Mastodon instance URL."); } const mastodonUrl = new URL("/share", instance.origin); mastodonUrl.searchParams.set("text", `${currentShareText} ${gameUrl}`); mastodonLink.href = mastodonUrl.href; mastodonLink.removeAttribute("aria-disabled"); mastodonLink.tabIndex = 0; shareError.textContent = ""; } catch { mastodonLink.removeAttribute("href"); mastodonLink.setAttribute("aria-disabled", "true"); mastodonLink.tabIndex = -1; shareError.textContent = "Enter an HTTPS Mastodon instance URL to enable sharing."; } } function zoneFor(remaining) { return game.zones.find((z) => remaining > z.remaining_to_ms) ?? game.zones[game.zones.length - 1]; } function formatDuration(ms) { const totalMin = Math.round(ms / 60000); const h = Math.floor(totalMin / 60); const m = totalMin % 60; if (h && m) return `${h}h ${m}m`; if (h) return `${h}h`; if (m) return `${m}m`; return `${Math.round(ms / 1000)}s`; } function setZoneClass(el, zoneName) { el.className = el.className.replace(/\bzone-\w+/g, "").trim(); el.classList.add(`zone-${zoneName}`); } function el(tag, text, className) { const e = document.createElement(tag); if (text !== undefined) e.textContent = text; if (className) e.className = className; return e; } function fillTable(table, headers, rows) { table.replaceChildren(); const head = table.createTHead().insertRow(); headers.forEach((h) => head.appendChild(el("th", h))); const tbody = table.createTBody(); rows.forEach((cells) => { const tr = tbody.insertRow(); cells.forEach((c) => { const td = tr.insertCell(); if (c instanceof Node) td.appendChild(c); else td.textContent = c; }); }); } function zoneSwatch(name) { return el("span", name, `swatch zone-${name}`); } function zoneRows(counts) { return game.zones.map((z) => [zoneSwatch(z.name), z.points, counts[z.name] ?? 0]); } function buildStaticUi() { const bar = $("zone-bar"); game.zones.forEach((z) => { const seg = el("span", undefined, `zone-${z.name}`); seg.style.flexGrow = z.remaining_from_ms - z.remaining_to_ms; bar.insertBefore(seg, $("zone-marker")); }); $("rules-zones").replaceChildren(...game.zones.map((z) => { const s = zoneSwatch(z.name); s.textContent = `${z.name} (${z.points} pt${z.points === 1 ? "" : "s"}, ${formatDuration(z.remaining_from_ms - z.remaining_to_ms)})`; return s; })); } async function refreshState() { const t0 = Date.now(); const s = await api("GET", "/api/state"); const first = !game; clockOffset = s.now - (t0 + Date.now()) / 2; game = s; if (first) buildStaticUi(); if (game.ended && !statsLoaded) loadStats(); updateButton(); } function updateButton() { $("the-button").disabled = !token || !game || game.ended; $("register-section").hidden = !!token; $("mailing-list").hidden = !token || subscribed; $("my-subscribed").hidden = !token || !subscribed; } function tick() { if (game) { const remaining = game.ended ? 0 : game.deadline - serverNow(); const zone = zoneFor(remaining); const cd = $("countdown"); cd.textContent = formatMs(remaining); setZoneClass(cd, zone.name); setZoneClass($("the-button"), zone.name); $("zone-label").textContent = game.ended ? "game over" : `${zone.name} zone \u00b7 ${zone.points} pt${zone.points === 1 ? "" : "s"}`; const pct = Math.min(100, Math.max(0, ((game.duration_ms - remaining) / game.duration_ms) * 100)); $("zone-marker").style.left = `${pct}%`; if (!game.ended && remaining <= 0) refreshState().catch(() => {}); } requestAnimationFrame(tick); } async function loadMe() { if (!token) return; try { const me = await api("GET", "/api/me"); $("me-section").hidden = false; $("my-name").textContent = me.name; $("my-points").textContent = me.total_points.toLocaleString(); $("my-presses").textContent = me.press_count; subscribed = me.subscribed; updateButton(); fillTable($("my-zones"), ["Zone", "Points each", "Your presses"], zoneRows(me.zone_counts)); fillTable( $("my-history"), ["When", "Remaining", "Zone", "Points"], me.presses.slice().reverse().map((p) => [ new Date(p.at).toLocaleString(), formatPrecise(p.remaining_ms), zoneSwatch(p.zone), p.points, ]), ); } catch (e) { if (e.status === 401) { token = null; localStorage.removeItem(TOKEN_KEY); updateButton(); } } } async function loadStats() { try { const s = await api("GET", "/api/stats"); statsLoaded = true; $("stats-section").hidden = false; const summary = $("stats-summary"); summary.replaceChildren( el("p", `Lasted ${formatMs(s.ended_at - s.started_at)} \u00b7 ${s.total_players} players \u00b7 ${s.total_presses} presses`), ); if (s.last_press) { summary.appendChild(el("p", `Last press: ${s.last_press.name} with ${formatPrecise(s.last_press.remaining_ms)} remaining`)); } if (s.lowest_press) { summary.appendChild(el("p", `Closest call: ${s.lowest_press.name} with ${formatPrecise(s.lowest_press.remaining_ms)} remaining`)); } fillTable($("stats-zones"), ["Zone", "Points each", "Presses"], zoneRows(s.zone_counts)); fillTable( $("stats-leaderboard"), ["#", "Player", "Points", "Presses", ...game.zones.map((z) => z.name), "Best remaining"], s.leaderboard.map((p, i) => [ i + 1, p.name, p.total_points.toLocaleString(), p.press_count, ...game.zones.map((z) => p.zone_counts[z.name]), p.best_remaining_ms == null ? "-" : formatPrecise(p.best_remaining_ms), ]), ); fillTable( $("stats-log"), ["When", "Player", "Remaining", "Zone", "Points"], s.presses.map((p) => [ new Date(p.at).toLocaleString(), p.name, formatPrecise(p.remaining_ms), zoneSwatch(p.zone), p.points, ]), ); } catch { // Not ended yet; ignore. } } $("the-button").addEventListener("click", async () => { const btn = $("the-button"); btn.disabled = true; $("share-controls").hidden = true; currentShareText = ""; try { const r = await api("POST", "/api/press"); game.deadline = r.deadline; currentShareText = `I pushed the button at ${formatPrecise(r.press.remaining_ms)}.`; $("press-result").textContent = `You pressed in the ${r.press.zone} zone with ${formatPrecise(r.press.remaining_ms)} remaining: +${r.press.points} points`; updateShareLinks(); $("share-controls").hidden = false; await loadMe(); } catch (e) { $("press-result").textContent = e.message; await refreshState().catch(() => {}); } finally { updateButton(); } }); $("mastodon-instance").addEventListener("input", updateShareLinks); $("register-form").addEventListener("submit", async (ev) => { ev.preventDefault(); $("register-error").textContent = ""; const action = ev.submitter?.value === "register" ? "register" : "login"; try { const r = await api("POST", `/api/${action}`, { name: $("name-input").value, password: $("password-input").value, }); token = r.token; localStorage.setItem(TOKEN_KEY, token); $("password-input").value = ""; updateButton(); await loadMe(); } catch (e) { $("register-error").textContent = e.message; } }); $("logout-button").addEventListener("click", async () => { await api("POST", "/api/logout").catch(() => {}); token = null; subscribed = false; localStorage.removeItem(TOKEN_KEY); $("me-section").hidden = true; $("press-result").textContent = ""; $("subscribe-result").textContent = ""; updateButton(); }); $("subscribe-form").addEventListener("submit", async (ev) => { ev.preventDefault(); const result = $("subscribe-result"); result.className = ""; try { await api("POST", "/api/subscribe", { first_name: $("sub-first").value, last_name: $("sub-last").value, email: $("sub-email").value, phone: $("sub-phone").value, }); ev.target.reset(); result.textContent = ""; subscribed = true; updateButton(); } catch (e) { result.className = "error"; result.textContent = e.message; } }); (async function init() { await refreshState(); await loadMe(); requestAnimationFrame(tick); setInterval(() => refreshState().catch(() => {}), 5000); })();