const USERNAME_KEY = 'scrumPoker.username'; const roomID = document.body.dataset.roomId; const params = new URLSearchParams(window.location.search); const roomSkeleton = document.getElementById('room-skeleton'); const roomGrid = document.getElementById('room-grid'); const roomTitle = document.getElementById('room-title'); const revealModeLabel = document.getElementById('reveal-mode-label'); const roundStateLabel = document.getElementById('round-state-label'); const votingBoard = document.getElementById('voting-board'); const summaryBody = document.getElementById('summary-body'); const summaryAverage = document.getElementById('summary-average'); const summaryRecommended = document.getElementById('summary-recommended'); const participantList = document.getElementById('participant-list'); const adminControls = document.getElementById('admin-controls'); const revealBtn = document.getElementById('reveal-btn'); const resetBtn = document.getElementById('reset-btn'); const terminalBtn = document.getElementById('terminal-btn'); const shareLinkInput = document.getElementById('share-link'); const shareAdminToggle = document.getElementById('share-admin-toggle'); const votesCounter = document.getElementById('votes-counter'); const roomMessage = document.getElementById('room-message'); const changeNameBtn = document.getElementById('change-name-btn'); const terminalModalOverlay = document.getElementById('terminal-modal-overlay'); const terminalCloseBtn = document.getElementById('terminal-close-btn'); const terminalLogOutput = document.getElementById('terminal-log-output'); const joinPanel = document.getElementById('join-panel'); const joinForm = document.getElementById('join-form'); const joinUsernameInput = document.getElementById('join-username'); const joinRoleInput = document.getElementById('join-role'); const joinPasswordInput = document.getElementById('join-password'); const joinAdminTokenInput = document.getElementById('join-admin-token'); const joinError = document.getElementById('join-error'); let participantID = params.get('participantId') || ''; let adminToken = params.get('adminToken') || ''; const prefillUsername = params.get('username') || ''; let eventSource = null; let latestLinks = { participantLink: '', adminLink: '' }; let latestAdminLogs = []; let latestRole = joinRoleInput.value || 'participant'; const savedUsername = localStorage.getItem(USERNAME_KEY) || ''; joinUsernameInput.value = prefillUsername || savedUsername; joinAdminTokenInput.value = adminToken; if (!window.CardUI || typeof window.CardUI.appendFace !== 'function') { throw new Error('CardUI is not loaded. Ensure /static/js/cards.js is included before room.js.'); } function setJoinError(message) { if (!message) { joinError.classList.add('hidden'); joinError.textContent = ''; return; } joinError.classList.remove('hidden'); joinError.textContent = message; } function updateURL() { const next = new URL(window.location.href); next.searchParams.delete('username'); if (participantID) { next.searchParams.set('participantId', participantID); } else { next.searchParams.delete('participantId'); } if (adminToken) { next.searchParams.set('adminToken', adminToken); } else { next.searchParams.delete('adminToken'); } window.history.replaceState({}, '', next.toString()); } function activateRoomView() { document.body.classList.remove('prejoin'); roomSkeleton.classList.add('hidden'); roomGrid.classList.remove('hidden'); joinPanel.classList.add('hidden'); } function setRoomMessage(message) { roomMessage.textContent = message; } async function joinRoom({ username, role, password, participantIdOverride }) { const response = await fetch(`/api/rooms/${encodeURIComponent(roomID)}/join`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ participantId: participantIdOverride || participantID, username, role, password, adminToken, }), }); const data = await response.json(); if (!response.ok) { throw new Error(data.error || 'Unable to join room.'); } participantID = data.participantId; localStorage.setItem(USERNAME_KEY, data.username); updateURL(); setJoinError(''); return data; } function renderParticipants(participants, isRevealed) { participantList.innerHTML = ''; const visibleParticipants = participants.filter((participant) => participant.connected); visibleParticipants.forEach((participant) => { const item = document.createElement('li'); item.className = 'participant-item'; const name = document.createElement('span'); name.className = 'participant-name'; let label = participant.username; if (participant.id === participantID) { label += ' (You)'; } if (participant.isAdmin) { label += ' [Admin]'; name.classList.add('is-admin'); } name.textContent = label; const status = document.createElement('span'); if (participant.role === 'viewer') { status.textContent = 'Viewer'; } else if (!participant.hasVoted) { status.textContent = 'Voting...'; } else if (isRevealed) { status.textContent = participant.voteValue || '-'; } else { status.textContent = participant.voteValue ? `Voted (${participant.voteValue})` : 'Voted'; } item.appendChild(name); item.appendChild(status); participantList.appendChild(item); }); } function parseNumericVote(value) { if (!/^-?\d+(\.\d+)?$/.test(value)) { return null; } return Number(value); } function calculateSummary(state) { const rows = new Map(); const numericVotes = []; state.participants.forEach((participant) => { if (!participant.connected) { return; } if (participant.role !== 'participant' || !participant.hasVoted || !participant.voteValue) { return; } if (!rows.has(participant.voteValue)) { rows.set(participant.voteValue, []); } rows.get(participant.voteValue).push(participant.username); const numeric = parseNumericVote(participant.voteValue); if (numeric !== null) { numericVotes.push(numeric); } }); let average = null; if (numericVotes.length > 0) { average = numericVotes.reduce((acc, value) => acc + value, 0) / numericVotes.length; } const deckNumeric = state.cards .map(parseNumericVote) .filter((value) => value !== null) .sort((a, b) => a - b); let recommended = null; if (average !== null && deckNumeric.length > 0) { recommended = deckNumeric.find((value) => value >= average) ?? deckNumeric[deckNumeric.length - 1]; } return { rows, average, recommended }; } function renderSummary(state) { if (!state.revealed) { summaryBody.innerHTML = 'Results hidden until reveal.'; summaryAverage.textContent = 'Average: -'; summaryRecommended.textContent = 'Recommended: -'; return; } const { rows, average, recommended } = calculateSummary(state); summaryBody.innerHTML = ''; if (rows.size === 0) { summaryBody.innerHTML = 'No votes submitted.'; } else { state.cards.forEach((cardValue) => { const users = rows.get(cardValue); if (!users || users.length === 0) { return; } const row = document.createElement('tr'); const left = document.createElement('td'); const right = document.createElement('td'); left.textContent = cardValue; right.textContent = users.join(', '); row.appendChild(left); row.appendChild(right); summaryBody.appendChild(row); }); } summaryAverage.textContent = average === null ? 'Average: -' : `Average: ${average.toFixed(2)}`; summaryRecommended.textContent = recommended === null ? 'Recommended: -' : `Recommended: ${recommended}`; } function renderCards(cards, participants, isRevealed) { const self = participants.find((participant) => participant.id === participantID && participant.connected); const canVote = self && self.role === 'participant'; const selfVote = self ? self.voteValue : ''; votingBoard.innerHTML = ''; cards.forEach((value) => { const card = document.createElement('button'); card.type = 'button'; card.className = 'vote-card'; card.setAttribute('aria-label', `Vote ${value}`); window.CardUI.appendFace(card, value); if (selfVote === value && !isRevealed) { card.classList.add('is-selected'); } card.disabled = !canVote; card.addEventListener('click', () => { card.classList.remove('impact'); void card.offsetWidth; card.classList.add('impact'); spawnVoteParticles(card); castVote(value); }); votingBoard.appendChild(card); }); } function spawnVoteParticles(card) { const particleCount = 12; for (let i = 0; i < particleCount; i += 1) { const particle = document.createElement('span'); particle.className = 'vote-particle'; const angle = (Math.PI * 2 * i) / particleCount + (Math.random() * 0.4 - 0.2); const distance = 18 + Math.random() * 26; const dx = Math.cos(angle) * distance; const dy = Math.sin(angle) * distance; particle.style.setProperty('--tx', `${dx.toFixed(2)}px`); particle.style.setProperty('--ty', `${dy.toFixed(2)}px`); particle.style.setProperty('--hue', `${Math.floor(Math.random() * 80) + 90}`); particle.addEventListener('animationend', () => particle.remove(), { once: true }); card.appendChild(particle); } } function formatLogTime(raw) { const parsed = new Date(raw); if (Number.isNaN(parsed.getTime())) { return raw || '-'; } return parsed.toLocaleString(); } function renderTerminalLogs(logs) { terminalLogOutput.innerHTML = ''; if (!Array.isArray(logs) || logs.length === 0) { const emptyLine = document.createElement('div'); emptyLine.className = 'terminal-log-line'; emptyLine.textContent = '[system] no activity recorded yet'; terminalLogOutput.appendChild(emptyLine); return; } logs.forEach((item) => { const line = document.createElement('div'); line.className = 'terminal-log-line'; line.textContent = `[${formatLogTime(item.at)}] ${item.message}`; terminalLogOutput.appendChild(line); }); terminalLogOutput.scrollTop = terminalLogOutput.scrollHeight; } function openTerminal() { terminalModalOverlay.classList.remove('hidden'); renderTerminalLogs(latestAdminLogs); } function closeTerminal() { terminalModalOverlay.classList.add('hidden'); } function renderState(state) { roomTitle.textContent = `${state.roomName} (${state.roomId})`; revealModeLabel.textContent = `Reveal mode: ${state.revealMode}`; roundStateLabel.textContent = state.revealed ? 'Cards revealed' : 'Cards hidden'; renderParticipants(state.participants, state.revealed); renderCards(state.cards, state.participants, state.revealed); renderSummary(state); const self = state.participants.find((participant) => participant.id === participantID && participant.connected); if (self && self.role) { latestRole = self.role; } latestLinks = state.links || { participantLink: '', adminLink: '' }; updateShareLink(); if (state.viewerIsAdmin) { adminControls.classList.remove('hidden'); terminalBtn.classList.remove('hidden'); } else { adminControls.classList.add('hidden'); terminalBtn.classList.add('hidden'); closeTerminal(); } latestAdminLogs = Array.isArray(state.adminLogs) ? state.adminLogs : []; if (state.viewerIsAdmin && !terminalModalOverlay.classList.contains('hidden')) { renderTerminalLogs(latestAdminLogs); } const votedCount = state.participants.filter((p) => p.connected && p.role === 'participant' && p.hasVoted).length; const totalParticipants = state.participants.filter((p) => p.connected && p.role === 'participant').length; votesCounter.textContent = `Votes: ${votedCount}/${totalParticipants}`; } function updateShareLink() { const useAdmin = shareAdminToggle.checked && latestLinks.adminLink; const raw = useAdmin ? latestLinks.adminLink : latestLinks.participantLink; shareLinkInput.value = raw ? `${window.location.origin}${raw}` : ''; } function connectSSE() { if (eventSource) { eventSource.close(); } eventSource = new EventSource(`/api/rooms/${encodeURIComponent(roomID)}/events?participantId=${encodeURIComponent(participantID)}`); eventSource.addEventListener('state', (event) => { try { const payload = JSON.parse(event.data); renderState(payload); activateRoomView(); setRoomMessage('Connected.'); } catch (_err) { setRoomMessage('Failed to parse room update.'); } }); eventSource.onerror = () => { setRoomMessage('Connection interrupted. Retrying...'); }; } async function castVote(card) { if (!participantID) { return; } try { const response = await fetch(`/api/rooms/${encodeURIComponent(roomID)}/vote`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ participantId: participantID, card }), }); if (!response.ok) { const data = await response.json(); setRoomMessage(data.error || 'Vote rejected.'); } } catch (_err) { setRoomMessage('Network error while casting vote.'); } } async function adminAction(action) { if (!participantID) { return; } try { const response = await fetch(`/api/rooms/${encodeURIComponent(roomID)}/${action}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ participantId: participantID }), }); if (!response.ok) { const data = await response.json(); setRoomMessage(data.error || `Unable to ${action}.`); } } catch (_err) { setRoomMessage('Network error while sending admin action.'); } } async function changeName() { if (!participantID) { return; } const current = joinUsernameInput.value.trim() || localStorage.getItem(USERNAME_KEY) || ''; const next = window.prompt('Enter your new name:', current); if (next === null) { return; } const username = next.trim(); if (!username) { setRoomMessage('Name cannot be empty.'); return; } if (username === current) { return; } try { const result = await joinRoom({ username, role: latestRole || joinRoleInput.value || 'participant', password: joinPasswordInput.value, participantIdOverride: participantID, }); joinUsernameInput.value = result.username; setRoomMessage('Name updated.'); } catch (err) { setRoomMessage(err.message || 'Unable to change name.'); } } revealBtn.addEventListener('click', () => adminAction('reveal')); resetBtn.addEventListener('click', () => adminAction('reset')); terminalBtn.addEventListener('click', openTerminal); terminalCloseBtn.addEventListener('click', closeTerminal); terminalModalOverlay.addEventListener('click', (event) => { if (event.target === terminalModalOverlay) { closeTerminal(); } }); shareAdminToggle.addEventListener('change', updateShareLink); changeNameBtn.addEventListener('click', () => { void changeName(); }); window.addEventListener('keydown', (event) => { if (event.key === 'Escape') { closeTerminal(); } }); joinForm.addEventListener('submit', async (event) => { event.preventDefault(); const username = joinUsernameInput.value.trim(); if (!username) { setJoinError('Username is required.'); return; } adminToken = joinAdminTokenInput.value.trim(); try { const result = await joinRoom({ username, role: joinRoleInput.value, password: joinPasswordInput.value, participantIdOverride: participantID, }); if (result.isAdmin) { const adminRoomURL = `/room/${encodeURIComponent(roomID)}?participantId=${encodeURIComponent(participantID)}&adminToken=${encodeURIComponent(adminToken)}`; window.location.assign(adminRoomURL); return; } connectSSE(); } catch (err) { if (participantID) { participantID = ''; updateURL(); } setJoinError(err.message); } }); async function tryAutoJoinExistingParticipant() { if (!participantID) { return; } const username = joinUsernameInput.value.trim() || prefillUsername || 'host'; try { await joinRoom({ username, role: 'participant', password: joinPasswordInput.value, participantIdOverride: participantID, }); connectSSE(); } catch (_err) { participantID = ''; updateURL(); } } window.addEventListener('pagehide', () => { if (!participantID) { return; } const payload = JSON.stringify({ participantId: participantID }); navigator.sendBeacon(`/api/rooms/${encodeURIComponent(roomID)}/leave`, new Blob([payload], { type: 'application/json' })); }); void tryAutoJoinExistingParticipant();