word-waltz / commit
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; +}