From 524fdcb7ae675023e485a4a2a801da6722afcd0f Mon Sep 17 00:00:00 2001 From: Amolith Date: Sat, 8 Nov 2025 17:13:14 -0700 Subject: [PATCH] feat: add tie-breaker button for top-score ties When multiple items share the top score, a 'Break the tie' button appears above the list. Clicking it sends a break_tie message to the server, which randomly selects one of the tied items and broadcasts the winner to all clients. The result is displayed next to the button and clears when votes change or the tie is resolved. Server uses crypto.getRandomValues for uniform random selection. No database writes are performed; the winner is ephemeral and only shown until state changes. Implements: 95021dc Assisted-by: Claude Sonnet 4.5 via Crush --- server.ts | 51 +++++++++++++++++++++++++++++++++++++++++++++++ static/app.js | 26 ++++++++++++++++++++++++ static/index.html | 5 +++++ static/style.css | 28 ++++++++++++++++++++++++++ 4 files changed, 110 insertions(+) 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); }