Detailed changes
@@ -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);
@@ -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) =>
@@ -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>
@@ -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);
}