feat: add viewer voting on user answers with leaderboard scoring

Viewers can now vote for their favourite audience answers during the
30-second voting window. Votes are persisted to the DB at round end
and aggregated as SUM(votes) in the JUGADORES leaderboard.

- db.ts: add persistUserAnswerVotes(); switch getPlayerScores() to SUM(votes)
- game.ts: add userAnswerVotes to RoundState; persist votes before saveRound
- server.ts: add userAnswerVoters map + /api/vote/respuesta endpoint
- frontend.tsx: add userAnswerVotes type; vote state/handler in App; ▲ buttons in Arena
- frontend.css: flex layout for user-answer rows; user-vote-btn styles

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-05 18:26:09 +01:00
parent fe5bb5a5c2
commit 40c919fc64
5 changed files with 182 additions and 12 deletions

24
db.ts
View File

@@ -95,16 +95,28 @@ export function markQuestionUsed(id: number): void {
db.prepare("UPDATE questions SET status = 'used' WHERE id = $id").run({ $id: id }); db.prepare("UPDATE questions SET status = 'used' WHERE id = $id").run({ $id: id });
} }
/** Top 7 players by number of answers submitted, excluding anonymous. */ /** Top 7 players by total votes received on their answers, excluding anonymous. */
export function getPlayerScores(): Record<string, number> { export function getPlayerScores(): Record<string, number> {
const rows = db const rows = db
.query( .query(
"SELECT username, COUNT(*) as score FROM user_answers WHERE username != '' GROUP BY username ORDER BY score DESC LIMIT 7" "SELECT username, SUM(votes) as score FROM user_answers WHERE username != '' GROUP BY username ORDER BY score DESC LIMIT 7"
) )
.all() as { username: string; score: number }[]; .all() as { username: string; score: number }[];
return Object.fromEntries(rows.map(r => [r.username, r.score])); return Object.fromEntries(rows.map(r => [r.username, r.score]));
} }
/** Persist accumulated vote counts for user answers in a given round. */
export function persistUserAnswerVotes(roundNum: number, votes: Record<string, number>): void {
const stmt = db.prepare(
"UPDATE user_answers SET votes = $votes WHERE round_num = $roundNum AND username = $username"
);
db.transaction(() => {
for (const [username, voteCount] of Object.entries(votes)) {
stmt.run({ $votes: voteCount, $roundNum: roundNum, $username: username });
}
})();
}
// ── User answers (submitted during live rounds) ────────────────────────────── // ── User answers (submitted during live rounds) ──────────────────────────────
db.exec(` db.exec(`
@@ -114,10 +126,18 @@ db.exec(`
text TEXT NOT NULL, text TEXT NOT NULL,
username TEXT NOT NULL, username TEXT NOT NULL,
token TEXT NOT NULL, token TEXT NOT NULL,
votes INTEGER NOT NULL DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP created_at DATETIME DEFAULT CURRENT_TIMESTAMP
); );
`); `);
// Migration: add votes column to pre-existing user_answers tables
try {
db.exec("ALTER TABLE user_answers ADD COLUMN votes INTEGER NOT NULL DEFAULT 0");
} catch {
// Column already exists — no-op
}
// ── Credits (answer-count-based access) ────────────────────────────────────── // ── Credits (answer-count-based access) ──────────────────────────────────────
db.exec(` db.exec(`

View File

@@ -796,6 +796,53 @@ body {
.user-answer { .user-answer {
font-size: 13px; font-size: 13px;
line-height: 1.4; line-height: 1.4;
display: flex;
align-items: center;
gap: 8px;
justify-content: space-between;
}
.user-answer__main {
flex: 1;
min-width: 0;
}
.user-answer__vote {
display: flex;
align-items: center;
gap: 5px;
flex-shrink: 0;
}
.user-vote-btn {
background: none;
border: 1px solid var(--border);
border-radius: 4px;
color: var(--text-muted);
font-size: 10px;
padding: 2px 7px;
cursor: pointer;
transition: border-color 0.15s, color 0.15s;
line-height: 1.4;
}
.user-vote-btn:hover {
border-color: #444;
color: var(--text-dim);
}
.user-vote-btn--active {
border-color: var(--accent);
color: var(--accent);
}
.user-vote-count {
font-family: var(--mono);
font-size: 11px;
font-weight: 700;
color: var(--text-muted);
min-width: 14px;
text-align: right;
} }
.user-answer__name { .user-answer__name {

View File

@@ -34,6 +34,7 @@ type RoundState = {
viewerVotesB?: number; viewerVotesB?: number;
viewerVotingEndsAt?: number; viewerVotingEndsAt?: number;
userAnswers?: { username: string; text: string }[]; userAnswers?: { username: string; text: string }[];
userAnswerVotes?: Record<string, number>;
}; };
type GameState = { type GameState = {
lastCompleted: RoundState | null; lastCompleted: RoundState | null;
@@ -337,12 +338,16 @@ function Arena({
viewerVotingSecondsLeft, viewerVotingSecondsLeft,
myVote, myVote,
onVote, onVote,
myUserAnswerVote,
onUserAnswerVote,
}: { }: {
round: RoundState; round: RoundState;
total: number | null; total: number | null;
viewerVotingSecondsLeft: number; viewerVotingSecondsLeft: number;
myVote: "A" | "B" | null; myVote: "A" | "B" | null;
onVote: (side: "A" | "B") => void; onVote: (side: "A" | "B") => void;
myUserAnswerVote: string | null;
onUserAnswerVote: (username: string) => void;
}) { }) {
const [contA, contB] = round.contestants; const [contA, contB] = round.contestants;
const showVotes = round.phase === "voting" || round.phase === "done"; const showVotes = round.phase === "voting" || round.phase === "done";
@@ -439,13 +444,35 @@ function Arena({
<div className="user-answers"> <div className="user-answers">
<div className="user-answers__label">Respuestas del público</div> <div className="user-answers__label">Respuestas del público</div>
<div className="user-answers__list"> <div className="user-answers__list">
{round.userAnswers.map((a, i) => ( {round.userAnswers.map((a, i) => {
const voteCount = round.userAnswerVotes?.[a.username] ?? 0;
const isMyVote = myUserAnswerVote === a.username;
return (
<div key={i} className="user-answer"> <div key={i} className="user-answer">
<div className="user-answer__main">
<span className="user-answer__name">{a.username}</span> <span className="user-answer__name">{a.username}</span>
<span className="user-answer__sep"> </span> <span className="user-answer__sep"> </span>
<span className="user-answer__text">&ldquo;{a.text}&rdquo;</span> <span className="user-answer__text">&ldquo;{a.text}&rdquo;</span>
</div> </div>
))} {(showCountdown || voteCount > 0) && (
<div className="user-answer__vote">
{showCountdown && (
<button
className={`user-vote-btn ${isMyVote ? "user-vote-btn--active" : ""}`}
onClick={() => onUserAnswerVote(a.username)}
title="Votar"
>
</button>
)}
{voteCount > 0 && (
<span className="user-vote-count">{voteCount}</span>
)}
</div>
)}
</div>
);
})}
</div> </div>
</div> </div>
)} )}
@@ -950,6 +977,7 @@ function App() {
const [connected, setConnected] = useState(false); const [connected, setConnected] = useState(false);
const [viewerVotingSecondsLeft, setViewerVotingSecondsLeft] = useState(0); const [viewerVotingSecondsLeft, setViewerVotingSecondsLeft] = useState(0);
const [myVote, setMyVote] = useState<"A" | "B" | null>(null); const [myVote, setMyVote] = useState<"A" | "B" | null>(null);
const [myUserAnswerVote, setMyUserAnswerVote] = useState<string | null>(null);
const lastVotedRoundRef = useRef<number | null>(null); const lastVotedRoundRef = useRef<number | null>(null);
const [playerScores, setPlayerScores] = useState<Record<string, number>>({}); const [playerScores, setPlayerScores] = useState<Record<string, number>>({});
@@ -984,11 +1012,12 @@ function App() {
return () => clearInterval(interval); return () => clearInterval(interval);
}, [state?.active?.viewerVotingEndsAt, state?.active?.phase]); }, [state?.active?.viewerVotingEndsAt, state?.active?.phase]);
// Reset my vote when a new round starts // Reset my votes when a new round starts
useEffect(() => { useEffect(() => {
const roundNum = state?.active?.num ?? null; const roundNum = state?.active?.num ?? null;
if (roundNum !== null && roundNum !== lastVotedRoundRef.current) { if (roundNum !== null && roundNum !== lastVotedRoundRef.current) {
setMyVote(null); setMyVote(null);
setMyUserAnswerVote(null);
lastVotedRoundRef.current = roundNum; lastVotedRoundRef.current = roundNum;
} }
}, [state?.active?.num]); }, [state?.active?.num]);
@@ -1006,6 +1035,19 @@ function App() {
} }
} }
async function handleUserAnswerVote(username: string) {
setMyUserAnswerVote(username);
try {
await fetch("/api/vote/respuesta", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username }),
});
} catch {
// ignore network errors
}
}
useEffect(() => { useEffect(() => {
const wsProtocol = window.location.protocol === "https:" ? "wss:" : "ws:"; const wsProtocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const wsUrl = `${wsProtocol}//${window.location.host}/ws`; const wsUrl = `${wsProtocol}//${window.location.host}/ws`;
@@ -1083,6 +1125,8 @@ function App() {
viewerVotingSecondsLeft={viewerVotingSecondsLeft} viewerVotingSecondsLeft={viewerVotingSecondsLeft}
myVote={myVote} myVote={myVote}
onVote={handleVote} onVote={handleVote}
myUserAnswerVote={myUserAnswerVote}
onUserAnswerVote={handleUserAnswerVote}
/> />
) : ( ) : (
<div className="waiting"> <div className="waiting">

View File

@@ -68,6 +68,7 @@ export type RoundState = {
viewerVotesB?: number; viewerVotesB?: number;
viewerVotingEndsAt?: number; viewerVotingEndsAt?: number;
userAnswers?: { username: string; text: string }[]; userAnswers?: { username: string; text: string }[];
userAnswerVotes?: Record<string, number>;
}; };
export type GameState = { export type GameState = {
@@ -266,7 +267,7 @@ export async function callVote(
return cleaned.startsWith("A") ? "A" : "B"; return cleaned.startsWith("A") ? "A" : "B";
} }
import { saveRound, getNextPendingQuestion, markQuestionUsed } from "./db.ts"; import { saveRound, getNextPendingQuestion, markQuestionUsed, persistUserAnswerVotes } from "./db.ts";
// ── Game loop ─────────────────────────────────────────────────────────────── // ── Game loop ───────────────────────────────────────────────────────────────
@@ -498,6 +499,11 @@ export async function runGame(
continue; continue;
} }
// Persist votes for user answers
if (round.userAnswerVotes && round.userAnswers && round.userAnswers.length > 0) {
persistUserAnswerVotes(round.num, round.userAnswerVotes);
}
// Archive round // Archive round
saveRound(round); saveRound(round);
state.completed = [...state.completed, round]; state.completed = [...state.completed, round];

View File

@@ -9,7 +9,7 @@ import {
clearAllRounds, getRounds, getAllRounds, clearAllRounds, getRounds, getAllRounds,
createPendingCredit, activateCredit, getCreditByOrder, createPendingCredit, activateCredit, getCreditByOrder,
submitUserAnswer, insertAdminAnswer, submitUserAnswer, insertAdminAnswer,
getPlayerScores, getPlayerScores, persistUserAnswerVotes,
} from "./db.ts"; } from "./db.ts";
import { buildPaymentForm, verifyNotification, decodeParams, isPaymentApproved } from "./redsys.ts"; import { buildPaymentForm, verifyNotification, decodeParams, isPaymentApproved } from "./redsys.ts";
import { import {
@@ -336,6 +336,7 @@ function cancelAutoPause() {
const clients = new Set<ServerWebSocket<WsData>>(); const clients = new Set<ServerWebSocket<WsData>>();
const viewerVoters = new Map<string, "A" | "B">(); const viewerVoters = new Map<string, "A" | "B">();
const userAnswerVoters = new Map<string, string>(); // voterIp → voted-for username
let viewerVoteBroadcastTimer: ReturnType<typeof setTimeout> | null = null; let viewerVoteBroadcastTimer: ReturnType<typeof setTimeout> | null = null;
function scheduleViewerVoteBroadcast() { function scheduleViewerVoteBroadcast() {
@@ -463,6 +464,57 @@ const server = Bun.serve<WsData>({
}); });
} }
if (url.pathname === "/api/vote/respuesta") {
if (req.method !== "POST") {
return new Response("Method Not Allowed", {
status: 405,
headers: { Allow: "POST" },
});
}
let username = "";
try {
const body = await req.json();
username = String((body as Record<string, unknown>).username ?? "").trim();
} catch {
return new Response("Invalid JSON body", { status: 400 });
}
const round = gameState.active;
const votingOpen =
round?.phase === "voting" &&
round.viewerVotingEndsAt &&
Date.now() <= round.viewerVotingEndsAt;
if (!votingOpen || !round) {
return new Response(JSON.stringify({ ok: false, reason: "voting closed" }), {
headers: { "Content-Type": "application/json", "Cache-Control": "no-store" },
});
}
const answers = round.userAnswers ?? [];
if (!answers.some((a) => a.username === username)) {
return new Response("Unknown answer", { status: 400 });
}
const prevVote = userAnswerVoters.get(ip);
if (prevVote !== username) {
// Undo previous vote
if (prevVote) {
round.userAnswerVotes = { ...(round.userAnswerVotes ?? {}) };
round.userAnswerVotes[prevVote] = Math.max(0, (round.userAnswerVotes[prevVote] ?? 0) - 1);
}
userAnswerVoters.set(ip, username);
round.userAnswerVotes = { ...(round.userAnswerVotes ?? {}) };
round.userAnswerVotes[username] = (round.userAnswerVotes[username] ?? 0) + 1;
scheduleViewerVoteBroadcast();
}
return new Response(JSON.stringify({ ok: true }), {
headers: { "Content-Type": "application/json", "Cache-Control": "no-store" },
});
}
if (url.pathname === "/api/pregunta/iniciar") { if (url.pathname === "/api/pregunta/iniciar") {
if (req.method !== "POST") { if (req.method !== "POST") {
return new Response("Method Not Allowed", { return new Response("Method Not Allowed", {
@@ -1121,6 +1173,7 @@ log("INFO", "server", `Web server started on port ${server.port}`, {
runGame(runs, gameState, broadcast, () => { runGame(runs, gameState, broadcast, () => {
viewerVoters.clear(); viewerVoters.clear();
userAnswerVoters.clear();
}).then(() => { }).then(() => {
console.log(`\n✅ ¡Juego completado! Registro: ${LOG_FILE}`); console.log(`\n✅ ¡Juego completado! Registro: ${LOG_FILE}`);
}); });