diff --git a/server.ts b/server.ts index 39639e64651d927f35ccbd200eb0c3834a44bd68..db666a0bd61b5042af1cb49f91b085371f742af2 100644 --- a/server.ts +++ b/server.ts @@ -513,6 +513,57 @@ function handleWebSocket(ws: WebSocket, roomCode: string, userId: string) { }); break; } + + case 'break_tie': { + // Get all items in the room with their votes + const items = db.prepare(` + SELECT i.id, i.text, + GROUP_CONCAT(v.vote_type) as votes + FROM items i + LEFT JOIN votes v ON i.id = v.item_id + WHERE i.room_code = ? + GROUP BY i.id + `).all(roomCode) as Array<{id: string, text: string, votes: string | null}>; + + // Filter to non-vetoed items and calculate scores + const nonVetoed = items + .filter(item => { + const votes = item.votes ? item.votes.split(',') : []; + return !votes.includes('veto'); + }) + .map(item => { + const votes = item.votes ? item.votes.split(',') : []; + const score = votes.reduce((sum, v) => + sum + (v === 'up' ? 1 : v === 'down' ? -1 : 0), 0); + return { id: item.id, text: item.text, score }; + }); + + if (nonVetoed.length === 0) { + console.warn('break_tie: no non-vetoed items'); + return; + } + + // Find top score + const topScore = Math.max(...nonVetoed.map(item => item.score)); + const tiedItems = nonVetoed.filter(item => item.score === topScore); + + // Only break tie if there are multiple items at top score + if (tiedItems.length <= 1) { + console.warn('break_tie: no tie exists'); + return; + } + + // Randomly select one using crypto.getRandomValues for uniform distribution + const randomIndex = crypto.getRandomValues(new Uint32Array(1))[0] % tiedItems.length; + const chosen = tiedItems[randomIndex]; + + broadcast(roomCode, { + type: 'tie_broken', + itemId: chosen.id, + text: chosen.text + }); + break; + } } } catch (err) { console.error('Message handling error:', err); diff --git a/static/app.js b/static/app.js index a4732da6d60fee072cfc2bd35bf0be06bf834741..f3fd79afa9d10df5cb391b4a12f9da4866dc85e5 100644 --- a/static/app.js +++ b/static/app.js @@ -21,6 +21,9 @@ const startScreen = document.getElementById('start-screen'); const listScreen = document.getElementById('list-screen'); const roomTitleEl = document.getElementById('room-title'); const listContainer = document.getElementById('list-container'); +const tieBreakerEl = document.getElementById('tie-breaker'); +const breakTieBtn = document.getElementById('break-tie-btn'); +const tieBreakResultEl = document.getElementById('tie-break-result'); // Start screen handlers document.getElementById('create-btn').addEventListener('click', async () => { @@ -128,6 +131,10 @@ document.getElementById('set-title-btn').addEventListener('click', () => { input.select(); }); +breakTieBtn.addEventListener('click', () => { + sendMessage({ type: 'break_tie' }); +}); + // Add items handlers document.getElementById('add-single-btn').addEventListener('click', () => { const input = document.getElementById('single-input'); @@ -302,6 +309,9 @@ function handleMessage(msg) { roomTitle = msg.title; renderTitle(); break; + case 'tie_broken': + tieBreakResultEl.textContent = `Winner: ${msg.text}`; + break; } } @@ -598,6 +608,22 @@ function render() { )) : -Infinity; + // Check for tie at the top + const topItems = nonVetoedItems.filter(item => { + const score = Object.values(item.votes).reduce((sum, v) => + sum + (v === 'up' ? 1 : v === 'down' ? -1 : 0), 0); + return score === highestScore; + }); + const hasTie = hasAnyVotes && topItems.length > 1; + + // Show/hide tie-breaker UI and clear result when state changes + if (hasTie) { + tieBreakerEl.classList.remove('hidden'); + } else { + tieBreakerEl.classList.add('hidden'); + tieBreakResultEl.textContent = ''; + } + listContainer.innerHTML = sorted.map(item => { const myVote = item.votes[userId]; const score = Object.values(item.votes).reduce((sum, v) => diff --git a/static/index.html b/static/index.html index 8a7a5e1f0ad363e0f3a1cdf2128fb9b1eefc3378..5d333154dafd933be7246f659bc10dca471db08f 100644 --- a/static/index.html +++ b/static/index.html @@ -56,6 +56,11 @@ SPDX-License-Identifier: AGPL-3.0-or-later + +
source code diff --git a/static/style.css b/static/style.css index 951cac5af0b4f5f995e839c572c799bf250f6b51..e86c19e24870d766f2276027595785729a8eab50 100644 --- a/static/style.css +++ b/static/style.css @@ -273,6 +273,26 @@ textarea::placeholder { resize: vertical; } +/* Tie breaker */ +#tie-breaker { + display: flex; + align-items: center; + gap: 1rem; + margin-bottom: 1.5rem; + padding: 1rem; + background: var(--stone-2); + border-radius: 8px; +} + +#break-tie-btn { + flex-shrink: 0; +} + +#tie-break-result { + font-weight: 500; + color: var(--brand-color); +} + /* List items */ #list-container.disabled { pointer-events: none; @@ -615,6 +635,14 @@ kbd { box-shadow: 0 1px 3px color-mix(in oklch, var(--shadow-color) 30%, transparent); } + #tie-breaker { + background: var(--background-dark-2); + } + + #tie-break-result { + color: var(--avocado-8); + } + .list-item:hover { box-shadow: 0 4px 12px color-mix(in oklch, var(--shadow-color) 45%, transparent); }