diff --git a/README.md b/README.md index 1df7833..50b7506 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ The goal is to make waiting social and consequential without rewarding button sp 2. **Personal bests and rivalries — implemented.** Your score shows closest call, highest-scoring press, leaderboard position, and the player immediately ahead with points needed to catch up. Existing history is credited automatically; no data migration is needed. 3. **Achievements — implemented.** Close call, Full spectrum, and Keeping us alive appear under Your score with unlock dates and progress. Existing press history counts, with no bonus points or press-volume rewards. 4. **Community milestones — implemented.** Eight one-time shared goals post gold announcements in chat. Unlocks persist across restarts, and historical milestones are silently credited on upgrade. -5. **Optional zone alerts.** Let players choose a zone and opt into sound or browser notifications. Request permissions only after an explicit action, alert on transitions rather than polls, and handle resets and denied permissions. Closed-browser delivery is separate work, not promised by basic tab notifications. +5. **Optional zone alerts — implemented.** Choose a scoring zone and opt into a chime, browser notifications, or both in Alerts. Off by default; notifications require explicit permission. Works while the page remains open, not after closing it. 6. **Separate one-life mode — exploratory.** Allow one press per player per round in a separate mode with its own rounds and leaderboard. Never silently change the current game or reset its data. Keep new features consistent with the Catppuccin Mocha styling and selectable-section navigation. Preserve visible countdown/button controls on desktop and mobile, authenticated mutations, bounded input, safe text rendering, and server-authoritative scoring. Verify changes using isolated databases rather than live game data. @@ -88,7 +88,15 @@ Survival milestones use real elapsed time since game start, not scaled countdown Milestone unlocks are stored in SQLite separately from player chat, and share the latest-100-message feed. They have no player dependency and do not affect chat cooldowns. Registration/press milestone unlocks commit with the corresponding game update. Repeated requests, watchers, and restarts cannot announce a goal twice. The first upgrade/import silently records already-reached goals to avoid flooding chat; future newly-reached goals announce normally. On later restarts, newly crossed survival milestones are announced once. Missed goals are never replayed repeatedly. -Optional zone alerts are the next roadmap step. +### Optional zone alerts + +The **Alerts** section is available without an account. Select blue, yellow, red, black, or white, enable alerts, choose a chime and/or browser notification, and save. Alerts are off by default. Settings are saved locally in this browser and synchronized between its open tabs, not stored with your account. + +Alerts use server snapshots, normally every five seconds, rather than trusting the animated countdown alone. They fire only when an observed countdown advances into the selected zone, once per selected zone per countdown deadline. Opening/reloading the page, saving settings, game over, stale responses, and a changed deadline do not trigger an alert. A reset establishes a fresh baseline. If polling skips the entire selected zone, no late alert is sent. The latest alerted deadline/zones are remembered in browser storage to reduce duplicates between tabs; simultaneous-tab races are possible. + +Notification permission is requested only when explicitly saving enabled notification settings. Unsupported browsers, insecure origins, denied/dismissed permissions, and blocked audio produce guidance rather than repeatedly prompting. Sound uses a short generated chime; **Test chime** lets you enable/test audio after opening the page. Audio may require another gesture after reopening, depending on browser policy. + +Keep the page open. Background throttling, suspension, network failures, and OS notification settings can delay or suppress delivery. No service worker, push subscription, or closed-browser notification delivery is included. The selected section need not remain open. One-life mode remains exploratory and is not implemented. ### Live presence diff --git a/static/app.js b/static/app.js index 44cd29b..12f4027 100644 --- a/static/app.js +++ b/static/app.js @@ -21,6 +21,135 @@ if (!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; @@ -72,6 +201,11 @@ $("presence-opt-in").addEventListener("change", () => { }); 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) === "true"; token = localStorage.getItem(TOKEN_KEY); @@ -85,6 +219,7 @@ function renderSections() { "account-section": true, "leaderboard-section": true, "chat-section": true, + "alerts-section": true, "stats-section": statsLoaded, rules: true, "share-controls": !!currentShareText, @@ -254,6 +389,7 @@ async function refreshState() { const first = !game; clockOffset = s.now - (t0 + Date.now()) / 2; game = s; + 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(); diff --git a/static/index.html b/static/index.html index ab9f53a..d29ee25 100644 --- a/static/index.html +++ b/static/index.html @@ -42,6 +42,7 @@ + @@ -57,6 +58,25 @@
Choose a section above to join in, compare scores, or say hello.
+WAIT ON YOUR TERMS
+Choose a zone. We'll give you a nudge when the clock gets there.
+Alerts are off by default.
+ +Keep this page open. Alerts follow server updates, usually every five seconds. Background tabs may be delayed or suspended, and very short zones can be missed. Alerts do not work after the page is closed.
Opening the page, saving settings, or someone resetting the clock won't trigger an alert. Each selected zone alerts once per countdown. Preferences stay in this browser; no account is required.
Sound may need a click on Test chime after reopening the page. Browser notifications require permission and a supported secure browser (HTTPS or localhost).