Engagement features

This commit is contained in:
2026-10-21 13:10:25 -04:00
parent f4c10c9d7d
commit f9afa8ccbe
6 changed files with 405 additions and 4 deletions
+98
View File
@@ -12,6 +12,73 @@ let currentShareText = "";
let selectedSection = null;
let chatSignature = null;
let chatLoading = false;
const PRESENCE_KEY = "thebutton-share-presence";
let sharePresence = localStorage.getItem(PRESENCE_KEY) === "true";
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;
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 === PRESENCE_KEY || event.key === TOKEN_KEY) {
sharePresence = localStorage.getItem(PRESENCE_KEY) === "true";
token = localStorage.getItem(TOKEN_KEY);
updateButton();
syncPresence();
}
});
function renderSections() {
const available = {
@@ -38,6 +105,7 @@ function renderSections() {
$("chat-form").hidden = !token;
$("chat-login").hidden = !!token;
$("section-hint").hidden = selectedSection !== null;
$("presence-opt-in").checked = sharePresence;
}
$("section-nav").addEventListener("click", (event) => {
@@ -48,6 +116,7 @@ $("section-nav").addEventListener("click", (event) => {
if (selectedSection === "account-section") loadMe();
if (selectedSection === "leaderboard-section") loadLeaderboard();
if (selectedSection === "chat-section") loadChat();
syncPresence();
});
async function api(method, path, body) {
@@ -190,8 +259,10 @@ async function refreshState() {
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();
}
function updateButton() {
@@ -225,6 +296,31 @@ async function loadMe() {
$("my-name").textContent = me.name;
$("my-points").textContent = me.total_points.toLocaleString();
$("my-presses").textContent = me.press_count;
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));
@@ -394,6 +490,7 @@ $("register-form").addEventListener("submit", async (ev) => {
});
token = r.token;
localStorage.setItem(TOKEN_KEY, token);
syncPresence();
$("password-input").value = "";
updateButton();
await loadMe();
@@ -412,6 +509,7 @@ $("logout-button").addEventListener("click", async () => {
$("press-result").textContent = "";
$("subscribe-result").textContent = "";
updateButton();
syncPresence();
});
$("subscribe-form").addEventListener("submit", async (ev) => {
+21
View File
@@ -26,6 +26,7 @@
<div id="zone-label"></div>
<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>
</section>
<section id="button-section">
@@ -110,10 +111,26 @@
</header>
<div class="panel-body">
<p id="my-subscribed" hidden>You're on the mailing list. Thanks!</p>
<div class="presence-preference">
<label for="presence-opt-in"><input id="presence-opt-in" type="checkbox" aria-describedby="presence-help"> Show me online</label>
<p id="presence-help">Optional. Share that you're here, and your name in chat while viewing it. Only visible tabs count; no activity history is saved.</p>
<p id="presence-error" class="error" role="status"></p>
</div>
<div class="score-grid">
<div class="metric-card"><span>Total points</span><strong id="my-points">0</strong><small>Every moment counts</small></div>
<div class="metric-card"><span>Button presses</span><strong id="my-presses">0</strong><small>Times you kept us going</small></div>
</div>
<h3 class="subheading">Your personal bests</h3>
<div class="score-grid personal-records">
<div class="metric-card"><span>Closest call</span><strong id="my-closest-call">&mdash;</strong><small id="my-closest-detail">Your first press starts the story.</small></div>
<div class="metric-card"><span>Highest-scoring press</span><strong id="my-best-press">&mdash;</strong><small id="my-best-detail">Wait for your moment.</small></div>
</div>
<div class="rival-card" aria-labelledby="rival-heading">
<div class="header-topline"><p id="rival-heading" class="eyebrow">YOUR NEXT CHALLENGE</p><span id="my-position" class="site-badge"></span></div>
<h3 id="my-rival-title">Finding your place&hellip;</h3>
<p id="my-rival-description"></p>
<p class="fine-print">Positions follow the leaderboard: total points, then player name for ties. Any positive points gain puts you ahead of an equal score.</p>
</div>
<h3 class="subheading">Your scoring zones</h3>
<div class="table-scroll"><table id="my-zones" class="zone-table"></table></div>
<details>
@@ -147,6 +164,10 @@
</div>
<span class="chat-badge">Public room</span>
</header>
<div class="chat-presence" aria-label="Players viewing chat">
<p id="chat-presence-summary">Who's in the lounge</p>
<ul id="chat-presence-names" aria-label="Online chat players"></ul>
</div>
<div class="chat-feed">
<div class="chat-feed-label"><span>Conversation</span><span>Latest 100 messages</span></div>
<ol id="chat-messages" aria-label="Chat messages" tabindex="0"></ol>
+19
View File
@@ -196,6 +196,12 @@ details[open] summary { margin-bottom: 1rem; }
.metric-card strong { display: block; font-size: clamp(1.7rem, 3vw, 2.4rem); color: var(--mauve); line-height: 1.5; overflow-wrap: anywhere; font-variant-numeric: tabular-nums; }
.metric-card:nth-child(2) strong { color: var(--teal); }
.metric-card small { font-size: 0.65rem; color: var(--subtext); }
.personal-records .metric-card strong { font-size: clamp(1rem, 1.8vw, 1.35rem); letter-spacing: -0.04em; padding-block: 0.4rem; }
.personal-records .metric-card small { display: block; line-height: 1.6; }
.rival-card { padding: 1.1rem; margin-top: 1rem; border: 1px solid #cba6f730; border-radius: 12px; background: linear-gradient(110deg, #cba6f70c, transparent), var(--base); }
.rival-card #my-rival-title { margin: 0.8rem 0 0.4rem; font-size: 1rem; color: var(--lavender); overflow-wrap: anywhere; }
#my-rival-description { font-size: 0.85rem; color: var(--text); margin: 0; }
.rival-card .fine-print { font-size: 0.65rem; margin-bottom: 0; }
#my-subscribed { padding: 0.65rem; border-radius: 8px; background: #a6e3a110; color: var(--green); font-size: 0.75rem; }
#leaderboard-status:empty { display: none; }
@@ -367,6 +373,19 @@ stripe-buy-button { display: block; max-width: 100%; margin: 1.5rem 0; }
#chat-login { margin: 0; color: var(--lavender); font-size: 0.85rem; line-height: 1.6; }
#chat-status:empty, #chat-error:empty { display: none; }
.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); }
.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; }
#presence-help { margin: 0.5rem 0 0; font-size: 0.7rem; line-height: 1.6; color: var(--subtext); }
#presence-error { margin-bottom: 0; font-size: 0.75rem; }
.chat-presence { padding: 0.9rem 1.5rem; border-bottom: 1px solid var(--surface); background: #94e2d504; }
#chat-presence-summary { font-size: 0.7rem; color: var(--subtext); margin: 0; }
#chat-presence-names { display: flex; flex-wrap: wrap; gap: 0.4rem; list-style: none; padding: 0; margin: 0.5rem 0 0; }
#chat-presence-names:empty { display: none; }
#chat-presence-names li { font-size: 0.7rem; padding: 0.15rem 0.55rem; border-radius: 20px; color: var(--teal); background: #94e2d50c; border: 1px solid #94e2d525; overflow-wrap: anywhere; max-width: 100%; }
#rules { text-align: left; font-size: 0.9rem; color: var(--subtext); }
@media (max-width: 760px) {