diff --git a/db.ts b/db.ts index 01300b0..b387d33 100644 --- a/db.ts +++ b/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 { 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): 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(` diff --git a/frontend.css b/frontend.css index 0a86b42..79ab214 100644 --- a/frontend.css +++ b/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 { diff --git a/frontend.tsx b/frontend.tsx index 00f3b23..94f96ce 100644 --- a/frontend.tsx +++ b/frontend.tsx @@ -34,6 +34,7 @@ type RoundState = { viewerVotesB?: number; viewerVotingEndsAt?: number; userAnswers?: { username: string; text: string }[]; + userAnswerVotes?: Record; }; 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({
Respuestas del público
- {round.userAnswers.map((a, i) => ( -
- {a.username} - - “{a.text}” -
- ))} + {round.userAnswers.map((a, i) => { + const voteCount = round.userAnswerVotes?.[a.username] ?? 0; + const isMyVote = myUserAnswerVote === a.username; + return ( +
+
+ {a.username} + + “{a.text}” +
+ {(showCountdown || voteCount > 0) && ( +
+ {showCountdown && ( + + )} + {voteCount > 0 && ( + {voteCount} + )} +
+ )} +
+ ); + })}
)} @@ -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(null); const lastVotedRoundRef = useRef(null); const [playerScores, setPlayerScores] = useState>({}); @@ -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} /> ) : (
diff --git a/game.ts b/game.ts index 1e311b2..b20594b 100644 --- a/game.ts +++ b/game.ts @@ -68,6 +68,7 @@ export type RoundState = { viewerVotesB?: number; viewerVotingEndsAt?: number; userAnswers?: { username: string; text: string }[]; + userAnswerVotes?: Record; }; 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]; diff --git a/server.ts b/server.ts index ccb413e..61c1055 100644 --- a/server.ts +++ b/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>(); const viewerVoters = new Map(); +const userAnswerVoters = new Map(); // voterIp → voted-for username let viewerVoteBroadcastTimer: ReturnType | null = null; function scheduleViewerVoteBroadcast() { @@ -463,6 +464,57 @@ const server = Bun.serve({ }); } + 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).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}`); });