word-waltz / files / src / components / Scoreboard.ts
Scoreboard.ts
1c548168 2026-03-24 Pavel Safronov | 1 | import type { Player, PlayerId, Round } from '../types.js'; |
26f9fd72 2026-03-26 Pavel Safronov | 2 | import { guessesToEmoji } from '../utils/scoring.js'; |
f755bca6 2026-03-23 Pavel Safronov | 3 | |
| 4 | /** | |
| 5 | * Scoreboard — running score display | |
| 6 | * | |
1c548168 2026-03-24 Pavel Safronov | 7 | * Shows each player's name and cumulative score, plus the round history. |
bf4044de 2026-03-24 Pavel Safronov | 8 | * The current player's name can be edited inline. |
| 9 | * The history is collapsed by default (showing last 2 rounds) and can be expanded. | |
f755bca6 2026-03-23 Pavel Safronov | 10 | */ |
| 11 | ||
| 12 | interface ScoreboardProps { | |
| 13 | players: { a: Player; b: Player }; | |
| 14 | rounds: Round[]; | |
bf4044de 2026-03-24 Pavel Safronov | 15 | myRole: PlayerId; |
4a3e918a 2026-03-26 Pavel Safronov | 16 | turn: 'pick' | 'solve'; |
| 17 | currentRound: number; | |
bf4044de 2026-03-24 Pavel Safronov | 18 | onNameChange: (newName: string) => void; |
f755bca6 2026-03-23 Pavel Safronov | 19 | } |
| 20 | ||
| 21 | export function mountScoreboard(container: HTMLElement, props: ScoreboardProps): () => void { | |
4a3e918a 2026-03-26 Pavel Safronov | 22 | const { players, rounds, myRole, turn, currentRound, onNameChange } = props; |
1c548168 2026-03-24 Pavel Safronov | 23 | |
| 24 | const root = document.createElement('div'); | |
| 25 | root.className = 'scoreboard'; | |
| 26 | ||
4a3e918a 2026-03-26 Pavel Safronov | 27 | // Exclude a live round (currently being solved) from the completed list. |
| 28 | const liveRoundIdx = turn === 'solve' ? currentRound : -1; | |
| 29 | const completedRounds = rounds | |
| 30 | .map((r, i) => ({ r, i })) | |
| 31 | .filter(({ r, i }) => i !== liveRoundIdx && r.guesses.length > 0); | |
405e0a58 2026-03-27 Pavel Safronov | 32 | let historyVisible = false; |
bf4044de 2026-03-24 Pavel Safronov | 33 | |
| 34 | function render(): void { | |
| 35 | root.innerHTML = ''; | |
| 36 | ||
| 37 | // --- Players row --- | |
| 38 | const playersEl = document.createElement('div'); | |
| 39 | playersEl.className = 'scoreboard__players'; | |
| 40 | ||
| 41 | (['a', 'b'] as PlayerId[]).forEach((id, i) => { | |
| 42 | if (i === 1) { | |
| 43 | const vs = document.createElement('span'); | |
| 44 | vs.className = 'scoreboard__vs'; | |
| 45 | vs.textContent = 'vs'; | |
| 46 | playersEl.appendChild(vs); | |
| 47 | } | |
| 48 | ||
| 49 | const p: Player = players[id]; | |
| 50 | const displayName = p.name || (id === 'a' ? 'Player A' : 'Player B'); | |
| 51 | const card = document.createElement('div'); | |
| 52 | card.className = 'scoreboard__player'; | |
| 53 | ||
4a3e918a 2026-03-26 Pavel Safronov | 54 | const nameEl = document.createElement('span'); |
| 55 | nameEl.className = `scoreboard__name scoreboard__name--${id}`; | |
| 56 | nameEl.textContent = displayName; | |
| 57 | ||
bf4044de 2026-03-24 Pavel Safronov | 58 | if (id === myRole) { |
4a3e918a 2026-03-26 Pavel Safronov | 59 | nameEl.classList.add('scoreboard__name--editable'); |
| 60 | nameEl.title = 'Click to rename'; | |
| 61 | nameEl.addEventListener('click', () => { | |
bf4044de 2026-03-24 Pavel Safronov | 62 | const input = document.createElement('input'); |
| 63 | input.type = 'text'; | |
| 64 | input.className = 'scoreboard__name-input'; | |
| 65 | input.value = p.name; | |
| 66 | input.maxLength = 20; | |
4a3e918a 2026-03-26 Pavel Safronov | 67 | nameEl.replaceWith(input); |
bf4044de 2026-03-24 Pavel Safronov | 68 | input.focus(); |
| 69 | input.select(); | |
| 70 | ||
| 71 | function commit() { | |
| 72 | const val = input.value.trim(); | |
| 73 | if (val && val !== p.name) onNameChange(val); | |
| 74 | else render(); // revert | |
| 75 | } | |
| 76 | input.addEventListener('blur', commit); | |
| 77 | input.addEventListener('keydown', (e) => { | |
| 78 | if (e.key === 'Enter') { e.preventDefault(); input.blur(); } | |
| 79 | if (e.key === 'Escape') { input.removeEventListener('blur', commit); render(); } | |
| 80 | }); | |
| 81 | }); | |
| 82 | } | |
1c548168 2026-03-24 Pavel Safronov | 83 | |
4a3e918a 2026-03-26 Pavel Safronov | 84 | card.appendChild(nameEl); |
| 85 | ||
bf4044de 2026-03-24 Pavel Safronov | 86 | const scoreEl = document.createElement('span'); |
b6b781d4 2026-03-26 Pavel Safronov | 87 | scoreEl.className = `scoreboard__score scoreboard__score--${id}`; |
bf4044de 2026-03-24 Pavel Safronov | 88 | scoreEl.textContent = String(p.score); |
| 89 | card.appendChild(scoreEl); | |
| 90 | playersEl.appendChild(card); | |
| 91 | }); | |
1c548168 2026-03-24 Pavel Safronov | 92 | |
bf4044de 2026-03-24 Pavel Safronov | 93 | root.appendChild(playersEl); |
| 94 | ||
| 95 | // --- History --- | |
4a3e918a 2026-03-26 Pavel Safronov | 96 | if (completedRounds.length === 0 && liveRoundIdx < 0) return; |
bf4044de 2026-03-24 Pavel Safronov | 97 | |
| 98 | const historyEl = document.createElement('div'); | |
| 99 | historyEl.className = 'scoreboard__history-wrap'; | |
| 100 | ||
405e0a58 2026-03-27 Pavel Safronov | 101 | const toggle = document.createElement('button'); |
| 102 | toggle.className = 'scoreboard__toggle'; | |
| 103 | toggle.textContent = historyVisible ? '▲ Hide history' : '▼ Show history'; | |
| 104 | toggle.addEventListener('click', () => { historyVisible = !historyVisible; render(); }); | |
| 105 | historyEl.appendChild(toggle); | |
| 106 | ||
| 107 | if (historyVisible) { | |
4a3e918a 2026-03-26 Pavel Safronov | 108 | |
405e0a58 2026-03-27 Pavel Safronov | 109 | const ol = document.createElement('ol'); |
| 110 | ol.className = 'scoreboard__history'; | |
| 111 | completedRounds.forEach(({ r, i }) => { | |
| 112 | const roundNum = i + 1; | |
| 113 | const solver: PlayerId = r.challenger === 'a' ? 'b' : 'a'; | |
4a3e918a 2026-03-26 Pavel Safronov | 114 | const solverName = players[solver].name || (solver === 'a' ? 'Player A' : 'Player B'); |
405e0a58 2026-03-27 Pavel Safronov | 115 | const result = r.solved |
| 116 | ? `solved "${r.word}" in ${r.guesses.length} ${r.guesses.length === 1 ? 'guess' : 'guesses'} (+${r.score})` | |
| 117 | : `failed to solve "${r.word}"`; | |
| 118 | const li = document.createElement('li'); | |
| 119 | li.className = 'scoreboard__round'; | |
| 120 | const textEl = document.createElement('span'); | |
4a3e918a 2026-03-26 Pavel Safronov | 121 | const numSpan = document.createElement('span'); |
| 122 | numSpan.className = 'scoreboard__round-num'; | |
| 123 | numSpan.textContent = `${roundNum}.`; | |
| 124 | const nameSpan = document.createElement('span'); | |
| 125 | nameSpan.className = `scoreboard__name--${solver}`; | |
| 126 | nameSpan.textContent = solverName; | |
405e0a58 2026-03-27 Pavel Safronov | 127 | textEl.appendChild(numSpan); |
| 128 | textEl.appendChild(nameSpan); | |
| 129 | textEl.appendChild(document.createTextNode(`: ${result}`)); | |
| 130 | li.appendChild(textEl); | |
| 131 | if (r.guesses.length > 0) { | |
| 132 | const emojiEl = document.createElement('div'); | |
| 133 | emojiEl.className = 'scoreboard__emoji'; | |
| 134 | emojiEl.textContent = guessesToEmoji(r.guesses, r.word); | |
| 135 | li.appendChild(emojiEl); | |
| 136 | } | |
| 137 | ol.appendChild(li); | |
| 138 | }); | |
| 139 | ||
| 140 | // Live round — show pending entry to the challenger only. | |
| 141 | if (liveRoundIdx >= 0) { | |
| 142 | const liveRound = rounds[liveRoundIdx]; | |
| 143 | if (liveRound && myRole === liveRound.challenger) { | |
| 144 | const roundNum = liveRoundIdx + 1; | |
| 145 | const solver: PlayerId = liveRound.challenger === 'a' ? 'b' : 'a'; | |
| 146 | const solverName = players[solver].name || (solver === 'a' ? 'Player A' : 'Player B'); | |
| 147 | const liveLi = document.createElement('li'); | |
| 148 | liveLi.className = 'scoreboard__round scoreboard__round--live'; | |
| 149 | const numSpan = document.createElement('span'); | |
| 150 | numSpan.className = 'scoreboard__round-num'; | |
| 151 | numSpan.textContent = `${roundNum}.`; | |
| 152 | const nameSpan = document.createElement('span'); | |
| 153 | nameSpan.className = `scoreboard__name--${solver}`; | |
| 154 | nameSpan.textContent = solverName; | |
| 155 | liveLi.appendChild(numSpan); | |
| 156 | liveLi.appendChild(nameSpan); | |
| 157 | liveLi.appendChild(document.createTextNode(` is trying to solve ${liveRound.word}…`)); | |
| 158 | ol.appendChild(liveLi); | |
| 159 | } | |
4a3e918a 2026-03-26 Pavel Safronov | 160 | } |
| 161 | ||
405e0a58 2026-03-27 Pavel Safronov | 162 | historyEl.appendChild(ol); |
| 163 | } // end if historyVisible | |
bf4044de 2026-03-24 Pavel Safronov | 164 | root.appendChild(historyEl); |
| 165 | } | |
| 166 | ||
| 167 | render(); | |
1c548168 2026-03-24 Pavel Safronov | 168 | container.appendChild(root); |
| 169 | ||
| 170 | return function cleanup() { | |
| 171 | container.removeChild(root); | |
| 172 | }; | |
| 173 | } |