Phase 10: team buzzers

Each team gets a buzzer link (/b/[token]); a game opts in with a
"Teams buzz in" checkbox. The database's clock orders the buzzes, so
the result doesn't depend on phone clocks. The control room lists the
order and clears it per question (B); the scoreboard shows who buzzed
first with a ding.

Buzzes follow the existing rules: token-gated route handlers with the
service-role client, RLS on the new table, and data-free realtime pings
on a new buzzer:{eventId} channel.

Needs supabase/migrations/0003_buzzer.sql applied before deploying.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-05 09:33:44 +02:00
co-authored by Claude Opus 5.5
parent 48cecfff7f
commit bbcf84bbc3
38 changed files with 969 additions and 31 deletions
+50
View File
@@ -0,0 +1,50 @@
"use client";
import { clearBuzzes } from "@/app/admin/live-actions";
import { Button } from "@/components/ui/button";
import { formatBuzzGap, type BuzzerView } from "@/lib/buzzer";
import { ErrorText } from "./error-text";
import { useAction } from "./use-action";
/** Who buzzed for the current question, in order, and the button that clears them for the next. */
export function BuzzerPanel({ eventId, gameId, buzzer }: { eventId: string; gameId: string; buzzer: BuzzerView }) {
const { pending, error, run } = useAction();
const { buzzes } = buzzer;
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="secondary"
disabled={pending || buzzes.length === 0}
onClick={() => run(() => clearBuzzes(eventId, gameId))}
>
Clear buzzers (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 role="status" className="mt-2 flex flex-col gap-1">
{buzzes.map((entry, index) => (
<li
key={entry.teamId}
className={`flex items-center gap-2 rounded-md px-2 ${index === 0 ? "min-h-11 bg-floodlight text-xl font-bold" : ""}`}
>
<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.name}</span>
<span className="text-sm tabular-nums">{index === 0 ? "first" : formatBuzzGap(entry.afterFirstMs)}</span>
</li>
))}
</ol>
)}
<ErrorText>{error}</ErrorText>
</div>
);
}