Files
game-scoreboard/src/components/admin/buzzer-panel.tsx
T
joshuaandClaude Opus 5.5 186a112b95 Phase 11: mark buzzer answers, +1 for a correct one
The host marks the answering team "Got it" or "Wrong" (G / W). Wrong
passes the turn to the next team in the buzz order; Got it closes the
buzzers until "Next question" (B). Undo reverses either mark.

In a direct game a correct answer adds 1 to the team's score in the
game's first criterion, as an ordinary admin score row, so it flows
through the scoring engine (multiplier, placement) and the existing
range/step trigger. The point is written before the mark, so a refused
point never leaves a "correct" with nothing behind it. Judged games
only get the mark: the judges' scores stand.

Needs supabase/migrations/0004_buzz_result.sql applied before deploying.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 09:55:50 +02:00

112 lines
4.6 KiB
TypeScript

"use client";
import { clearBuzzes, markBuzz } from "@/app/admin/live-actions";
import { Button } from "@/components/ui/button";
import { formatBuzzGap, type BuzzerView } from "@/lib/buzzer";
import type { LiveGame } from "@/lib/live-view";
import { ErrorText } from "./error-text";
import { useAction } from "./use-action";
/**
* Who buzzed for the current question, in order. The host marks the answering team:
* "Got it" gives the point and ends the question, "Wrong" passes the turn down the order.
*/
export function BuzzerPanel({ eventId, game, buzzer }: { eventId: string; game: LiveGame; buzzer: BuzzerView }) {
const { pending, error, run } = useAction();
const { buzzes, answeringTeamId } = buzzer;
const decided = buzzes.some((entry) => entry.result === "correct");
// Only a direct game takes the point; in a judged game the judges' scores stand.
const pointCriterion = game.scoringMode === "direct" ? game.criteria[0] : undefined;
return (
<div className="rounded-md border border-ink/15 p-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<h3 className="text-lg font-bold">Buzzers</h3>
<Button
variant={decided ? "primary" : "secondary"}
disabled={pending || buzzes.length === 0}
onClick={() => run(() => clearBuzzes(eventId, game.id))}
>
Next question (B)
</Button>
</div>
{buzzes.length === 0 ? (
<p role="status" className="mt-1">
{buzzer.open ? "Waiting for a buzz…" : "Closed — the game is done."}
</p>
) : (
<ol className="mt-2 flex flex-col gap-1">
{buzzes.map((entry, index) => {
const answering = entry.teamId === answeringTeamId;
return (
<li
key={entry.teamId}
aria-current={answering ? "true" : undefined}
className={`flex min-h-11 flex-wrap items-center gap-2 rounded-md px-2 py-1 ${
answering ? "bg-floodlight text-xl font-bold" : entry.result === "wrong" ? "text-ink/70" : ""
}`}
>
<span className="w-5 tabular-nums">{index + 1}</span>
<span aria-hidden className="inline-block size-3 rounded-full" style={{ backgroundColor: entry.color }} />
<span className={`min-w-0 flex-1 truncate ${entry.result === "wrong" ? "line-through" : ""}`}>
{entry.name}
</span>
<span className="text-sm font-normal tabular-nums">
{index === 0 ? "first" : formatBuzzGap(entry.afterFirstMs)}
</span>
{answering && (
<>
<Button
disabled={pending}
onClick={() => run(() => markBuzz(eventId, game.id, entry.teamId, "correct"))}
>
{pointCriterion ? "Got it +1 (G)" : "Got it (G)"}
</Button>
<Button
variant="danger"
disabled={pending}
onClick={() => run(() => markBuzz(eventId, game.id, entry.teamId, "wrong"))}
>
Wrong (W)
</Button>
</>
)}
{entry.result && (
<>
<strong className={entry.result === "correct" ? "" : "text-alert-strong"}>
{entry.result === "correct" ? "✓ Got it" : "✗ Wrong"}
</strong>
<Button
variant="secondary"
size="sm"
disabled={pending}
aria-label={`Undo for ${entry.name}`}
onClick={() => run(() => markBuzz(eventId, game.id, entry.teamId, null))}
>
Undo
</Button>
</>
)}
</li>
);
})}
</ol>
)}
<p role="status" className="mt-2 text-sm text-ink/70">
{decided
? "Answered. Press “Next question” to open the buzzers again."
: buzzes.length > 0 && !answeringTeamId
? "Nobody is waiting to answer. The buzzers are still open."
: pointCriterion
? `“Got it” adds 1 to ${pointCriterion.label}. “Wrong” passes to the next team.`
: "Judged game: “Got it” adds no point — the judges score. “Wrong” passes to the next team."}
</p>
<ErrorText>{error}</ErrorText>
</div>
);
}