"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 = ""; let selectedSection = null; let chatSignature = null; let chatLoading = false; let isAdmin = false; let adminSnapshot = null; let adminBusy = false; const PRESENCE_KEY = "thebutton-share-presence"; let sharePresence = localStorage.getItem(PRESENCE_KEY) !== "false"; let presenceTab = sessionStorage.getItem("thebutton-presence-tab"); if (!presenceTab) { presenceTab = Array.from(crypto.getRandomValues(new Uint8Array(16)), (n) => n.toString(16).padStart(2, "0")).join(""); sessionStorage.setItem("thebutton-presence-tab", presenceTab); } let presenceQueue = Promise.resolve(); let lastPresenceUpdate = null; const ALERTS_KEY = "thebutton-zone-alerts"; const ALERTS_SENT_KEY = "thebutton-zone-alerts-sent"; const ALERT_ZONES = ["blue", "yellow", "red", "black", "white"]; let alertSettings = readAlertSettings(); let alertObservation = null; let alertAudio = null; function readAlertSettings() { try { const value = JSON.parse(localStorage.getItem(ALERTS_KEY)); return { enabled: value?.enabled === true, zone: ALERT_ZONES.includes(value?.zone) ? value.zone : "blue", sound: value?.sound === true, notification: value?.notification === true }; } catch { return { enabled: false, zone: "blue", sound: false, notification: false }; } } function renderAlertSettings() { $("alerts-enabled").checked = alertSettings.enabled; $("alerts-zone").value = alertSettings.zone; $("alerts-sound").checked = alertSettings.sound; $("alerts-notification").checked = alertSettings.notification; $("alerts-status").textContent = alertSettings.enabled ? `Watching for the ${alertSettings.zone} zone. Keep this page open.` : "Zone alerts are off."; } async function prepareAlertAudio() { const Audio = window.AudioContext || window.webkitAudioContext; if (!Audio) throw new Error("Sound is not supported by this browser."); alertAudio = alertAudio || new Audio(); await alertAudio.resume(); if (alertAudio.state !== "running") throw new Error("Click Test chime to allow sound in this browser."); } function playAlertChime() { if (!alertAudio || alertAudio.state !== "running") throw new Error("Click Test chime in Alerts to enable sound for this visit."); const start = alertAudio.currentTime; [660, 880].forEach((frequency, index) => { const oscillator = alertAudio.createOscillator(); const gain = alertAudio.createGain(); const at = start + index * 0.18; oscillator.frequency.value = frequency; gain.gain.setValueAtTime(0, at); gain.gain.linearRampToValueAtTime(0.08, at + 0.02); gain.gain.exponentialRampToValueAtTime(0.001, at + 0.28); oscillator.connect(gain); gain.connect(alertAudio.destination); oscillator.start(at); oscillator.stop(at + 0.3); oscillator.onended = () => { oscillator.disconnect(); gain.disconnect(); }; }); } function observeZoneAlerts(snapshot) { if (alertObservation && snapshot.now < alertObservation.now) return; const remaining = snapshot.deadline - snapshot.now; const zone = snapshot.zones.find((entry) => remaining > entry.remaining_to_ms)?.name; const previous = alertObservation; alertObservation = { now: snapshot.now, deadline: snapshot.deadline, zone, ended: snapshot.ended }; if (!alertSettings.enabled || !previous || snapshot.ended || previous.ended || remaining <= 0 || previous.deadline !== snapshot.deadline || previous.zone === zone || zone !== alertSettings.zone) return; const currentIndex = snapshot.zones.findIndex((entry) => entry.name === zone); const previousIndex = snapshot.zones.findIndex((entry) => entry.name === previous.zone); if (previousIndex < 0 || currentIndex <= previousIndex) return; try { const sent = JSON.parse(localStorage.getItem(ALERTS_SENT_KEY) || "null"); const zones = sent?.deadline === snapshot.deadline && Array.isArray(sent.zones) ? sent.zones : []; if (zones.includes(zone)) return; localStorage.setItem(ALERTS_SENT_KEY, JSON.stringify({ deadline: snapshot.deadline, zones: [...zones, zone] })); } catch { // The observation still prevents repeated alerts within this tab if storage is unavailable. } const message = `The countdown entered the ${zone} zone. ${formatPrecise(remaining)} remaining.`; $("alerts-status").textContent = message; $("alerts-error").textContent = ""; if (alertSettings.sound) { try { playAlertChime(); } catch (error) { $("alerts-error").textContent = error.message; } } if (alertSettings.notification) { try { if (!("Notification" in window) || Notification.permission !== "granted") { throw new Error("Browser notifications aren't permitted. Save your notification settings in Alerts to try again."); } const notification = new Notification("The Button: your zone is here", { body: message, tag: "thebutton-zone", silent: true }); notification.onclick = () => { window.focus(); notification.close(); }; } catch (error) { $("alerts-error").textContent = error.message; } } } $("alerts-form").addEventListener("submit", async (event) => { event.preventDefault(); $("alerts-error").textContent = ""; const settings = { enabled: $("alerts-enabled").checked, zone: $("alerts-zone").value, sound: $("alerts-sound").checked, notification: $("alerts-notification").checked }; if (settings.enabled && !settings.sound && !settings.notification) { $("alerts-error").textContent = "Choose a chime, a browser notification, or both."; return; } let permissionRequest = null; if (settings.enabled && settings.notification) { if (!("Notification" in window) || !window.isSecureContext) { $("alerts-error").textContent = "Browser notifications are unavailable here. Choose the chime instead."; return; } // Start permission requests directly in the submit gesture, before awaiting audio. try { permissionRequest = Notification.requestPermission().catch(() => "denied"); } catch { permissionRequest = Promise.resolve("denied"); } } if (settings.enabled && settings.sound) { try { await prepareAlertAudio(); } catch (error) { $("alerts-error").textContent = error.message; return; } } if (permissionRequest) { const permission = await permissionRequest; if (permission !== "granted") { $("alerts-error").textContent = "Notifications were not allowed. Enable them in your browser settings, or choose the chime instead."; return; } } alertSettings = settings; alertObservation = null; // baseline the next server response, never alert immediately on enabling try { localStorage.setItem(ALERTS_KEY, JSON.stringify(settings)); } catch { $("alerts-error").textContent = "Settings work for this visit but could not be saved in this browser."; } renderAlertSettings(); }); $("alerts-test").addEventListener("click", async () => { try { await prepareAlertAudio(); playAlertChime(); $("alerts-error").textContent = ""; } catch (error) { $("alerts-error").textContent = error.message; } }); renderAlertSettings(); function renderPresence(data) { const count = data.online_count; const countText = `${count.toLocaleString()} ${count === 1 ? "player" : "players"} watching`; if ($("presence-count").textContent !== countText) $("presence-count").textContent = countText; $("chat-presence-summary").textContent = data.chat_players.length ? `${data.chat_players.length} ${data.chat_players.length === 1 ? "player" : "players"} in the lounge` : "No players sharing chat presence yet"; const list = $("chat-presence-names"); // Player names are text, never HTML. if (list.dataset.names !== JSON.stringify(data.chat_players)) { list.replaceChildren(...data.chat_players.map((name) => el("li", name))); list.dataset.names = JSON.stringify(data.chat_players); } } function syncPresence() { presenceQueue = presenceQueue.then(async () => { const active = !!token && sharePresence && !document.hidden; const inChat = active && selectedSection === "chat-section"; const changed = !lastPresenceUpdate || lastPresenceUpdate.token !== token || lastPresenceUpdate.active !== active || lastPresenceUpdate.inChat !== inChat; const due = active && (!lastPresenceUpdate || performance.now() - lastPresenceUpdate.at >= 15000); if (token && (changed || due) && (active || lastPresenceUpdate?.active)) { const data = await api("POST", "/api/presence", { tab_id: presenceTab, active, in_chat: inChat }); lastPresenceUpdate = { token, active, inChat, at: performance.now() }; $("presence-error").textContent = ""; renderPresence(data); } else if (!document.hidden) { renderPresence(await api("GET", "/api/presence")); } }).catch((error) => { $("presence-count").textContent = "Online status temporarily unavailable"; if (token && sharePresence) $("presence-error").textContent = "Couldn't update online status. Retrying automatically."; if (error.status === 401) { token = null; localStorage.removeItem(TOKEN_KEY); lastPresenceUpdate = null; updateButton(); } }); return presenceQueue; } $("presence-opt-in").addEventListener("change", () => { sharePresence = $("presence-opt-in").checked; localStorage.setItem(PRESENCE_KEY, String(sharePresence)); syncPresence(); }); document.addEventListener("visibilitychange", syncPresence); window.addEventListener("storage", (event) => { if (event.key === ALERTS_KEY) { alertSettings = readAlertSettings(); alertObservation = null; renderAlertSettings(); } if (event.key === PRESENCE_KEY || event.key === TOKEN_KEY) { sharePresence = localStorage.getItem(PRESENCE_KEY) !== "false"; if (event.key === TOKEN_KEY) isAdmin = false; token = localStorage.getItem(TOKEN_KEY); updateButton(); syncPresence(); if (event.key === TOKEN_KEY && token) loadMe(); } }); function renderSections() { const available = { "account-section": true, "leaderboard-section": true, "seasons-section": true, "chat-section": true, "alerts-section": true, "admin-section": !!token && isAdmin, "stats-section": statsLoaded, rules: true, "share-controls": !!currentShareText, more: true, donate: true, }; if (!available[selectedSection]) selectedSection = null; document.querySelectorAll("[data-section]").forEach((button) => { const id = button.dataset.section; button.hidden = !available[id]; button.setAttribute("aria-expanded", String(selectedSection === id)); if (id === "account-section") button.textContent = token ? "Your score" : "Log in / Join"; }); document.querySelectorAll("[data-panel]").forEach((panel) => { panel.hidden = panel.id !== selectedSection; }); $("mailing-list").hidden = !token || subscribed; $("chat-form").hidden = !token; $("chat-login").hidden = !!token; $("section-hint").hidden = selectedSection !== null; $("presence-opt-in").checked = sharePresence; if (!token || !isAdmin) clearAdminData(); } $("section-nav").addEventListener("click", (event) => { const button = event.target.closest("[data-section]"); if (!button) return; selectedSection = selectedSection === button.dataset.section ? null : button.dataset.section; renderSections(); if (selectedSection === "account-section") loadMe(); if (selectedSection === "leaderboard-section") loadLeaderboard(); if (selectedSection === "chat-section") loadChat(); if (selectedSection === "seasons-section") loadSeasons(); if (selectedSection === "admin-section") loadAdmin(); syncPresence(); }); 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`; } let faviconZone = "green"; function updateFavicon(zoneName) { if (zoneName === faviconZone) return; $("zone-favicon").href = `/favicon-${zoneName}.svg`; faviconZone = zoneName; } 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; const changedSeason = game && game.season?.id !== s.season?.id; if (changedSeason) { statsLoaded = false; currentShareText = ""; alertObservation = null; $("press-result").textContent = ""; $("me-section").hidden = true; } clockOffset = s.now - (t0 + Date.now()) / 2; game = s; if (s.season) { $("season-subtitle").textContent = s.season.name; $("season-label").textContent = `${s.ended ? "Season finished. " : ""}Next season: ${new Date(s.season.ends_at).toISOString().slice(0, 10)} UTC.`; } observeZoneAlerts(s); $("history-global-summary").textContent = `There have been ${s.total_presses.toLocaleString()} total presses and there are ${s.total_players.toLocaleString()} total players.`; if (first) buildStaticUi(); if (game.ended && !statsLoaded) loadStats(); updateButton(); await syncPresence(); await loadLeaderboard(); if (selectedSection === "chat-section") await loadChat(); if (selectedSection === "account-section") await loadMe(); if (selectedSection === "seasons-section") await loadSeasons(); } function updateButton() { $("the-button").disabled = !token || !game || game.ended; $("register-section").hidden = !!token; $("me-section").hidden = !token; $("my-subscribed").hidden = !token || !subscribed; renderSections(); } 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); updateFavicon(zone.name); $("zone-label").textContent = game.ended ? "season finished" : `${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); } function renderAchievements(achievements) { const list = $("my-achievements"); const signature = JSON.stringify(achievements); if (list.dataset.achievements === signature) return; list.dataset.achievements = signature; $("my-achievement-count").textContent = `${achievements.filter((a) => a.unlocked).length} / ${achievements.length} unlocked`; list.replaceChildren(...achievements.map((achievement) => { const item = el("li", undefined, `achievement-card${achievement.unlocked ? " is-unlocked" : ""}`); const icon = el("span", achievement.unlocked ? "\u2713" : "\u25c7", "achievement-icon"); icon.setAttribute("aria-hidden", "true"); const content = el("div", undefined, "achievement-content"); const heading = el("div", undefined, "achievement-title"); heading.append(el("h4", achievement.name), el("span", achievement.unlocked ? "Unlocked" : "Locked", "achievement-state")); content.append(heading, el("p", achievement.description)); let progressText = "One well-timed press unlocks this badge."; if (achievement.id === "full_spectrum") { progressText = `${achievement.progress} / ${achievement.target} zones visited`; } else if (achievement.id === "keeping_us_alive") { progressText = `Longest quiet stretch before your press: ${formatPrecise(achievement.progress)} / ${formatPrecise(achievement.target)} needed`; } else if (achievement.scope === "lifetime") { progressText = achievement.id === "season_veteran" ? `${achievement.progress} / ${achievement.target} completed seasons played` : "Awarded when a season finishes. Kept across seasons."; } if (achievement.unlocked) { progressText = `Unlocked ${new Date(achievement.unlocked_at).toLocaleString()}`; } content.append(el("small", progressText)); if (["full_spectrum", "keeping_us_alive", "season_veteran"].includes(achievement.id) && !achievement.unlocked) { const progress = el("progress"); progress.max = achievement.target; progress.value = achievement.progress; progress.setAttribute("aria-label", `${achievement.name} progress`); content.append(progress); } item.append(icon, content); return item; })); } async function loadMe() { if (!token) return; const requestedToken = token; try { const me = await api("GET", "/api/me"); if (token !== requestedToken) return; isAdmin = me.is_admin === true; $("my-name").textContent = me.name; $("my-points").textContent = me.total_points.toLocaleString(); $("my-presses").textContent = me.press_count; renderAchievements(me.achievements); const closest = me.personal_bests.closest_call; const best = me.personal_bests.highest_scoring_press; $("my-closest-call").textContent = closest ? formatPrecise(closest.remaining_ms) : "--"; $("my-closest-detail").textContent = closest ? `${closest.zone} zone / ${new Date(closest.at).toLocaleDateString()}` : "Your first press starts the story."; $("my-best-press").textContent = best ? `${best.points.toLocaleString()} pts` : "--"; $("my-best-detail").textContent = best ? `${best.zone} zone / ${formatPrecise(best.remaining_ms)} left` : "No presses yet. Wait for your moment."; const ranking = me.ranking; $("my-position").textContent = `#${ranking.position} of ${ranking.total_players}`; const rival = ranking.rival; if (rival) { $("my-rival-title").textContent = rival.points_to_tie === 0 ? `Tied with ${rival.name}` : `Next up: ${rival.name}`; $("my-rival-description").textContent = rival.points_to_tie === 0 ? `You're both on ${me.total_points.toLocaleString()} points. Gain points to move ahead.` : `${rival.points_to_tie.toLocaleString()} more points to match their ${rival.total_points.toLocaleString()}; ${rival.points_to_pass.toLocaleString()} to beat it.`; } else { $("my-rival-title").textContent = ranking.total_players === 1 ? "The first to take a seat." : ranking.tied_for_lead ? "Sharing the lead." : "You're setting the pace."; $("my-rival-description").textContent = ranking.total_players === 1 ? "You're the only player so far. Your next challenge is your own best." : ranking.tied_for_lead ? "You're first alphabetically among equal top scores. Gain points to take a clear lead." : "You're at the top of the leaderboard. Make your next 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; isAdmin = false; localStorage.removeItem(TOKEN_KEY); updateButton(); } } } function clearAdminData() { adminSnapshot = null; for (const id of ["admin-overview", "admin-players", "admin-messages", "admin-audit"]) $(id).replaceChildren(); $("admin-status").textContent = ""; $("admin-error").textContent = ""; $("admin-player-filter").value = ""; } function adminButton(text, action, target, value, extra = {}) { const button = el("button", text, action === "delete_player" || action === "delete_message" ? "danger-button" : ""); button.type = "button"; button.disabled = adminBusy; button.addEventListener("click", async () => { const confirmation = action === "delete_player" ? `Delete account ${extra.name}? Login credentials will be removed, messages anonymized, and historical scores kept as Deleted player. This cannot be undone.` : action === "delete_message" ? "Permanently remove this message from chat?" : `${text} for ${extra.name}? Existing sessions will be revoked and the player must log in again.`; if (adminBusy || !window.confirm(confirmation)) return; adminBusy = true; $("admin-error").textContent = ""; document.querySelectorAll("#admin-section button").forEach((b) => { b.disabled = true; }); try { await api("POST", "/api/admin/action", { action, target_id: String(target), ...(value === undefined ? {} : { value }), ...(extra.at === undefined ? {} : { at: extra.at }) }); $("admin-status").textContent = "Action completed."; chatSignature = null; await loadAdmin(); await loadMe(); } catch (error) { $("admin-error").textContent = error.message; if (error.status === 401 || error.status === 403) { isAdmin = false; renderSections(); } } finally { adminBusy = false; document.querySelectorAll("#admin-section button").forEach((b) => { b.disabled = false; }); renderAdminPlayers(); } }); return button; } function renderAdminPlayers() { if (!adminSnapshot) return; const query = $("admin-player-filter").value.toLowerCase(); const players = adminSnapshot.players.filter((p) => p.name.toLowerCase().includes(query)); $("admin-players").replaceChildren(...players.map((player) => { const item = el("li", undefined, "admin-card"); item.append(el("strong", player.name), el("p", `${player.is_admin ? "Admin" : "Player"} / ${player.is_banned ? "Banned" : "Active"} / ${player.total_points} points / ${player.press_count} presses / ${player.sessions} sessions`, "fine-print")); const actions = el("div", undefined, "form-actions"); if (player.id === adminSnapshot.actor_id) { actions.append(el("span", "Your account (protected)", "site-badge")); } else { actions.append(adminButton(player.is_admin ? "Remove admin" : "Make admin", "set_admin", player.id, !player.is_admin, { name: player.name }), adminButton(player.is_banned ? "Unban" : "Ban", "set_banned", player.id, !player.is_banned, { name: player.name }), adminButton("Delete account", "delete_player", player.id, undefined, { name: player.name })); } item.append(actions); return item; })); } async function loadAdmin() { if (!token || !isAdmin) return; const requestedToken = token; try { const data = await api("GET", "/api/admin"); if (token !== requestedToken || !isAdmin) return; adminSnapshot = data; $("admin-overview").replaceChildren(...[ [data.season.name, data.game.ended ? "Season finished" : "Season active"], [data.players.length.toLocaleString(), "Player accounts"], [data.game.total_presses.toLocaleString(), "Season presses"], [data.game.stored_chat_messages.toLocaleString(), "Stored chat rows"], ].map(([value, label]) => { const card = el("div", undefined, "metric-card"); card.append(el("span", label), el("strong", value)); return card; })); renderAdminPlayers(); $("admin-messages").replaceChildren(...data.messages.slice().reverse().map((message) => { const item = el("li", undefined, "admin-card"); item.append(el("strong", message.name), el("small", `${new Date(message.at).toLocaleString()} / ${message.kind}`, "fine-print"), el("p", message.message), adminButton("Remove message", "delete_message", message.id, undefined, { at: message.at })); return item; })); fillTable($("admin-audit"), ["When", "Actor", "Action", "Target"], data.audit.map((entry) => [ new Date(entry.at).toLocaleString(), entry.actor_id, entry.action, entry.target_id, ])); $("admin-status").textContent = `Last refreshed ${new Date().toLocaleTimeString()}. Refresh manually to keep controls stable.`; } catch (error) { $("admin-error").textContent = error.message; if (error.status === 401 || error.status === 403) { isAdmin = false; renderSections(); } } } $("admin-refresh").addEventListener("click", () => { if (!adminBusy) loadAdmin(); }); $("admin-player-filter").addEventListener("input", renderAdminPlayers); function seasonDuration(ms) { const days = Math.floor(ms / 86400000); return `${days ? `${days}d ` : ""}${formatMs(ms % 86400000).slice(0, -3)}`; } async function loadSeasons() { try { const result = await api("GET", "/api/seasons"); const current = result.current; $("season-current").replaceChildren( el("p", "CURRENT SEASON", "eyebrow"), el("h3", current.name), el("p", `${current.ended ? "Finished - next countdown" : "Next season"}: ${new Date(current.ends_at).toISOString().slice(0, 10)} at 00:00 UTC`, "fine-print"), ); $("seasons-status").textContent = result.seasons.length ? "" : "No finished seasons yet. This chapter is still being written."; $("season-winners").hidden = result.seasons.length === 0; fillTable($("season-winners"), ["Season", "Winner(s)", "Points each", "Lasted"], result.seasons.map((season) => [ season.name, season.winners.map((p) => p.name).join(", ") || "No winner (no presses)", season.winners.length ? season.winners[0].total_points.toLocaleString() : "--", seasonDuration(season.duration_ms), ])); const container = $("season-standings"); // Do not replace focused/open archive details on every poll. const signature = JSON.stringify(result.seasons); if (container.dataset.signature !== signature) { container.dataset.signature = signature; container.replaceChildren(...result.seasons.map((season) => { const details = el("details"); details.append(el("summary", `${season.name} - final standings`)); details.append(el("p", `Played from ${new Date(season.played_from).toLocaleString()} until ${new Date(season.finished_at).toLocaleString()}. Ended by ${season.reason === "boundary" ? "the next season" : "the countdown"}.`, "fine-print")); const wrapper = el("div", undefined, "table-scroll"); const table = el("table"); fillTable(table, ["#", "Player", "Points", "Presses"], season.leaderboard.map((player, index) => [ index + 1, player.name, player.total_points.toLocaleString(), player.press_count.toLocaleString(), ])); wrapper.append(table); details.append(wrapper); return details; })); } } catch { $("seasons-status").textContent = "Seasons could not be refreshed. Retrying automatically..."; } } async function loadLeaderboard() { try { const { leaderboard } = await api("GET", "/api/leaderboard"); fillTable( $("leaderboard"), ["Rank", "Player", "Points", "Presses"], leaderboard.map((player, index) => [ index + 1, player.name, player.total_points.toLocaleString(), player.press_count.toLocaleString(), ]), ); $("leaderboard").hidden = leaderboard.length === 0; $("leaderboard-status").textContent = leaderboard.length ? "" : "No players yet. Be the first to join!"; } catch { $("leaderboard-status").textContent = "Scores could not be refreshed. Retrying automatically..."; } } async function loadChat() { if (chatLoading) return; chatLoading = true; try { const { messages } = await api("GET", "/api/chat"); const signature = JSON.stringify(messages); const list = $("chat-messages"); if (signature !== chatSignature) { const atBottom = list.scrollHeight - list.scrollTop - list.clientHeight < 40; const firstLoad = chatSignature === null; const previousScroll = list.scrollTop; list.replaceChildren(...messages.map((message) => { const item = el("li"); if (message.kind === "press") item.classList.add("chat-press"); if (message.kind === "milestone") item.classList.add("chat-milestone"); const avatar = el("span", Array.from(message.name.trim())[0]?.toUpperCase() || "?", "chat-avatar"); avatar.setAttribute("aria-hidden", "true"); const content = el("div", undefined, "chat-message-content"); const heading = el("div", undefined, "chat-heading"); const date = new Date(message.at); const timestamp = el("time", date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })); timestamp.dateTime = new Date(message.at).toISOString(); timestamp.title = date.toLocaleString(); heading.append(el("strong", message.name), timestamp); // Names and messages are untrusted text, never HTML. content.append(heading, el("p", message.message)); item.append(avatar, content); return item; })); chatSignature = signature; list.scrollTop = firstLoad || atBottom ? list.scrollHeight : previousScroll; } $("chat-status").textContent = messages.length ? "" : "No messages yet. Start the conversation!"; } catch { $("chat-status").textContent = "Messages could not be refreshed. Retrying automatically..."; } finally { chatLoading = false; } } const CHAT_EMOJIS = [ ["smile", "😄"], ["smiley", "😃"], ["grin", "😁"], ["laughing", "😆"], ["joy", "😂"], ["rofl", "🤣"], ["wink", "😉"], ["blush", "😊"], ["heart_eyes", "😍"], ["thinking", "🤔"], ["sunglasses", "😎"], ["sweat_smile", "😅"], ["nervous", "😬"], ["eyes", "👀"], ["cry", "😢"], ["sob", "😭"], ["neutral", "😐"], ["surprised", "😮"], ["scream", "😱"], ["sleeping", "😴"], ["heart", "❤️"], ["green_heart", "💚"], ["blue_heart", "💙"], ["yellow_heart", "💛"], ["purple_heart", "💜"], ["black_heart", "🖤"], ["white_heart", "🤍"], ["thumbsup", "👍"], ["+1", "👍"], ["thumbsdown", "👎"], ["-1", "👎"], ["clap", "👏"], ["wave", "👋"], ["pray", "🙏"], ["muscle", "💪"], ["fire", "🔥"], ["tada", "🎉"], ["party", "🥳"], ["sparkles", "✨"], ["star", "⭐"], ["trophy", "🏆"], ["medal", "🏅"], ["rocket", "🚀"], ["hourglass", "⌛"], ["clock", "🕒"], ["alarm", "⏰"], ["stopwatch", "⏱️"], ["button", "🔘"], ["red_circle", "🔴"], ["blue_circle", "🔵"], ["green_circle", "🟢"], ["yellow_circle", "🟡"], ["white_circle", "⚪"], ["black_circle", "⚫"], ["100", "💯"], ["gg", "🤝"], ["skull", "💀"], ["coffee", "☕"], ]; let emojiMatches = []; let emojiSelection = 0; let emojiRange = null; function closeEmojiSuggestions() { emojiMatches = []; emojiRange = null; $("emoji-suggestions").hidden = true; $("chat-input").setAttribute("aria-expanded", "false"); $("chat-input").removeAttribute("aria-activedescendant"); } function renderEmojiSuggestions() { const list = $("emoji-suggestions"); list.replaceChildren(...emojiMatches.map(([name, emoji], index) => { const option = el("li", undefined, "emoji-option"); option.id = `emoji-option-${index}`; option.dataset.index = String(index); option.setAttribute("role", "option"); option.setAttribute("aria-selected", String(index === emojiSelection)); option.append(el("span", emoji, "emoji-glyph"), el("span", `:${name}:`)); return option; })); list.hidden = false; $("chat-input").setAttribute("aria-expanded", "true"); $("chat-input").setAttribute("aria-activedescendant", `emoji-option-${emojiSelection}`); } function suggestEmoji() { const input = $("chat-input"); if (input.selectionStart !== input.selectionEnd) return closeEmojiSuggestions(); const before = input.value.slice(0, input.selectionStart); const match = before.match(/(?:^|\s):([a-z0-9_+\-]*):?$/i); if (!match) return closeEmojiSuggestions(); const query = match[1].toLowerCase(); // A completed shortcode must match exactly; never turn an unknown code into another emoji. const complete = before.endsWith(":") && match[0].trim() !== ":"; emojiMatches = CHAT_EMOJIS.filter(([name]) => complete ? name === query : name.startsWith(query)).slice(0, 6); if (!emojiMatches.length) return closeEmojiSuggestions(); emojiSelection = 0; emojiRange = { start: before.lastIndexOf(":", complete ? before.length - 2 : before.length - 1), end: input.selectionStart }; renderEmojiSuggestions(); } function insertEmoji(index) { if (!emojiRange || !emojiMatches[index]) return; const input = $("chat-input"); const emoji = emojiMatches[index][1]; const { start, end } = emojiRange; if (input.value.length - (end - start) + emoji.length > input.maxLength) { $("chat-error").textContent = "This emoji would exceed the message length limit."; closeEmojiSuggestions(); return; } input.setRangeText(emoji, start, end, "end"); $("chat-error").textContent = ""; closeEmojiSuggestions(); input.focus(); } $("chat-input").addEventListener("input", (event) => { if (!event.isComposing) suggestEmoji(); }); $("chat-input").addEventListener("compositionend", suggestEmoji); $("chat-input").addEventListener("click", suggestEmoji); $("chat-input").addEventListener("blur", closeEmojiSuggestions); $("chat-input").addEventListener("keydown", (event) => { if (event.isComposing || !emojiMatches.length) return; if (["ArrowDown", "ArrowUp"].includes(event.key)) { event.preventDefault(); emojiSelection = (emojiSelection + (event.key === "ArrowDown" ? 1 : -1) + emojiMatches.length) % emojiMatches.length; renderEmojiSuggestions(); } else if (["Enter", "Tab"].includes(event.key)) { event.preventDefault(); insertEmoji(emojiSelection); } else if (event.key === "Escape") { event.preventDefault(); closeEmojiSuggestions(); } else if (["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) { closeEmojiSuggestions(); } }); $("emoji-suggestions").addEventListener("pointerdown", (event) => { const option = event.target.closest("[data-index]"); if (!option) return; event.preventDefault(); // keep the caret and avoid blur before insertion insertEmoji(Number(option.dataset.index)); }); $("chat-form").addEventListener("submit", async (event) => { event.preventDefault(); const input = $("chat-input"); closeEmojiSuggestions(); $("chat-send").disabled = true; $("chat-error").textContent = ""; try { await api("POST", "/api/chat", { message: input.value }); input.value = ""; await loadChat(); } catch (error) { $("chat-error").textContent = error.message; if (error.status === 401) { token = null; localStorage.removeItem(TOKEN_KEY); updateButton(); } } finally { $("chat-send").disabled = false; } }); async function loadStats() { const seasonId = game?.season?.id; try { const s = await api("GET", "/api/stats"); if (game?.season?.id !== seasonId) return; statsLoaded = true; renderSections(); 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; currentShareText = ""; renderSections(); try { const r = await api("POST", "/api/press"); game.deadline = r.deadline; currentShareText = `I pushed #TheButton 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(); await loadMe(); await loadLeaderboard(); if (selectedSection === "chat-section") await loadChat(); } 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); syncPresence(); $("password-input").value = ""; updateButton(); await loadMe(); await loadLeaderboard(); } 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(); syncPresence(); }); $("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); })();