feat: add tie-breaker button for top-score ties

Amolith created

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

Change summary

server.ts         | 51 +++++++++++++++++++++++++++++++++++++++++++++++++
static/app.js     | 26 ++++++++++++++++++++++++
static/index.html |  5 ++++
static/style.css  | 28 ++++++++++++++++++++++++++
4 files changed, 110 insertions(+)

Detailed changes

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);

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) => 

static/index.html πŸ”—

@@ -56,6 +56,11 @@ SPDX-License-Identifier: AGPL-3.0-or-later
         <button id="add-bulk-btn">Add all</button>
       </div>
 
+      <div id="tie-breaker" class="hidden">
+        <button id="break-tie-btn">Break the tie</button>
+        <span id="tie-break-result"></span>
+      </div>
+
       <div id="list-container"></div>
       <a href="https://git.secluded.site/sift" class="source-link" target="_blank" rel="noopener noreferrer">source code</a>
     </div>

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);
   }