Vote fixing

This commit is contained in:
Theo Browne
2026-02-22 17:33:52 -08:00
parent 845065ac8f
commit e7d1cb3bf7
3 changed files with 44 additions and 40 deletions

View File

@@ -335,6 +335,24 @@ body {
background: rgba(255, 255, 255, 0.06); background: rgba(255, 255, 255, 0.06);
} }
/* ── My Vote Highlight ──────────────────────────────────────── */
.contestant--my-vote {
background: rgba(255, 255, 255, 0.03);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.08);
}
.my-vote-tag {
font-family: var(--mono);
font-size: 10px;
font-weight: 700;
letter-spacing: 1px;
padding: 3px 8px;
border: 1px solid var(--text-muted);
color: var(--text-dim);
border-radius: 3px;
}
/* ── Viewer Votes ────────────────────────────────────────────── */ /* ── Viewer Votes ────────────────────────────────────────────── */
.viewer-vote-bar { .viewer-vote-bar {
@@ -358,25 +376,6 @@ body {
font-size: 14px; font-size: 14px;
} }
/* ── Vote CTA ────────────────────────────────────────────────── */
.vote-cta {
text-align: center;
font-family: var(--mono);
font-size: 13px;
font-weight: 600;
letter-spacing: 0.5px;
text-transform: uppercase;
color: var(--text-dim);
padding: 10px 0;
margin-bottom: 8px;
animation: vote-cta-pulse 2s ease-in-out infinite;
}
@keyframes vote-cta-pulse {
0%, 100% { opacity: 0.7; }
50% { opacity: 1; }
}
.vote-countdown { .vote-countdown {
color: var(--text); color: var(--text);

View File

@@ -53,7 +53,7 @@ type ViewerCountMessage = {
type: "viewerCount"; type: "viewerCount";
viewerCount: number; viewerCount: number;
}; };
type VotedAckMessage = { type: "votedAck" }; type VotedAckMessage = { type: "votedAck"; votedFor: "A" | "B" };
type ServerMessage = StateMessage | ViewerCountMessage | VotedAckMessage; type ServerMessage = StateMessage | ViewerCountMessage | VotedAckMessage;
// ── Model colors & logos ───────────────────────────────────────────────────── // ── Model colors & logos ─────────────────────────────────────────────────────
@@ -163,6 +163,7 @@ function ContestantCard({
totalViewerVotes, totalViewerVotes,
votable, votable,
onVote, onVote,
isMyVote,
}: { }: {
task: TaskInfo; task: TaskInfo;
voteCount: number; voteCount: number;
@@ -174,6 +175,7 @@ function ContestantCard({
totalViewerVotes?: number; totalViewerVotes?: number;
votable?: boolean; votable?: boolean;
onVote?: () => void; onVote?: () => void;
isMyVote?: boolean;
}) { }) {
const color = getColor(task.model.name); const color = getColor(task.model.name);
const pct = totalVotes > 0 ? Math.round((voteCount / totalVotes) * 100) : 0; const pct = totalVotes > 0 ? Math.round((voteCount / totalVotes) * 100) : 0;
@@ -184,7 +186,7 @@ function ContestantCard({
return ( return (
<div <div
className={`contestant ${isWinner ? "contestant--winner" : ""} ${votable ? "contestant--votable" : ""}`} className={`contestant ${isWinner ? "contestant--winner" : ""} ${votable ? "contestant--votable" : ""} ${isMyVote ? "contestant--my-vote" : ""}`}
style={{ "--accent": color } as React.CSSProperties} style={{ "--accent": color } as React.CSSProperties}
onClick={votable ? onVote : undefined} onClick={votable ? onVote : undefined}
role={votable ? "button" : undefined} role={votable ? "button" : undefined}
@@ -193,6 +195,7 @@ function ContestantCard({
> >
<div className="contestant__head"> <div className="contestant__head">
<ModelTag model={task.model} /> <ModelTag model={task.model} />
{isMyVote && <span className="my-vote-tag">YOUR PICK</span>}
{isWinner && <span className="win-tag">WIN</span>} {isWinner && <span className="win-tag">WIN</span>}
</div> </div>
@@ -277,13 +280,13 @@ function ContestantCard({
function Arena({ function Arena({
round, round,
total, total,
hasVoted, myVote,
onVote, onVote,
viewerVotingSecondsLeft, viewerVotingSecondsLeft,
}: { }: {
round: RoundState; round: RoundState;
total: number | null; total: number | null;
hasVoted: boolean; myVote: "A" | "B" | null;
onVote: (side: "A" | "B") => void; onVote: (side: "A" | "B") => void;
viewerVotingSecondsLeft: number; viewerVotingSecondsLeft: number;
}) { }) {
@@ -304,7 +307,6 @@ function Arena({
const canVote = const canVote =
round.phase === "voting" && round.phase === "voting" &&
!hasVoted &&
viewerVotingSecondsLeft > 0 && viewerVotingSecondsLeft > 0 &&
round.answerTasks[0].finishedAt && round.answerTasks[0].finishedAt &&
round.answerTasks[1].finishedAt; round.answerTasks[1].finishedAt;
@@ -337,12 +339,6 @@ function Arena({
<PromptCard round={round} /> <PromptCard round={round} />
{canVote && (
<div className="vote-cta">
Pick the funnier answer!
</div>
)}
{round.phase !== "prompting" && ( {round.phase !== "prompting" && (
<div className="showdown"> <div className="showdown">
<ContestantCard <ContestantCard
@@ -356,6 +352,7 @@ function Arena({
totalViewerVotes={totalViewerVotes} totalViewerVotes={totalViewerVotes}
votable={!!canVote} votable={!!canVote}
onVote={() => onVote("A")} onVote={() => onVote("A")}
isMyVote={myVote === "A"}
/> />
<ContestantCard <ContestantCard
task={round.answerTasks[1]} task={round.answerTasks[1]}
@@ -368,6 +365,7 @@ function Arena({
totalViewerVotes={totalViewerVotes} totalViewerVotes={totalViewerVotes}
votable={!!canVote} votable={!!canVote}
onVote={() => onVote("B")} onVote={() => onVote("B")}
isMyVote={myVote === "B"}
/> />
</div> </div>
)} )}
@@ -492,16 +490,16 @@ function App() {
const [totalRounds, setTotalRounds] = useState<number | null>(null); const [totalRounds, setTotalRounds] = useState<number | null>(null);
const [viewerCount, setViewerCount] = useState(0); const [viewerCount, setViewerCount] = useState(0);
const [connected, setConnected] = useState(false); const [connected, setConnected] = useState(false);
const [hasVoted, setHasVoted] = useState(false); const [myVote, setMyVote] = useState<"A" | "B" | null>(null);
const [votedRound, setVotedRound] = useState<number | null>(null); const [votedRound, setVotedRound] = useState<number | null>(null);
const [viewerVotingSecondsLeft, setViewerVotingSecondsLeft] = useState(0); const [viewerVotingSecondsLeft, setViewerVotingSecondsLeft] = useState(0);
const wsRef = React.useRef<WebSocket | null>(null); const wsRef = React.useRef<WebSocket | null>(null);
// Reset hasVoted when round changes // Reset vote when round changes
useEffect(() => { useEffect(() => {
const currentRound = state?.active?.num ?? null; const currentRound = state?.active?.num ?? null;
if (currentRound !== null && currentRound !== votedRound) { if (currentRound !== null && currentRound !== votedRound) {
setHasVoted(false); setMyVote(null);
setVotedRound(null); setVotedRound(null);
} }
}, [state?.active?.num, votedRound]); }, [state?.active?.num, votedRound]);
@@ -552,7 +550,7 @@ function App() {
} else if (msg.type === "viewerCount") { } else if (msg.type === "viewerCount") {
setViewerCount(msg.viewerCount); setViewerCount(msg.viewerCount);
} else if (msg.type === "votedAck") { } else if (msg.type === "votedAck") {
setHasVoted(true); setMyVote(msg.votedFor);
} }
}; };
} }
@@ -565,8 +563,9 @@ function App() {
}, []); }, []);
const handleVote = (side: "A" | "B") => { const handleVote = (side: "A" | "B") => {
if (hasVoted || !wsRef.current) return; if (myVote === side || !wsRef.current) return;
wsRef.current.send(JSON.stringify({ type: "vote", votedFor: side })); wsRef.current.send(JSON.stringify({ type: "vote", votedFor: side }));
setMyVote(side);
setVotedRound(state?.active?.num ?? null); setVotedRound(state?.active?.num ?? null);
}; };
@@ -607,7 +606,7 @@ function App() {
<Arena <Arena
round={displayRound} round={displayRound}
total={totalRounds} total={totalRounds}
hasVoted={hasVoted} myVote={myVote}
onVote={handleVote} onVote={handleVote}
viewerVotingSecondsLeft={viewerVotingSecondsLeft} viewerVotingSecondsLeft={viewerVotingSecondsLeft}
/> />

View File

@@ -218,7 +218,7 @@ function setHistoryCache(key: string, body: string, expiresAt: number) {
// ── WebSocket clients ─────────────────────────────────────────────────────── // ── WebSocket clients ───────────────────────────────────────────────────────
const clients = new Set<ServerWebSocket<WsData>>(); const clients = new Set<ServerWebSocket<WsData>>();
const viewerVoters = new Set<ServerWebSocket<WsData>>(); const viewerVoters = new Map<ServerWebSocket<WsData>, "A" | "B">();
let viewerVoteBroadcastTimer: ReturnType<typeof setTimeout> | null = null; let viewerVoteBroadcastTimer: ReturnType<typeof setTimeout> | null = null;
function scheduleViewerVoteBroadcast() { function scheduleViewerVoteBroadcast() {
@@ -611,14 +611,20 @@ const server = Bun.serve<WsData>({
const round = gameState.active; const round = gameState.active;
if (!round || round.phase !== "voting") return; if (!round || round.phase !== "voting") return;
if (!round.viewerVotingEndsAt || Date.now() > round.viewerVotingEndsAt) return; if (!round.viewerVotingEndsAt || Date.now() > round.viewerVotingEndsAt) return;
if (viewerVoters.has(ws)) return;
if (msg.votedFor !== "A" && msg.votedFor !== "B") return; if (msg.votedFor !== "A" && msg.votedFor !== "B") return;
viewerVoters.add(ws); const previousVote = viewerVoters.get(ws);
if (previousVote === msg.votedFor) return; // same vote, ignore
// Undo previous vote if changing
if (previousVote === "A") round.viewerVotesA = Math.max(0, (round.viewerVotesA ?? 0) - 1);
else if (previousVote === "B") round.viewerVotesB = Math.max(0, (round.viewerVotesB ?? 0) - 1);
viewerVoters.set(ws, msg.votedFor);
if (msg.votedFor === "A") round.viewerVotesA = (round.viewerVotesA ?? 0) + 1; if (msg.votedFor === "A") round.viewerVotesA = (round.viewerVotesA ?? 0) + 1;
else round.viewerVotesB = (round.viewerVotesB ?? 0) + 1; else round.viewerVotesB = (round.viewerVotesB ?? 0) + 1;
ws.send(JSON.stringify({ type: "votedAck" })); ws.send(JSON.stringify({ type: "votedAck", votedFor: msg.votedFor }));
scheduleViewerVoteBroadcast(); scheduleViewerVoteBroadcast();
} catch {} } catch {}
}, },