fuzzyworld

word-waltz / commit

add notification

f81da770679a1fd83e8b7e829d07da1a298a0cf7 · Pavel Safronov · 2026-03-23 18:13:01 -0700 · parents f038874d96f6

add notification
 src/main.ts                | 24 ++++++++++++++++++++++++
 src/utils/notifications.ts | 41 +++++++++++++++++++++++++++++++++++++++++
 2 files changed, 65 insertions(+)

diff --git a/src/main.ts b/src/main.ts
index ef45c94..6e939e3 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -15,6 +15,7 @@ import { mountWordPicker } from './components/WordPicker.js';
 import { mountWordleBoard } from './components/WordleBoard.js';
 import { mountScoreboard } from './components/Scoreboard.js';
 import { mountShareDialog } from './components/ShareDialog.js';
+import { notifyMyTurn, clearNotification } from './utils/notifications.js';
 import type { GameState, PlayerId, Round } from './types.js';
 
 // ---------------------------------------------------------------------------
@@ -54,6 +55,13 @@ function currentSolver(state: GameState): PlayerId {
     return state.rounds[state.currentRound].challenger === 'a' ? 'b' : 'a';
 }
 
+/** Returns true if it is currently this player's turn to act. */
+function isMyTurn(state: GameState, role: PlayerId): boolean {
+    if (state.players.b.name === '') return false; // waiting for opponent to join
+    if (state.turn === 'pick') return nextChallenger(state) === role;
+    return currentSolver(state) === role;
+}
+
 // ---------------------------------------------------------------------------
 // Firestore write helper
 // ---------------------------------------------------------------------------
@@ -351,10 +359,20 @@ async function bootstrap(): Promise<void> {
 
     render(app, state, role, gameId, cryptoKey, shareUrl);
 
+    // Track whether it was already my turn when we first loaded so we only
+    // notify on *transitions* (opponent finished), not on initial page open.
+    let wasMyTurn = isMyTurn(state, role);
+
     unsubscribeGame?.();
     unsubscribeGame = subscribeToGame(gameId, async (newEnvelope) => {
         try {
             const newState = JSON.parse(await decrypt(newEnvelope, cryptoKey)) as GameState;
+            const myTurnNow = isMyTurn(newState, role);
+            if (myTurnNow && !wasMyTurn) {
+                const msg = newState.turn === 'pick' ? "It's your turn to pick a word!" : "It's your turn to solve the word!";
+                notifyMyTurn(msg);
+            }
+            wasMyTurn = myTurnNow;
             render(app, newState, role, gameId, cryptoKey, shareUrl);
         } catch {
             // Silently ignore — could be a stale snapshot during writes
@@ -370,11 +388,17 @@ document.getElementById('home-link')?.addEventListener('click', (e) => {
     unsubscribeGame = null;
     cleanupView?.();
     cleanupView = null;
+    clearNotification();
     history.replaceState(null, '', window.location.pathname);
     const app = document.getElementById('app');
     if (app) showNewGameScreen(app);
 });
 
+// Clear the alert title as soon as the user focuses the tab
+document.addEventListener('visibilitychange', () => {
+    if (document.visibilityState === 'visible') clearNotification();
+});
+
 // Re-run bootstrap whenever the fragment changes (e.g. after game creation
 // navigates to the game URL — a hash-only change doesn't reload the page).
 window.addEventListener('hashchange', () => {
diff --git a/src/utils/notifications.ts b/src/utils/notifications.ts
new file mode 100644
index 0000000..badf7d9
--- /dev/null
+++ b/src/utils/notifications.ts
@@ -0,0 +1,41 @@
+/**
+ * notifications.ts — tab title + OS notification helpers
+ *
+ * Strategy:
+ *  - Always update document.title so the user sees an alert in the tab bar
+ *    even without notification permission.
+ *  - Fire a Notification API alert when permission is granted and the tab is
+ *    not currently focused (no point interrupting someone already looking at
+ *    the page).
+ *  - Request permission lazily on the first "your turn" event so the browser
+ *    prompt appears in a meaningful context, not on page load.
+ */
+
+const BASE_TITLE = 'Word Waltz';
+
+/** Set the tab title and optionally send an OS notification. */
+export async function notifyMyTurn(message: string): Promise<void> {
+    document.title = `⚡ ${message} — ${BASE_TITLE}`;
+
+    if (!('Notification' in window)) return;
+
+    // Request permission lazily on first use
+    if (Notification.permission === 'default') {
+        await Notification.requestPermission();
+    }
+
+    if (Notification.permission !== 'granted') return;
+
+    // Only fire when the tab is hidden — no need to interrupt an active player
+    if (document.visibilityState === 'visible') return;
+
+    new Notification(BASE_TITLE, {
+        body: message,
+        icon: '/apple-touch-icon.png',
+    });
+}
+
+/** Reset the title back to the default. */
+export function clearNotification(): void {
+    document.title = BASE_TITLE;
+}