diff --git a/.gitignore b/.gitignore index 7f3f178..2e84f3b 100644 --- a/.gitignore +++ b/.gitignore @@ -8,4 +8,6 @@ data/ node_modules/ npm-debug.log* -TODO \ No newline at end of file +TODO +push.sh +CLAUDE.md diff --git a/public/app.js b/public/app.js index 32d66d4..ab9cf26 100644 --- a/public/app.js +++ b/public/app.js @@ -19,6 +19,7 @@ let swapSelectedSeat = -1; // seat highlighted for swap in waiting room // Widow discard state let widowSelected = []; +let widowActive = false; // true while the widow overlay is open for this WIDOW phase // Bid UI state let currentBidAmount = 85; @@ -907,11 +908,20 @@ function computeLegalCards(st, seat) { // ─── Overlays ───────────────────────────────────────────────── function renderOverlays(st) { - hideAllOverlays(); + const widowDeclarer = st.state === 'WIDOW' && mySeat === st.declarer; + + // Hide the bid/hand overlays every render, but manage the widow overlay + // separately so an unrelated re-render (a spectator joining, a rejoin, etc.) + // does not wipe the declarer's in-progress card selection. + hide('overlay-bid'); + hide('overlay-hand'); + if (!widowDeclarer && widowActive) { widowActive = false; hide('overlay-widow'); } + // Restore actual hand mode if we temporarily forced fan mode during bidding + if (isTouchDevice()) applyHandMode(); if (st.state === 'BIDDING') { renderBiddingOverlay(st); - } else if (st.state === 'WIDOW' && mySeat === st.declarer) { + } else if (widowDeclarer) { renderWidowOverlay(st); } else if (st.state === 'WIDOW' && mySeat !== st.declarer) { $('phase-msg').textContent = `${st.names[st.declarer]} is picking up the widow…`; @@ -922,6 +932,7 @@ function hideAllOverlays() { hide('overlay-bid'); hide('overlay-widow'); hide('overlay-hand'); + widowActive = false; // Restore actual hand mode if we temporarily forced fan mode during bidding if (isTouchDevice()) applyHandMode(); } @@ -992,12 +1003,21 @@ function renderBiddingOverlay(st) { // ─── Widow overlay ──────────────────────────────────────────── function renderWidowOverlay(st) { - widowSelected = []; - show('overlay-widow'); - const needed = st.widowSize; - $('widow-title').textContent = `Pick up widow — discard ${needed} cards`; - $('widow-hint').textContent = `Select exactly ${needed} cards to discard. They count as your first trick.`; + + if (!widowActive) { + // First render of this WIDOW phase — initialize a fresh selection + widowActive = true; + widowSelected = []; + show('overlay-widow'); + $('widow-title').textContent = `Pick up widow — discard ${needed} cards`; + $('widow-hint').textContent = `Select exactly ${needed} cards to discard. They count as your first trick.`; + } else { + // Re-render while already open — preserve the current selection, but drop + // any card that is somehow no longer in hand as a safety measure. + const hand = Array.isArray(st.hands[mySeat]) ? st.hands[mySeat] : []; + widowSelected = widowSelected.filter(c => hand.includes(c)); + } renderWidowHand(st); updateWidowPreview(st); @@ -1152,6 +1172,9 @@ function onHandOver(st) { scoresEl.appendChild(row); } + // Round-by-round running totals (all rounds so far, current totals after the divider) + renderRoundBreakdown($('hand-round-breakdown'), st.roundHistory, st.teamNames, st.scores); + show('overlay-hand'); // overlay-hand is hidden by hideAllOverlays() when the next render() fires (BIDDING state) } @@ -1178,6 +1201,9 @@ function onGameOver(st) { scoresEl.appendChild(row); } + // The round-by-round breakdown every player sees at the end of the game + renderRoundBreakdown($('gameover-breakdown'), st.roundHistory, st.teamNames, st.scores); + show('overlay-gameover'); } @@ -1250,10 +1276,15 @@ async function showProfile(username) { const r = await fetch(`/api/profile/${encodeURIComponent(username)}`); const d = await r.json(); if (!r.ok) return; - $('stat-games-played').textContent = d.games_played; + $('stat-win-rate').textContent = (d.win_rate != null ? d.win_rate : 0) + '%'; $('stat-games-won').textContent = d.games_won; + $('stat-games-played').textContent = d.games_played; $('stat-shelem').textContent = d.shelemCount || 0; - $('stat-total-score').textContent = d.total_score; + $('stat-bids-won').textContent = d.bids_won || 0; + $('stat-avg-score').textContent = d.avg_score != null ? d.avg_score : 0; + + // History button only makes sense for a user who has an identity + $('btn-show-history').dataset.username = username; // Change password button (only own profile) if (username === authUser) show('btn-show-change-pass'); @@ -1282,10 +1313,12 @@ async function showLeaderboard() { const tr = document.createElement('tr'); tr.innerHTML = `
No rounds played yet.
'; + return; + } + const names = teamNames || ['Team 1', 'Team 2']; + const totals = finalScores || rounds[rounds.length - 1].scores; + + // Rounds run down the page (one row each), teams are the two columns, and a + // horizontal line separates the final totals row at the bottom. + let html = '| ' + + ` | ${escHtml(names[0] || 'Team 1')} | ` + + `${escHtml(names[1] || 'Team 2')} | ` + + '
|---|---|---|
| R${r.hand} | `; + for (let t = 0; t < 2; t++) { + const d = (r.deltas && r.deltas[t]) || 0; + html += `${d > 0 ? '+' : ''}${d} | `; + } + html += '|
| Total | ' + + `${totals[0]} | ${totals[1]} | ` + + '
Loading…
'; + try { + const r = await fetch(`/api/history/${encodeURIComponent(username)}`); + const games = await r.json(); + if (!Array.isArray(games) || games.length === 0) { + listEl.innerHTML = 'No games played yet.
'; + return; + } + listEl.innerHTML = ''; + games.forEach(g => { + const row = document.createElement('div'); + row.className = 'history-item' + (g.won ? ' won' : ' lost'); + const date = new Date(g.endedAt).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }); + row.innerHTML = ` +Failed to load history.
'; + } +} + +function showHistoryDetail(g) { + hide('history-list'); + show('history-detail'); + const date = new Date(g.endedAt).toLocaleString(); + $('history-detail-title').textContent = + `${g.won ? '🏆 ' : ''}${g.teamNames[0]} vs ${g.teamNames[1]} — ${date}`; + + const scoresEl = $('history-detail-scores'); + scoresEl.innerHTML = ''; + for (let t = 0; t < 2; t++) { + const won = (g.winnerTeams || []).includes(t); + const row = document.createElement('div'); + row.className = 'gameover-row' + (won ? ' winner' : ''); + row.innerHTML = `${escHtml(g.teamNames[t])}${g.finalScores[t]}`; + scoresEl.appendChild(row); + } + renderRoundBreakdown($('history-detail-breakdown'), g.rounds, g.teamNames, g.finalScores); +} + function updateAiControlBanner() { if (aiControlledSeats.size === 0) { hide('ai-control-banner'); @@ -1315,6 +1439,13 @@ function clearGameState() { aiControlledSeats.clear(); hide('ai-control-banner'); hide('afk-banner'); + // Dismiss any phase overlay so it can't linger over the lobby after leaving + hide('overlay-bid'); + hide('overlay-widow'); + hide('overlay-trump'); + hide('overlay-hand'); + hide('overlay-gameover'); + widowActive = false; } // ─── Event wiring ────────────────────────────────────────────── @@ -1396,6 +1527,11 @@ function wireGameEvents() { show('overlay-exit-confirm'); }); $('btn-leave-game').addEventListener('click', () => show('overlay-exit-confirm')); + // Leave buttons inside the bidding / widow / trump overlays (these overlays + // cover the info bar, so players need their own exit path during those phases) + document.querySelectorAll('[data-overlay-leave]').forEach(btn => { + btn.addEventListener('click', () => show('overlay-exit-confirm')); + }); $('btn-exit-confirm-yes').addEventListener('click', () => { socket?.emit('leave', { roomId: myRoomId, seat: mySeat, token: myToken }); clearSession(); @@ -1472,6 +1608,16 @@ function wireGameEvents() { $('btn-profile-back').addEventListener('click', () => showScreen('screen-lobby')); $('btn-lb-back').addEventListener('click', () => showScreen('screen-lobby')); + // Game history + $('btn-show-history').addEventListener('click', (e) => { + showHistory(e.currentTarget.dataset.username || authUser); + }); + $('btn-history-back').addEventListener('click', () => showScreen('screen-profile')); + $('btn-history-detail-back').addEventListener('click', () => { + hide('history-detail'); + show('history-list'); + }); + // Change password $('btn-show-change-pass').addEventListener('click', () => { $('change-pass-form').classList.toggle('hidden'); diff --git a/public/index.html b/public/index.html index 3845e66..4413da6 100644 --- a/public/index.html +++ b/public/index.html @@ -261,6 +261,7 @@ + @@ -276,6 +277,7 @@ + @@ -296,6 +298,7 @@ + @@ -306,6 +309,7 @@ +Next hand starting…
@@ -316,6 +320,7 @@ + @@ -367,21 +372,33 @@| # | Player | Avg Score | W | Played | 🃏 | +# | Player | Win % | W | P | Avg | Bid | 🃏 |
|---|