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:
24
db.ts
24
db.ts
@@ -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(`
|
||||
|
||||
47
frontend.css
47
frontend.css
@@ -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 {
|
||||
|
||||
60
frontend.tsx
60
frontend.tsx
@@ -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">“{a.text}”</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">“{a.text}”</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">
|
||||
|
||||
8
game.ts
8
game.ts
@@ -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];
|
||||
|
||||
55
server.ts
55
server.ts
@@ -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}`);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user