Seasons
This commit is contained in:
+61
-1
@@ -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
@@ -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…</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…</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…</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…</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
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user