From cb76142b2b1aa51bb5a605960b0ece3e2b9121ef Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Mon, 8 Dec 2025 06:22:27 +0100 Subject: [PATCH] feat: Add veto speaker button when boredom exceeds 66% MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add veto button that appears when collective boredom > 66% - Implement 15-second voting period for veto decisions - Majority vote (50%+) required to pass veto - Add animated UI for veto voting progress - Show veto result (passed/failed) with animations - WebSocket broadcasts veto state to all participants 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- server/index.js | 106 +++++++++++++++++++++- src/App.css | 231 ++++++++++++++++++++++++++++++++++++++++++++++++ src/App.js | 95 +++++++++++++++++++- 3 files changed, 428 insertions(+), 4 deletions(-) diff --git a/server/index.js b/server/index.js index f4ccb75..f413610 100644 --- a/server/index.js +++ b/server/index.js @@ -4,12 +4,16 @@ const crypto = require('crypto'); const PORT = process.env.PORT || 3001; -// Room storage: { roomId: { users: Map, createdAt: Date, name: string } } +// Room storage: { roomId: { users: Map, createdAt: Date, name: string, veto: object } } const rooms = new Map(); // Global room (the default public room with bots) const GLOBAL_ROOM_ID = 'global'; +// Veto threshold - when collective boredom exceeds this, veto becomes available +const VETO_THRESHOLD = 66; +const VETO_DURATION = 15000; // 15 seconds to vote + // Generate short room codes const generateRoomCode = () => { return crypto.randomBytes(3).toString('hex').toUpperCase(); @@ -81,15 +85,102 @@ const getRoomStats = (roomId) => { name: u.name || null })); + // Include veto state + const veto = room.veto ? { + active: true, + initiator: room.veto.initiatorName || 'Someone', + votes: room.veto.votes.size, + needed: Math.ceil(count / 2), + timeLeft: Math.max(0, Math.ceil((room.veto.endTime - Date.now()) / 1000)) + } : null; + return { average, count, individuals, roomName: room.name, - roomId + roomId, + vetoAvailable: average >= VETO_THRESHOLD, + veto }; }; +// Start a veto vote +const startVeto = (roomId, initiatorId, initiatorName) => { + const room = rooms.get(roomId); + if (!room || room.veto) return false; + + const stats = getRoomStats(roomId); + if (stats.average < VETO_THRESHOLD) return false; + + room.veto = { + initiatorId, + initiatorName: initiatorName || 'Someone', + votes: new Set([initiatorId]), // Initiator automatically votes yes + startTime: Date.now(), + endTime: Date.now() + VETO_DURATION + }; + + console.log(`Veto started in room ${roomId} by ${initiatorName}`); + + // Broadcast veto started + broadcastToRoom(roomId); + + // Set timeout to end veto + setTimeout(() => { + endVeto(roomId, false); + }, VETO_DURATION); + + return true; +}; + +// Cast a veto vote +const castVetoVote = (roomId, voterId) => { + const room = rooms.get(roomId); + if (!room || !room.veto) return false; + + room.veto.votes.add(voterId); + + // Check if majority reached + const realUsers = Array.from(room.users.values()).filter(u => !u.isBot && u.ws); + const needed = Math.ceil(realUsers.length / 2); + + if (room.veto.votes.size >= needed) { + endVeto(roomId, true); + return true; + } + + broadcastToRoom(roomId); + return true; +}; + +// End veto voting +const endVeto = (roomId, passed) => { + const room = rooms.get(roomId); + if (!room || !room.veto) return; + + const voteCount = room.veto.votes.size; + room.veto = null; + + // Broadcast result + const message = JSON.stringify({ + type: 'vetoResult', + passed, + votes: voteCount + }); + + room.users.forEach((user) => { + if (user.ws && user.ws.readyState === WebSocket.OPEN) { + user.ws.send(message); + } + }); + + console.log(`Veto in room ${roomId} ${passed ? 'PASSED' : 'failed'} with ${voteCount} votes`); + + // Also broadcast updated stats (veto no longer active) + setTimeout(() => broadcastToRoom(roomId), 100); +}; + // Broadcast to all users in a room const broadcastToRoom = (roomId) => { const room = rooms.get(roomId); @@ -270,6 +361,17 @@ wss.on('connection', (ws, req) => { broadcastToRoom(roomId); } } + + // Veto actions + if (message.type === 'startVeto') { + const user = room.users.get(userId); + const name = user?.name || 'Someone'; + startVeto(roomId, userId, name); + } + + if (message.type === 'vetoVote') { + castVetoVote(roomId, userId); + } } catch (err) { console.error('Invalid message:', err.message); } diff --git a/src/App.css b/src/App.css index 1b4f599..56038ac 100644 --- a/src/App.css +++ b/src/App.css @@ -697,3 +697,234 @@ body { font-size: 1rem; } } + +/* ==================== VETO SPEAKER ==================== */ +.veto-section { + width: 100%; + max-width: 400px; + margin: 1.5rem auto; + padding: 1.5rem; + border-radius: 1rem; + text-align: center; + animation: vetoSlideIn 0.3s ease-out; +} + +@keyframes vetoSlideIn { + from { + opacity: 0; + transform: translateY(20px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +/* Veto Available State */ +.veto-available { + background: linear-gradient(135deg, rgba(251, 146, 60, 0.15), rgba(239, 68, 68, 0.15)); + border: 1px solid rgba(251, 146, 60, 0.3); +} + +.veto-alert { + display: flex; + align-items: center; + justify-content: center; + gap: 0.75rem; + margin-bottom: 1rem; +} + +.veto-icon { + font-size: 1.5rem; + animation: vietoPulse 1s ease-in-out infinite; +} + +@keyframes vietoPulse { + 0%, 100% { transform: scale(1); } + 50% { transform: scale(1.1); } +} + +.veto-message { + font-size: 1rem; + font-weight: 600; + color: #fb923c; +} + +.btn-veto { + background: linear-gradient(135deg, #fb923c, #ef4444); + color: white; + font-size: 1.1rem; + padding: 1rem 2rem; + border: none; + border-radius: 0.75rem; + cursor: pointer; + transition: all 0.2s ease; + animation: vetoPulse 2s ease-in-out infinite; +} + +@keyframes vetoPulse { + 0%, 100% { box-shadow: 0 0 0 0 rgba(251, 146, 60, 0.4); } + 50% { box-shadow: 0 0 20px 5px rgba(251, 146, 60, 0.2); } +} + +.btn-veto:hover { + transform: translateY(-2px) scale(1.02); + box-shadow: 0 8px 25px rgba(251, 146, 60, 0.4); +} + +/* Veto Active (Voting) State */ +.veto-active { + background: linear-gradient(135deg, rgba(139, 92, 246, 0.2), rgba(236, 72, 153, 0.2)); + border: 2px solid rgba(139, 92, 246, 0.5); + animation: vetoSlideIn 0.3s ease-out, vetoBorder 1s ease-in-out infinite; +} + +@keyframes vetoBorder { + 0%, 100% { border-color: rgba(139, 92, 246, 0.5); } + 50% { border-color: rgba(236, 72, 153, 0.5); } +} + +.veto-voting { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.veto-title { + font-size: 1.3rem; + font-weight: 700; + color: #e4e4e7; + margin: 0; +} + +.veto-initiator { + font-size: 0.9rem; + color: #a1a1aa; + margin: 0; +} + +.veto-progress { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.veto-bar { + height: 12px; + background: rgba(0, 0, 0, 0.3); + border-radius: 6px; + overflow: hidden; +} + +.veto-bar-fill { + height: 100%; + background: linear-gradient(90deg, #a78bfa, #ec4899); + border-radius: 6px; + transition: width 0.3s ease; +} + +.veto-count { + font-size: 0.85rem; + color: #c4b5fd; + font-weight: 500; +} + +.veto-timer { + font-size: 1.2rem; + font-weight: 700; + color: #fbbf24; + animation: timerPulse 1s ease-in-out infinite; +} + +@keyframes timerPulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.7; } +} + +.btn-vote { + background: linear-gradient(135deg, #a78bfa, #ec4899); + color: white; + font-size: 1.1rem; + padding: 1rem 2rem; + border: none; + border-radius: 0.75rem; + cursor: pointer; + transition: all 0.2s ease; + margin-top: 0.5rem; +} + +.btn-vote:hover { + transform: scale(1.05); + box-shadow: 0 8px 25px rgba(167, 139, 250, 0.4); +} + +/* Veto Result States */ +.veto-result { + padding: 2rem; +} + +.veto-result-content { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.5rem; +} + +.veto-result-icon { + font-size: 3rem; + animation: resultBounce 0.5s ease-out; +} + +@keyframes resultBounce { + 0% { transform: scale(0); } + 50% { transform: scale(1.2); } + 100% { transform: scale(1); } +} + +.veto-result h3 { + font-size: 1.5rem; + font-weight: 700; + margin: 0; +} + +.veto-result p { + font-size: 0.9rem; + color: #a1a1aa; + margin: 0; +} + +.veto-passed { + background: linear-gradient(135deg, rgba(239, 68, 68, 0.2), rgba(220, 38, 38, 0.3)); + border: 2px solid rgba(239, 68, 68, 0.5); +} + +.veto-passed h3 { + color: #f87171; +} + +.veto-failed { + background: linear-gradient(135deg, rgba(34, 197, 94, 0.15), rgba(22, 163, 74, 0.2)); + border: 2px solid rgba(34, 197, 94, 0.4); +} + +.veto-failed h3 { + color: #4ade80; +} + +/* Mobile adjustments for veto */ +@media (max-width: 500px) { + .veto-section { + margin: 1rem auto; + padding: 1rem; + } + + .veto-title { + font-size: 1.1rem; + } + + .btn-veto, + .btn-vote { + padding: 0.875rem 1.5rem; + font-size: 1rem; + } +} diff --git a/src/App.js b/src/App.js index 17dd151..b16f5b8 100644 --- a/src/App.js +++ b/src/App.js @@ -27,6 +27,9 @@ const useWebSocket = (roomId = 'global', userName = null) => { const [userCount, setUserCount] = useState(0); const [individuals, setIndividuals] = useState([]); const [error, setError] = useState(null); + const [vetoAvailable, setVetoAvailable] = useState(false); + const [veto, setVeto] = useState(null); + const [vetoResult, setVetoResult] = useState(null); const wsRef = useRef(null); const reconnectTimeoutRef = useRef(null); @@ -56,10 +59,19 @@ const useWebSocket = (roomId = 'global', userName = null) => { setGlobalBoredom(data.average || 50); setUserCount(data.count || 0); setIndividuals(data.individuals || []); + setVetoAvailable(data.vetoAvailable || false); + setVeto(data.veto || null); } else if (data.type === 'stats') { setGlobalBoredom(data.average || 50); setUserCount(data.count || 0); setIndividuals(data.individuals || []); + setVetoAvailable(data.vetoAvailable || false); + setVeto(data.veto || null); + } else if (data.type === 'vetoResult') { + setVetoResult({ passed: data.passed, votes: data.votes }); + setVeto(null); + // Clear result after 5 seconds + setTimeout(() => setVetoResult(null), 5000); } } catch (err) { console.error('Failed to parse message:', err); @@ -114,6 +126,22 @@ const useWebSocket = (roomId = 'global', userName = null) => { } }, []); + const startVeto = useCallback(() => { + if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) { + wsRef.current.send(JSON.stringify({ + type: 'startVeto' + })); + } + }, []); + + const voteVeto = useCallback(() => { + if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) { + wsRef.current.send(JSON.stringify({ + type: 'vetoVote' + })); + } + }, []); + return { isConnected, userId, @@ -123,7 +151,12 @@ const useWebSocket = (roomId = 'global', userName = null) => { individuals, error, sendBoredom, - sendName + sendName, + vetoAvailable, + veto, + vetoResult, + startVeto, + voteVeto }; }; @@ -310,7 +343,10 @@ function RoomPage() { const navigate = useNavigate(); const [myBoredom, setMyBoredom] = useState(50); const [showShare, setShowShare] = useState(false); - const { isConnected, userId, roomName, globalBoredom, userCount, individuals, error, sendBoredom } = useWebSocket(roomId); + const { + isConnected, userId, roomName, globalBoredom, userCount, individuals, error, sendBoredom, + vetoAvailable, veto, vetoResult, startVeto, voteVeto + } = useWebSocket(roomId); const handleBoredomChange = useCallback((value) => { setMyBoredom(value); @@ -453,6 +489,61 @@ function RoomPage() { ))} + + {/* Veto Speaker Section */} + {vetoAvailable && !veto && !vetoResult && ( +
+
+ ⚠️ + Collective boredom exceeds 66%! +
+ +
+ )} + + {veto && ( +
+
+

🗳️ Veto in Progress!

+

{veto.initiator} called for a vote

+
+
+
+
+ {veto.votes} / {veto.needed} votes needed +
+
⏱️ {veto.timeLeft}s remaining
+ +
+
+ )} + + {vetoResult && ( +
+
+ {vetoResult.passed ? ( + <> + 🚫 +

Speaker Vetoed!

+

The group has spoken. Time for a change!

+ + ) : ( + <> + +

Veto Failed

+

Not enough votes. The speaker continues.

+ + )} +
+
+ )}