This commit is contained in:
2026-10-21 14:02:50 -04:00
parent 69d49073f0
commit 613647cb50
6 changed files with 580 additions and 22 deletions
+61 -1
View File
@@ -218,6 +218,7 @@ function renderSections() {
const available = {
"account-section": true,
"leaderboard-section": true,
"seasons-section": true,
"chat-section": true,
"alerts-section": true,
"stats-section": statsLoaded,
@@ -251,6 +252,7 @@ $("section-nav").addEventListener("click", (event) => {
if (selectedSection === "account-section") loadMe();
if (selectedSection === "leaderboard-section") loadLeaderboard();
if (selectedSection === "chat-section") loadChat();
if (selectedSection === "seasons-section") loadSeasons();
syncPresence();
});
@@ -387,8 +389,20 @@ 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.`;
@@ -399,6 +413,7 @@ async function refreshState() {
await loadLeaderboard();
if (selectedSection === "chat-section") await loadChat();
if (selectedSection === "account-section") await loadMe();
if (selectedSection === "seasons-section") await loadSeasons();
}
function updateButton() {
@@ -417,7 +432,7 @@ function tick() {
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"}`;
$("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(() => {});
@@ -513,6 +528,49 @@ async function loadMe() {
}
}
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");
@@ -592,8 +650,10 @@ $("chat-form").addEventListener("submit", async (event) => {
});
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");
+21 -3
View File
@@ -19,6 +19,7 @@
<div class="game-panel">
<p class="eyebrow game-eyebrow">A SHARED EXPERIMENT</p>
<h1>THE BUTTON</h1>
<p id="season-subtitle" class="season-subtitle">Loading season&hellip;</p>
<p class="game-tagline">One clock. Everyone's nerve.</p>
<section id="timer-section">
@@ -27,6 +28,7 @@
<div id="zone-bar"><div id="zone-marker"></div></div>
<p id="history-global-summary" aria-live="polite"></p>
<p id="presence-count" class="presence-count" role="status">Checking who's here&hellip;</p>
<p id="season-label" class="season-label"></p>
</section>
<section id="button-section">
@@ -41,6 +43,7 @@
<nav id="section-nav" aria-label="Game sections">
<button type="button" data-section="account-section" aria-controls="account-section" aria-expanded="false">Log in / Join</button>
<button type="button" data-section="leaderboard-section" aria-controls="leaderboard-section" aria-expanded="false">Leaderboard</button>
<button type="button" data-section="seasons-section" aria-controls="seasons-section" aria-expanded="false">Seasons</button>
<button type="button" data-section="chat-section" aria-controls="chat-section" aria-expanded="false">Chat</button>
<button type="button" data-section="alerts-section" aria-controls="alerts-section" aria-expanded="false">Alerts</button>
<button type="button" data-section="stats-section" aria-controls="stats-section" aria-expanded="false" hidden>Final stats</button>
@@ -58,6 +61,21 @@
<p class="welcome-hint">Choose a section above to join in, compare scores, or say hello.</p>
</div>
<section id="seasons-section" data-panel hidden aria-labelledby="seasons-heading">
<header class="panel-header"><p class="eyebrow">A NEW CHAPTER, FOUR TIMES A YEAR</p>
<h2 id="seasons-heading">The seasonal leaderboard.</h2>
<p class="panel-description">New scores. Familiar faces. Every equinox and solstice brings a fresh start.</p>
</header>
<div class="panel-body">
<div id="season-current" class="rival-card"></div>
<h3 class="subheading">Season champions</h3>
<p class="fine-print">Starts at 00:00 UTC on the astronomical equinox/solstice date. Joint top scorers share the win. Only players who pressed can win, including zero-point presses.</p>
<p id="seasons-status" role="status">Loading seasons&hellip;</p>
<div class="table-scroll"><table id="season-winners" aria-label="Season winners and durations"></table></div>
<div id="season-standings"></div>
</div>
</section>
<section id="alerts-section" data-panel hidden aria-labelledby="alerts-heading">
<header class="panel-header"><p class="eyebrow">WAIT ON YOUR TERMS</p>
<h2 id="alerts-heading">Don't miss your moment.</h2>
@@ -168,7 +186,7 @@
<header class="panel-header">
<div class="header-topline"><p class="eyebrow">THE LONG GAME</p><span class="site-badge">Updates every 5s</span></div>
<h2 id="leaderboard-heading">Leaderboard</h2>
<p class="panel-description">A little patience. A little nerve. See who's making every second count.</p>
<p class="panel-description">This season's scores. A little patience. A little nerve. See who's making every second count.</p>
</header>
<div class="panel-body">
<p id="leaderboard-status" role="status">Loading scores&hellip;</p>
@@ -213,7 +231,7 @@
<section id="stats-section" data-panel hidden>
<header class="panel-header"><p class="eyebrow">THE FINAL CHAPTER</p>
<h2>Every second had a story.</h2>
<p class="panel-description">The clock has stopped. Here's how we got here.</p></header>
<p class="panel-description">This season's clock has stopped. Here's how we got here. A new game starts next season.</p></header>
<div class="panel-body">
<div id="stats-summary"></div>
<h3>Presses per zone</h3>
@@ -235,7 +253,7 @@
<ol class="rules-list">
<li><div><h3>Keep the clock alive</h3><p>Every press resets the countdown for everyone.</p></div></li>
<li><div><h3>Wait for your moment</h3><p>The lower the countdown, the more you score. Each zone awards a fixed number of points per press.</p></div></li>
<li><div><h3>Don't wait too long</h3><p>If the countdown reaches zero, the game is over. Forever.</p></div></li>
<li><div><h3>Don't wait too long</h3><p>If the countdown reaches zero, this season's game ends. The next equinox or solstice starts a new countdown and resets scores. Accounts and chat stay.</p></div></li>
</ol>
<h3 class="subheading">Six zones. Increasing stakes.</h3>
<div id="rules-zones" class="zone-guide"></div>
+10 -2
View File
@@ -57,6 +57,7 @@ main {
box-shadow: 0 12px 32px #11111b40;
}
.game-panel h1 { font-size: 1.4rem; margin: 0.5rem 0 0; }
.season-subtitle { margin: 0.25rem 0 0; color: var(--mauve); font-size: 0.85rem; font-weight: 600; letter-spacing: 0.12em; }
.game-tagline { font-size: 0.8rem; color: var(--subtext); margin: 0.35rem 0 1.75rem; }
.game-panel #countdown { font-size: clamp(1.8rem, 3.5vw, 2.55rem); font-variant-numeric: tabular-nums; line-height: 1.3; letter-spacing: -0.05em; }
.game-panel #history-global-summary { font-size: 0.75rem; color: var(--subtext); margin: 1rem 0 0; }
@@ -399,6 +400,12 @@ stripe-buy-button { display: block; max-width: 100%; margin: 1.5rem 0; }
.game-panel #presence-count { color: var(--teal); font-size: 0.7rem; margin: 0.65rem 0 0; }
.presence-count::before { content: ""; display: inline-block; width: 6px; height: 6px; border-radius: 50%; margin-right: 0.4rem; background: var(--teal); }
.season-label { margin: 0.65rem 0 0; color: var(--lavender); font-size: 0.65rem; line-height: 1.5; }
#season-current { margin-top: 0; }
#season-current h3 { margin: 0.5rem 0; font-size: 1.1rem; color: var(--lavender); }
#season-winners td { min-width: 75px; overflow-wrap: anywhere; }
#season-winners td:nth-child(2) { min-width: 130px; }
#seasons-status:empty { display: none; }
.presence-preference { border: 1px solid var(--surface); border-radius: 12px; background: var(--base); padding: 0.9rem 1rem; margin-bottom: 1rem; }
.presence-preference label { display: flex; align-items: center; gap: 0.6rem; font-size: 0.8rem; font-weight: 600; color: var(--lavender); cursor: pointer; }
#presence-opt-in { width: 18px; height: 18px; min-height: 0; margin: 0; padding: 0; accent-color: var(--mauve); flex-shrink: 0; }
@@ -426,15 +433,16 @@ stripe-buy-button { display: block; max-width: 100%; margin: 1.5rem 0; }
}
.game-panel .game-eyebrow, .game-tagline, .button-caption { display: none; }
.game-panel h1 { grid-column: 1 / -1; margin: 0; font-size: 1rem; }
.game-panel .season-subtitle { grid-column: 1 / -1; grid-row: 2; margin: 0; font-size: 0.7rem; }
.game-panel section { margin: 0; }
.game-panel #countdown { font-size: clamp(1.4rem, 6vw, 2.5rem); }
#zone-label { font-size: 0.7rem; letter-spacing: 0.1em; }
#zone-bar { margin: 0.35rem auto; }
.game-panel #history-global-summary { margin: 0.25rem 0; font-size: 0.7rem; }
#the-button { width: 84px; height: 84px; font-size: 1rem; border-width: 4px; }
#timer-section { grid-column: 1; grid-row: 2; }
#timer-section { grid-column: 1; grid-row: 3; }
#button-section { display: contents; }
#the-button { grid-column: 2; grid-row: 2; align-self: center; }
#the-button { grid-column: 2; grid-row: 3; align-self: center; }
#press-result:not(:empty) { grid-column: 1 / -1; margin: 0.4rem 0 0; padding: 0.45rem; font-size: 0.7rem; line-height: 1.5; }
#press-result:empty { display: none; }
.section-browser { margin-top: 1rem; }