Engagement features
This commit is contained in:
@@ -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) => {
|
||||
|
||||
@@ -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…</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">—</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">—</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…</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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user