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 });
}
/** 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> {
const rows = db
.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 }[];
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) ──────────────────────────────
db.exec(`
@@ -114,10 +126,18 @@ db.exec(`
text TEXT NOT NULL,
username TEXT NOT NULL,
token TEXT NOT NULL,
votes INTEGER NOT NULL DEFAULT 0,
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) ──────────────────────────────────────
db.exec(`

View File

@@ -796,6 +796,53 @@ body {
.user-answer {
font-size: 13px;
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 {

View File

@@ -34,6 +34,7 @@ type RoundState = {
viewerVotesB?: number;
viewerVotingEndsAt?: number;
userAnswers?: { username: string; text: string }[];
userAnswerVotes?: Record<string, number>;
};
type GameState = {
lastCompleted: RoundState | null;
@@ -337,12 +338,16 @@ function Arena({
viewerVotingSecondsLeft,
myVote,
onVote,
myUserAnswerVote,
onUserAnswerVote,
}: {
round: RoundState;
total: number | null;
viewerVotingSecondsLeft: number;
myVote: "A" | "B" | null;
onVote: (side: "A" | "B") => void;
myUserAnswerVote: string | null;
onUserAnswerVote: (username: string) => void;
}) {
const [contA, contB] = round.contestants;
const showVotes = round.phase === "voting" || round.phase === "done";
@@ -439,13 +444,35 @@ function Arena({
<div className="user-answers">
<div className="user-answers__label">Respuestas del público</div>
<div className="user-answers__list">
{round.userAnswers.map((a, i) => (
<div key={i} className="user-answer">
<span className="user-answer__name">{a.username}</span>
<span className="user-answer__sep"> </span>
<span className="user-answer__text">&ldquo;{a.text}&rdquo;</span>
</div>
))}
{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 className="user-answer__main">
<span className="user-answer__name">{a.username}</span>
<span className="user-answer__sep"> </span>
<span className="user-answer__text">&ldquo;{a.text}&rdquo;</span>
</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>
)}
@@ -950,6 +977,7 @@ function App() {
const [connected, setConnected] = useState(false);
const [viewerVotingSecondsLeft, setViewerVotingSecondsLeft] = useState(0);
const [myVote, setMyVote] = useState<"A" | "B" | null>(null);
const [myUserAnswerVote, setMyUserAnswerVote] = useState<string | null>(null);
const lastVotedRoundRef = useRef<number | null>(null);
const [playerScores, setPlayerScores] = useState<Record<string, number>>({});
@@ -984,11 +1012,12 @@ function App() {
return () => clearInterval(interval);
}, [state?.active?.viewerVotingEndsAt, state?.active?.phase]);
// Reset my vote when a new round starts
// Reset my votes when a new round starts
useEffect(() => {
const roundNum = state?.active?.num ?? null;
if (roundNum !== null && roundNum !== lastVotedRoundRef.current) {
setMyVote(null);
setMyUserAnswerVote(null);
lastVotedRoundRef.current = roundNum;
}
}, [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(() => {
const wsProtocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const wsUrl = `${wsProtocol}//${window.location.host}/ws`;
@@ -1083,6 +1125,8 @@ function App() {
viewerVotingSecondsLeft={viewerVotingSecondsLeft}
myVote={myVote}
onVote={handleVote}
myUserAnswerVote={myUserAnswerVote}
onUserAnswerVote={handleUserAnswerVote}
/>
) : (
<div className="waiting">

View File

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

View File

@@ -9,7 +9,7 @@ import {
clearAllRounds, getRounds, getAllRounds,
createPendingCredit, activateCredit, getCreditByOrder,
submitUserAnswer, insertAdminAnswer,
getPlayerScores,
getPlayerScores, persistUserAnswerVotes,
} from "./db.ts";
import { buildPaymentForm, verifyNotification, decodeParams, isPaymentApproved } from "./redsys.ts";
import {
@@ -336,6 +336,7 @@ function cancelAutoPause() {
const clients = new Set<ServerWebSocket<WsData>>();
const viewerVoters = new Map<string, "A" | "B">();
const userAnswerVoters = new Map<string, string>(); // voterIp → voted-for username
let viewerVoteBroadcastTimer: ReturnType<typeof setTimeout> | null = null;
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 (req.method !== "POST") {
return new Response("Method Not Allowed", {
@@ -1121,6 +1173,7 @@ log("INFO", "server", `Web server started on port ${server.port}`, {
runGame(runs, gameState, broadcast, () => {
viewerVoters.clear();
userAnswerVoters.clear();
}).then(() => {
console.log(`\n✅ ¡Juego completado! Registro: ${LOG_FILE}`);
});