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>
This commit is contained in:
+14
-3
@@ -33,7 +33,7 @@ Admin mutations = server actions. `src/proxy.ts` (Next.js 16's name for middlewa
|
||||
- Public/judge access = token in URL → route handler validates with zod → service-role query scoped by that token.
|
||||
- Judge write checks: judge not revoked, game not locked/done, criterion belongs to judge's game, team belongs to the game's event, value within min/max and on step. (DB trigger also enforces range.)
|
||||
- Scoreboard API checks `scoreboard_visibility` and strips scores when `hidden`; returns `frozen_snapshot` when `frozen`.
|
||||
- Buzz checks: the token's team exists, its event's current game has `buzzer_enabled` and isn't done. The team and game come from the token alone — the request has no body. One row per (game, team), so a repeat press is a no-op.
|
||||
- Buzz checks: the token's team exists, its event's current game has `buzzer_enabled`, isn't done, and nobody has been marked correct for this question. The team and game come from the token alone — the request has no body. One row per (game, team), so a repeat press is a no-op.
|
||||
- Light rate limiting on judge PUT (20 req/s per token), buzz POST (5 req/s per token) and open-link join (3 req/s per link), in-memory per server instance. An open link can create at most 50 judges per game.
|
||||
|
||||
## Realtime (Broadcast "ping then refetch")
|
||||
@@ -73,8 +73,12 @@ type ScoreboardPayload = {
|
||||
} | null;
|
||||
// null unless the current game uses buzzers; sent regardless of score visibility
|
||||
buzzer: {
|
||||
open: boolean; // false once the game is done
|
||||
buzzes: Array<{ teamId: string; name: string; color: string; afterFirstMs: number }>; // in buzz order
|
||||
open: boolean; // false once a team got it, or the game is done
|
||||
buzzes: Array<{ // in buzz order
|
||||
teamId: string; name: string; color: string; afterFirstMs: number;
|
||||
result: 'correct' | 'wrong' | null; // the host's mark
|
||||
}>;
|
||||
answeringTeamId: string | null; // earliest unmarked buzz; null once someone got it
|
||||
} | null;
|
||||
updatedAt: string; // server clock; screens use it to correct their own
|
||||
};
|
||||
@@ -100,6 +104,13 @@ When frozen, rank/total come from `frozen_snapshot` but `currentGame` stays live
|
||||
- Buzzes follow rule 4 like everything else: the ping carries no data and each screen refetches.
|
||||
The buzzing phone gets its result in the POST response, so it doesn't wait for the ping.
|
||||
- Clearing = deleting the current game's rows (admin server action).
|
||||
- Marking an answer is the `markBuzz` server action. `checkMark` decides whether the mark is
|
||||
allowed and whether a point moves (+1 for a new "correct", −1 when one is undone);
|
||||
`bumpScore` gives the new cell value. In a direct game the point is written to the admin's
|
||||
score row for the game's first criterion — an ordinary score, so the scoring engine turns it
|
||||
into leaderboard points like any other and the `validate_score` trigger still guards range
|
||||
and step. The score is written before the mark, so a refused point leaves the answer unmarked.
|
||||
- A buzz is refused once any buzz of the game is marked correct, until the host clears.
|
||||
|
||||
## Folder layout
|
||||
```
|
||||
|
||||
@@ -73,5 +73,13 @@ human a short "how to test this manually" checklist. Don't start the next phase
|
||||
**Done when:** two phones on different teams buzz in a buzzer game; the control room and scoreboard
|
||||
show who was first within a second, and "Clear buzzers" resets both phones.
|
||||
|
||||
## Phase 11 — Buzzer answers
|
||||
- Apply `supabase/migrations/0004_buzz_result.sql`, then `npm run gen:types`.
|
||||
- `checkMark` / `bumpScore` / `answeringTeamId` in `src/lib/buzzer.ts` (tested); `markBuzz` server action.
|
||||
- Control room: "Got it" (`G`), "Wrong" (`W`), "Undo", "Next question" (`B`).
|
||||
- Phone and scoreboard follow whose turn it is; "correct" and "wrong" sounds.
|
||||
**Done when:** team A buzzes then team B; "Wrong" hands the turn to B; "Got it" gives B one point
|
||||
in the scoring grid and on the scoreboard; "Undo" takes it back; "Next question" resets the phones.
|
||||
|
||||
## Stretch (only if asked)
|
||||
Participants within teams · co-host admins · CSV export of results.
|
||||
|
||||
+3
-2
@@ -1,6 +1,6 @@
|
||||
# Data model
|
||||
|
||||
Full SQL in `supabase/migrations/` (`0001_init.sql`, then `0002_timer.sql`, `0003_buzzer.sql`).
|
||||
Full SQL in `supabase/migrations/` (`0001_init.sql`, then `0002_timer.sql`, `0003_buzzer.sql`, `0004_buzz_result.sql`).
|
||||
|
||||
| Table | Purpose | Key columns |
|
||||
|---|---|---|
|
||||
@@ -11,7 +11,7 @@ Full SQL in `supabase/migrations/` (`0001_init.sql`, then `0002_timer.sql`, `000
|
||||
| `judges` | Judges per game | `name`, `token`, `revoked`, `source` (nominated/open_link) |
|
||||
| `scores` | One value per (team, criterion, judge) | `judge_id` NULL = admin score (direct mode or override) |
|
||||
| `adjustments` | Bonus/penalty | `points` (+/-), `reason`, optional `game_id` |
|
||||
| `buzzes` | Who buzzed for the current question | `game_id`, `team_id`, `buzzed_at` |
|
||||
| `buzzes` | Who buzzed for the current question | `game_id`, `team_id`, `buzzed_at`, `result` (correct/wrong/NULL) |
|
||||
|
||||
## Notes
|
||||
- `scores` unique on `(team_id, criterion_id, judge_id)` with `NULLS NOT DISTINCT`, so there's exactly one admin row per team+criterion → clean upserts.
|
||||
@@ -19,4 +19,5 @@ Full SQL in `supabase/migrations/` (`0001_init.sql`, then `0002_timer.sql`, `000
|
||||
- Deleting a criterion/team/judge cascades its scores.
|
||||
- Timer (`0002_timer.sql`): running = `timer_ends_at` set; paused = `timer_remaining_ms` set; reset = both null (full `timer_duration_ms`).
|
||||
- Buzzers (`0003_buzzer.sql`): `buzzes` is unique on `(game_id, team_id)` — one buzz per team per question; `buzzed_at` defaults to `clock_timestamp()` and decides the order. Clearing the buzzers deletes the game's rows. A trigger checks the team is in the game's event. `teams.buzzer_token` is a 128-bit token like the others; duplicating an event gives the copied teams new ones.
|
||||
- Buzzer answers (`0004_buzz_result.sql`): `buzzes.result` is the host's mark. The team answering is the earliest buzz with `result IS NULL`, unless some buzz is `correct` (question decided). The point for a correct answer is not stored on the buzz: it is +1 on the admin's `scores` row for the game's first criterion.
|
||||
- `frozen_snapshot` holds the `ScoreboardPayload` captured at freeze time.
|
||||
|
||||
+27
-14
@@ -65,9 +65,10 @@ This is the screen used during the night. Must be fast and hard to mess up.
|
||||
Recent adjustments list with undo.
|
||||
- Scoreboard visibility: **Visible / Frozen / Hidden** (see 4.3). Big obvious toggle.
|
||||
- "Open scoreboard" button (new tab) + "Reveal mode" button (see 4.3).
|
||||
- Buzzers (buzzer games only): who buzzed, in order, with each team's gap behind the first; "Clear buzzers".
|
||||
- Buzzers (buzzer games only): who buzzed, in order, with each team's gap behind the first;
|
||||
"Got it" / "Wrong" for the team answering, "Undo" on a marked team, and "Next question".
|
||||
- Keyboard shortcuts: `N` next game, `P` prev game, `H` cycle visibility, `T` start/pause timer,
|
||||
`B` clear buzzers, `?` help.
|
||||
`G` got it, `W` wrong, `B` next question (clears the buzzers), `?` help.
|
||||
|
||||
### 4.3 Scoreboard (`/s/[token]`)
|
||||
- Projector-first, 16:9, readable from the back of a hall.
|
||||
@@ -111,24 +112,36 @@ Played by the scoreboard page, so they come out of the projector PC's speakers.
|
||||
- Timer: a tick for each of the last 10 seconds (sharper for the last 3) and a buzzer at zero.
|
||||
- Browsers only allow sound after a click on the page, so the scoreboard has a "Turn sound on"
|
||||
button. The choice is remembered, but after a reload one click anywhere is needed again.
|
||||
- Buzzer: a two-note ding when a team is first to buzz.
|
||||
- Buzzer: a two-note ding when a team starts answering, a rising arpeggio for "got it" and two
|
||||
falling notes for "wrong".
|
||||
- Sounds are synthesised in the browser (no audio files). With reduced motion the drum-roll is skipped.
|
||||
|
||||
### 4.7 Buzzers (`/b/[token]`)
|
||||
For "first to answer" games. A game opts in with the "Teams buzz in" checkbox; scoring is unchanged
|
||||
(the host or the judges still enter the points).
|
||||
For "first to answer" games. A game opts in with the "Teams buzz in" checkbox.
|
||||
- Every team has one buzzer link for the whole night. Someone on the team opens it on a phone:
|
||||
team name, the current game, and one big "Buzz" button.
|
||||
- The button works only while the current game uses buzzers and isn't done. Otherwise the page
|
||||
says there's no buzzer for this game, and wakes up by itself when one starts.
|
||||
- The server decides the order, using the database's clock — never the phones' clocks.
|
||||
- A team buzzes once per question; pressing again does nothing. Later teams can still buzz, so
|
||||
the host knows who is next if the first answer is wrong.
|
||||
- The phone shows "You buzzed first!" (the screen turns the team's colour) or its place and who was first.
|
||||
- The control room lists the order with the gap behind the first buzz ("+0.42s"). "Clear buzzers"
|
||||
(or `B`) empties it for the next question.
|
||||
- The scoreboard shows "{Team} buzzed!" in the team's colour, with the following teams after it,
|
||||
and plays a ding. It is not a score, so it shows whether scores are visible, frozen or hidden.
|
||||
- A team buzzes once per question; pressing again does nothing. Later teams can still buzz and
|
||||
queue up behind the team answering.
|
||||
- The team answering is the earliest buzz the host hasn't marked. The host marks it:
|
||||
- **Got it** (`G`) — the question is decided and the buzzers close. In a direct game the team
|
||||
gets +1 in the game's first criterion (so the point follows the game's multiplier and
|
||||
placement rules like any other score). In a judged game nothing is added: the judges score.
|
||||
- **Wrong** (`W`) — that team is out for this question and the turn passes to the next team
|
||||
in the buzz order. With nobody waiting, the buzzers stay open for the other teams.
|
||||
- **Undo** on a marked team removes the mark, and takes the point back if it was "got it".
|
||||
- **Next question** (`B`) clears everything and opens the buzzers again.
|
||||
- If the point can't be added (the score is at the criterion's maximum, or +1 doesn't fit its
|
||||
step), the answer stays unmarked and the host sees why.
|
||||
- The phone's screen turns the team's colour while it is that team's turn ("You buzzed first!
|
||||
Answer now." / "Your turn! Answer now.") and when it got it. Otherwise it shows the team's
|
||||
place and who is answering, "Not this time" after a wrong answer, or who got it.
|
||||
- The control room lists the order with the gap behind the first buzz ("+0.42s") and each mark.
|
||||
- The scoreboard shows "{Team} buzzed!" in the team's colour with the waiting teams after it,
|
||||
"{Team} to answer" when the turn passes, and "{Team} got it!". It is not a score, so it shows
|
||||
whether scores are visible, frozen or hidden (the point itself follows the usual visibility rules).
|
||||
- The phone asks the browser to keep the screen on while the page is open.
|
||||
|
||||
## 5. Non-functional
|
||||
@@ -141,5 +154,5 @@ For "first to answer" games. A game opts in with the "Teams buzz in" checkbox; s
|
||||
- Individual contestants within teams (stretch: `participants` per team).
|
||||
- Multiple admins / co-hosts (stretch).
|
||||
- Per-game timers (stretch). The event countdown timer is in 4.5; team buzzers are in 4.7.
|
||||
- Buzzers for individual contestants, automatic points for buzzing first, and locking a team out
|
||||
after a wrong answer (the host just reads the next team from the order).
|
||||
- Buzzers for individual contestants, a configurable points value per correct answer, and
|
||||
penalties for a wrong answer (use the bonus/penalty panel).
|
||||
|
||||
+8
-3
@@ -91,11 +91,16 @@ Selected block filled in team colour. Tap the selected block again to clear.
|
||||
└─────────────────────────┘
|
||||
```
|
||||
One round button in the team's colour, as big as the screen allows; it reacts on touch-down, not
|
||||
on release. After buzzing it shows the team's place ("1st", "2nd"). First in: the whole screen
|
||||
on release. After buzzing it shows the team's place ("1st", "2nd"), then ✓ or ✗ once the host
|
||||
marks the answer. While it's the team's turn to answer, and when it got it, the whole screen
|
||||
turns the team's colour. Greyed out while there is no buzzer game.
|
||||
|
||||
On the scoreboard a buzz is a banner in the team's colour above the standings: "Lions buzzed!",
|
||||
then the later teams in small text. It pops in once; reduced motion shows it without the pop.
|
||||
On the scoreboard the buzzer is a banner in the answering team's colour above the standings:
|
||||
"Lions buzzed!" with the waiting teams in small text, "Eagles to answer" when the turn passes,
|
||||
"Eagles got it!" at the end. It pops in on each change; reduced motion shows it without the pop.
|
||||
|
||||
In the control room the answering team's row is highlighted (Floodlight) and carries the two
|
||||
buttons "Got it +1" and "Wrong"; marked rows show ✓ / ✗ and an "Undo".
|
||||
|
||||
## Copy
|
||||
Plain verbs: "Next game", "Hide scores", "Freeze scores", "Show scores", "Add bonus", "Copy judge link".
|
||||
|
||||
@@ -5,7 +5,8 @@ import { z } from "zod";
|
||||
|
||||
import { requireAdmin } from "@/lib/auth";
|
||||
import type { Json } from "@/lib/database.types";
|
||||
import { loadEventData } from "@/lib/queries/scoring";
|
||||
import { bumpScore, checkMark, type BuzzResult } from "@/lib/buzzer";
|
||||
import { loadBuzzes, loadEventData } from "@/lib/queries/scoring";
|
||||
import { notify } from "@/lib/realtime";
|
||||
import { buildFrozenSnapshot } from "@/lib/scoreboard";
|
||||
import { toScoreboardInput } from "@/lib/scoreboard.server";
|
||||
@@ -130,28 +131,8 @@ export async function saveAdminScore(
|
||||
const parsed = scoreSchema.safeParse({ value });
|
||||
if (!parsed.success) return fail("That score isn't a valid number.");
|
||||
|
||||
if (parsed.data.value === null) {
|
||||
const { error } = await supabase
|
||||
.from("scores")
|
||||
.delete()
|
||||
.eq("game_id", gameId)
|
||||
.eq("team_id", teamId)
|
||||
.eq("criterion_id", criterionId)
|
||||
.is("judge_id", null);
|
||||
if (error) return scoreError(error.message);
|
||||
} else {
|
||||
const { error } = await supabase.from("scores").upsert(
|
||||
{
|
||||
game_id: gameId,
|
||||
team_id: teamId,
|
||||
criterion_id: criterionId,
|
||||
judge_id: null,
|
||||
value: parsed.data.value,
|
||||
},
|
||||
{ onConflict: "team_id,criterion_id,judge_id" },
|
||||
);
|
||||
if (error) return scoreError(error.message);
|
||||
}
|
||||
const { error } = await writeAdminScore(supabase, gameId, teamId, criterionId, parsed.data.value);
|
||||
if (error) return scoreError(error.message);
|
||||
|
||||
// No revalidatePath: the control room refreshes itself, so the "saved" tick isn't
|
||||
// held up by a full page re-render.
|
||||
@@ -159,6 +140,93 @@ export async function saveAdminScore(
|
||||
return ok;
|
||||
}
|
||||
|
||||
type Supabase = Awaited<ReturnType<typeof requireAdmin>>["supabase"];
|
||||
|
||||
/** Upserts the admin's row for one cell, or deletes it when `value` is null. */
|
||||
function writeAdminScore(
|
||||
supabase: Supabase,
|
||||
gameId: string,
|
||||
teamId: string,
|
||||
criterionId: string,
|
||||
value: number | null,
|
||||
) {
|
||||
return value === null
|
||||
? supabase
|
||||
.from("scores")
|
||||
.delete()
|
||||
.eq("game_id", gameId)
|
||||
.eq("team_id", teamId)
|
||||
.eq("criterion_id", criterionId)
|
||||
.is("judge_id", null)
|
||||
: supabase.from("scores").upsert(
|
||||
{ game_id: gameId, team_id: teamId, criterion_id: criterionId, judge_id: null, value },
|
||||
{ onConflict: "team_id,criterion_id,judge_id" },
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- buzzer answers ----------
|
||||
|
||||
const buzzResultSchema = z.enum(["correct", "wrong"]).nullable();
|
||||
|
||||
/**
|
||||
* Marks the answering team's buzz: "correct" ends the question, "wrong" passes the turn to the
|
||||
* next team that buzzed, null undoes an earlier mark. In a direct game a correct answer also
|
||||
* adds a point to the team's score in the game's first criterion (and undoing takes it back);
|
||||
* in a judged game the judges' scores stand, so only the mark changes.
|
||||
*/
|
||||
export async function markBuzz(
|
||||
eventId: string,
|
||||
gameId: string,
|
||||
teamId: string,
|
||||
result: BuzzResult,
|
||||
): Promise<ActionResult> {
|
||||
const { supabase } = await requireAdmin();
|
||||
if (!validIds(eventId, gameId, teamId) || !buzzResultSchema.safeParse(result).success) return BAD_ID;
|
||||
|
||||
const [game, buzzes, criteria] = await Promise.all([
|
||||
// The game must be in this event; RLS alone would allow a game from another of your events.
|
||||
supabase.from("games").select("scoring_mode").eq("id", gameId).eq("event_id", eventId).maybeSingle(),
|
||||
loadBuzzes(supabase, gameId),
|
||||
supabase.from("criteria").select("id, min_value").eq("game_id", gameId).order("sort_order").limit(1),
|
||||
]);
|
||||
const loadError = game.error ?? buzzes.error ?? criteria.error;
|
||||
if (loadError) return dbFail("Marking the answer", loadError.message);
|
||||
if (!game.data) return BAD_ID;
|
||||
|
||||
const check = checkMark(buzzes.data ?? [], teamId, result);
|
||||
if (!check.ok) return fail(check.error);
|
||||
if (!check.changed) return ok;
|
||||
|
||||
// The point first: if it can't be saved (say the score is at its maximum), the answer
|
||||
// stays unmarked and the host sees why, instead of a "correct" with no point behind it.
|
||||
const criterion = criteria.data?.[0];
|
||||
if (check.pointDelta !== 0 && game.data.scoring_mode === "direct" && criterion) {
|
||||
const current = await supabase
|
||||
.from("scores")
|
||||
.select("value")
|
||||
.eq("team_id", teamId)
|
||||
.eq("criterion_id", criterion.id)
|
||||
.is("judge_id", null)
|
||||
.maybeSingle();
|
||||
if (current.error) return dbFail("Adding the point", current.error.message);
|
||||
|
||||
const next = bumpScore(current.data?.value ?? null, check.pointDelta, criterion.min_value);
|
||||
const { error } = await writeAdminScore(supabase, gameId, teamId, criterion.id, next);
|
||||
if (error) return scoreError(error.message);
|
||||
}
|
||||
|
||||
const { error } = await supabase
|
||||
.from("buzzes")
|
||||
.update({ result })
|
||||
.eq("game_id", gameId)
|
||||
.eq("team_id", teamId);
|
||||
if (error) return dbFail("Marking the answer", error.message);
|
||||
|
||||
revalidatePath(livePath(eventId));
|
||||
await notify({ eventId, gameId, buzzer: true });
|
||||
return ok;
|
||||
}
|
||||
|
||||
// ---------- bonus / penalty ----------
|
||||
|
||||
const adjustmentSchema = z.object({
|
||||
|
||||
@@ -1,27 +1,34 @@
|
||||
"use client";
|
||||
|
||||
import { clearBuzzes } from "@/app/admin/live-actions";
|
||||
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, and the button that clears them for the next. */
|
||||
export function BuzzerPanel({ eventId, gameId, buzzer }: { eventId: string; gameId: string; buzzer: BuzzerView }) {
|
||||
/**
|
||||
* 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 } = buzzer;
|
||||
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="secondary"
|
||||
variant={decided ? "primary" : "secondary"}
|
||||
disabled={pending || buzzes.length === 0}
|
||||
onClick={() => run(() => clearBuzzes(eventId, gameId))}
|
||||
onClick={() => run(() => clearBuzzes(eventId, game.id))}
|
||||
>
|
||||
Clear buzzers (B)
|
||||
Next question (B)
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -30,20 +37,74 @@ export function BuzzerPanel({ eventId, gameId, buzzer }: { eventId: string; game
|
||||
{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 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>
|
||||
);
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import {
|
||||
clearBuzzes,
|
||||
markBuzz,
|
||||
pauseTimer,
|
||||
setCurrentGame,
|
||||
setGameStatus,
|
||||
@@ -35,7 +36,9 @@ const shortcuts = [
|
||||
["P", "Previous game"],
|
||||
["H", "Cycle what the room sees: visible → frozen → hidden"],
|
||||
["T", "Start or pause the timer"],
|
||||
["B", "Clear the buzzers for the next question"],
|
||||
["G", "Buzzer: the answering team got it"],
|
||||
["W", "Buzzer: wrong, pass to the next team"],
|
||||
["B", "Buzzer: next question (clears the buzzers)"],
|
||||
["?", "Show or hide this help"],
|
||||
] as const;
|
||||
|
||||
@@ -79,6 +82,16 @@ export function ControlRoom({ view }: { view: LiveView }) {
|
||||
if (buzzerGameId && !pending) run(() => clearBuzzes(event.id, buzzerGameId));
|
||||
}, [buzzerGameId, event.id, pending, run]);
|
||||
|
||||
const answeringTeamId = currentGame?.buzzer?.answeringTeamId;
|
||||
const markAnswer = useCallback(
|
||||
(result: "correct" | "wrong") => {
|
||||
if (currentGame && answeringTeamId && !pending) {
|
||||
run(() => markBuzz(event.id, currentGame.id, answeringTeamId, result));
|
||||
}
|
||||
},
|
||||
[answeringTeamId, currentGame, event.id, pending, run],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyDown = (keyEvent: KeyboardEvent) => {
|
||||
// Never steal keys from a field the host is typing in.
|
||||
@@ -92,12 +105,14 @@ export function ControlRoom({ view }: { view: LiveView }) {
|
||||
else if (key === "h") cycleVisibility();
|
||||
else if (key === "t") toggleTimer();
|
||||
else if (key === "b") clearBuzzers();
|
||||
else if (key === "g") markAnswer("correct");
|
||||
else if (key === "w") markAnswer("wrong");
|
||||
else if (key === "?") setShowHelp((shown) => !shown);
|
||||
else if (key === "escape") setShowHelp(false);
|
||||
};
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
return () => window.removeEventListener("keydown", onKeyDown);
|
||||
}, [clearBuzzers, cycleVisibility, goTo, nextGame?.id, previousGame?.id, toggleTimer]);
|
||||
}, [clearBuzzers, cycleVisibility, goTo, markAnswer, nextGame?.id, previousGame?.id, toggleTimer]);
|
||||
|
||||
const teamsById = new Map(teams.map((team) => [team.id, team]));
|
||||
|
||||
@@ -221,7 +236,7 @@ export function ControlRoom({ view }: { view: LiveView }) {
|
||||
{currentGame ? (
|
||||
<>
|
||||
{currentGame.buzzer && (
|
||||
<BuzzerPanel eventId={event.id} gameId={currentGame.id} buzzer={currentGame.buzzer} />
|
||||
<BuzzerPanel eventId={event.id} game={currentGame} buzzer={currentGame.buzzer} />
|
||||
)}
|
||||
{/* Keyed by game so half-saved edits never carry over to another game. */}
|
||||
<ScoringGrid key={currentGame.id} eventId={event.id} game={currentGame} teams={teams} />
|
||||
|
||||
@@ -39,7 +39,6 @@ export function BuzzerView({ token, initial }: { token: string; initial: BuzzerP
|
||||
|
||||
const { team, currentGame, buzzer } = payload;
|
||||
const position = buzzer ? buzzer.buzzes.findIndex((entry) => entry.teamId === team.id) : -1;
|
||||
const first = buzzer?.buzzes[0];
|
||||
const canBuzz = Boolean(buzzer?.open) && position < 0 && !sending;
|
||||
|
||||
const press = async () => {
|
||||
@@ -72,27 +71,41 @@ export function BuzzerView({ token, initial }: { token: string; initial: BuzzerP
|
||||
|
||||
if (linkOff) return <BuzzerMessage>{linkOff}</BuzzerMessage>;
|
||||
|
||||
const mine = position >= 0 ? buzzer?.buzzes[position] : undefined;
|
||||
const answering = buzzer?.buzzes.find((entry) => entry.teamId === buzzer.answeringTeamId);
|
||||
const winner = buzzer?.buzzes.find((entry) => entry.result === "correct");
|
||||
const myTurn = answering?.teamId === team.id;
|
||||
|
||||
const status = !buzzer
|
||||
? buzzerMessages.noGame
|
||||
: position === 0
|
||||
? "You buzzed first!"
|
||||
: position > 0
|
||||
? `You buzzed ${ordinal(position + 1)}. ${first?.name} was first.`
|
||||
: sending
|
||||
? "Buzzing…"
|
||||
: !buzzer.open
|
||||
? buzzerMessages.closed
|
||||
: first
|
||||
? `${first.name} buzzed first. You can still buzz in.`
|
||||
: "Ready. Tap to buzz in.";
|
||||
: mine?.result === "correct"
|
||||
? "You got it!"
|
||||
: winner
|
||||
? `${winner.name} got it. Wait for the next question.`
|
||||
: mine?.result === "wrong"
|
||||
? "Not this time. Wait for the next question."
|
||||
: myTurn
|
||||
? position === 0
|
||||
? "You buzzed first! Answer now."
|
||||
: "Your turn! Answer now."
|
||||
: mine
|
||||
? `You buzzed ${ordinal(position + 1)}. ${answering?.name} is answering.`
|
||||
: sending
|
||||
? "Buzzing…"
|
||||
: !buzzer.open
|
||||
? buzzerMessages.closed
|
||||
: answering
|
||||
? `${answering.name} is answering. You can still buzz in.`
|
||||
: "Ready. Tap to buzz in.";
|
||||
|
||||
const won = position === 0;
|
||||
// The screen lights up while it's this team's moment: answering, or just got it.
|
||||
const won = myTurn || mine?.result === "correct";
|
||||
const textColor = readableTextColor(team.color);
|
||||
|
||||
return (
|
||||
<main
|
||||
className="mx-auto flex min-h-dvh max-w-xl flex-col items-center gap-4 p-4 pb-8 transition-colors"
|
||||
// First in: the whole screen turns the team's colour, so the table sees it at a glance.
|
||||
// The whole screen turns the team's colour, so the table sees at a glance it's their turn.
|
||||
style={won ? { backgroundColor: team.color, color: textColor } : undefined}
|
||||
>
|
||||
<header className="w-full text-center">
|
||||
@@ -122,7 +135,7 @@ export function BuzzerView({ token, initial }: { token: string; initial: BuzzerP
|
||||
} ${won ? "border-current" : "border-ink"}`}
|
||||
style={{ backgroundColor: team.color, color: textColor }}
|
||||
>
|
||||
{position >= 0 ? ordinal(position + 1) : "Buzz"}
|
||||
{mine?.result === "correct" ? "✓" : mine?.result === "wrong" ? "✗" : mine ? ordinal(position + 1) : "Buzz"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
|
||||
import type { BuzzEntry } from "@/lib/buzzer";
|
||||
import type { BuzzerView } from "@/lib/buzzer";
|
||||
import { readableTextColor } from "@/lib/color";
|
||||
import { useRefreshChannel } from "@/lib/hooks/use-refresh-channel";
|
||||
import { useSound } from "@/lib/hooks/use-sound";
|
||||
@@ -124,10 +124,7 @@ export function Scoreboard({ token, initial }: { token: string; initial: Scorebo
|
||||
|
||||
{payload.timer && <ScoreboardTimer timer={payload.timer} serverNow={payload.updatedAt} play={play} />}
|
||||
|
||||
{payload.buzzer && payload.buzzer.buzzes.length > 0 && (
|
||||
// Keyed by the first team so the pop replays when a new question has a new winner.
|
||||
<BuzzBanner key={payload.buzzer.buzzes[0].teamId} buzzes={payload.buzzer.buzzes} />
|
||||
)}
|
||||
{payload.buzzer && <BuzzBanner buzzer={payload.buzzer} />}
|
||||
|
||||
{hidden && (
|
||||
<p className="self-center rounded-full border-2 border-alert px-[2.5vw] py-[1vh] text-[clamp(1rem,3.2vh,2.5rem)] font-bold">
|
||||
@@ -250,20 +247,37 @@ function TeamRow({
|
||||
);
|
||||
}
|
||||
|
||||
/** Who buzzed: the first team fills the banner in its colour, the rest follow in order. */
|
||||
function BuzzBanner({ buzzes }: { buzzes: BuzzEntry[] }) {
|
||||
const [first, ...rest] = buzzes;
|
||||
/**
|
||||
* The buzzer's headline: the team answering fills the banner in its colour, with the teams
|
||||
* still waiting after it; once a team got it, the banner says so. Nothing while nobody is
|
||||
* answering (no buzz yet, or everyone so far was wrong).
|
||||
*/
|
||||
function BuzzBanner({ buzzer }: { buzzer: BuzzerView }) {
|
||||
const winner = buzzer.buzzes.find((entry) => entry.result === "correct");
|
||||
const answering = buzzer.buzzes.find((entry) => entry.teamId === buzzer.answeringTeamId);
|
||||
const team = winner ?? answering;
|
||||
if (!team) return null;
|
||||
|
||||
const waiting = winner ? [] : buzzer.buzzes.filter((entry) => entry.result === null && entry !== answering);
|
||||
// The very first buzz "buzzed"; a team that inherits the turn after a wrong answer is "to answer".
|
||||
const headline = winner
|
||||
? `${team.name} got it!`
|
||||
: buzzer.buzzes[0] === answering
|
||||
? `${team.name} buzzed!`
|
||||
: `${team.name} to answer`;
|
||||
|
||||
return (
|
||||
<div
|
||||
// Keyed by the headline so the pop replays whenever the turn or the outcome changes.
|
||||
key={headline}
|
||||
role="status"
|
||||
className="buzz-pop flex shrink-0 items-baseline justify-center gap-[2vw] rounded-[1vh] px-[3vw] py-[1.5vh] font-display leading-none font-bold"
|
||||
style={{ backgroundColor: first.color, color: readableTextColor(first.color) }}
|
||||
style={{ backgroundColor: team.color, color: readableTextColor(team.color) }}
|
||||
>
|
||||
<span className="truncate text-[clamp(1.5rem,9vh,8rem)]">{first.name} buzzed!</span>
|
||||
{rest.length > 0 && (
|
||||
<span className="truncate text-[clamp(1.5rem,9vh,8rem)]">{headline}</span>
|
||||
{waiting.length > 0 && (
|
||||
<span className="truncate text-[clamp(1rem,3.5vh,3rem)]">
|
||||
then {rest.map((entry) => entry.name).join(", ")}
|
||||
then {waiting.map((entry) => entry.name).join(", ")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -82,6 +82,15 @@ export async function buzz(token: string): Promise<BuzzerLookup> {
|
||||
if (!game?.buzzer_enabled) return { ok: false, status: 409, error: buzzerMessages.noGame };
|
||||
if (!buzzerOpen(game)) return { ok: false, status: 409, error: buzzerMessages.closed };
|
||||
|
||||
// Once a team got it, the question is over until the host clears the buzzers.
|
||||
const decided = await supabase
|
||||
.from("buzzes")
|
||||
.select("id", { count: "exact", head: true })
|
||||
.eq("game_id", game.id)
|
||||
.eq("result", "correct");
|
||||
if (decided.error) throw new Error(`Could not check the buzzers: ${decided.error.message}`);
|
||||
if (decided.count) return { ok: false, status: 409, error: buzzerMessages.answered };
|
||||
|
||||
// The database stamps the time and keeps the first row per team (see 0003_buzzer.sql).
|
||||
const { error } = await supabase
|
||||
.from("buzzes")
|
||||
|
||||
+113
-14
@@ -1,13 +1,26 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { buzzerOpen, buzzerView, formatBuzzGap, ordinal } from "./buzzer";
|
||||
import {
|
||||
answeringTeamId,
|
||||
bumpScore,
|
||||
buzzerOpen,
|
||||
buzzerView,
|
||||
checkMark,
|
||||
formatBuzzGap,
|
||||
ordinal,
|
||||
type BuzzResult,
|
||||
} from "./buzzer";
|
||||
|
||||
const teams = [
|
||||
{ id: "lions", name: "Lions", color: "#E63946" },
|
||||
{ id: "eagles", name: "Eagles", color: "#3A86FF" },
|
||||
{ id: "sharks", name: "Sharks", color: "#2EC4B6" },
|
||||
];
|
||||
const live = { buzzer_enabled: true, status: "live" };
|
||||
const buzz = (team_id: string, buzzed_at: string) => ({ team_id, buzzed_at });
|
||||
const buzz = (team_id: string, buzzed_at: string, result: BuzzResult = null) => ({ team_id, buzzed_at, result });
|
||||
/** Buzzes in order, a tenth of a second apart. */
|
||||
const order = (...entries: Array<[string, BuzzResult?]>) =>
|
||||
entries.map(([teamId, result], index) => buzz(teamId, `2026-10-05T19:00:00.${index}00Z`, result ?? null));
|
||||
|
||||
describe("buzzerOpen", () => {
|
||||
it("is open only for a buzzer game that isn't done", () => {
|
||||
@@ -19,6 +32,27 @@ describe("buzzerOpen", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("answeringTeamId", () => {
|
||||
it("is the first buzz until the host marks it", () => {
|
||||
expect(answeringTeamId([])).toBeNull();
|
||||
expect(answeringTeamId(order(["lions"], ["eagles"]))).toBe("lions");
|
||||
});
|
||||
|
||||
it("passes down the order on a wrong answer", () => {
|
||||
expect(answeringTeamId(order(["lions", "wrong"], ["eagles"], ["sharks"]))).toBe("eagles");
|
||||
expect(answeringTeamId(order(["lions", "wrong"], ["eagles", "wrong"], ["sharks"]))).toBe("sharks");
|
||||
});
|
||||
|
||||
it("is nobody when everyone so far was wrong, until the next buzz", () => {
|
||||
expect(answeringTeamId(order(["lions", "wrong"]))).toBeNull();
|
||||
expect(answeringTeamId(order(["lions", "wrong"], ["eagles"]))).toBe("eagles");
|
||||
});
|
||||
|
||||
it("is nobody once a team got it, even with teams still waiting", () => {
|
||||
expect(answeringTeamId(order(["lions", "wrong"], ["eagles", "correct"], ["sharks"]))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("buzzerView", () => {
|
||||
it("is null when there is no game or it doesn't use buzzers", () => {
|
||||
expect(buzzerView(null, teams, [])).toBeNull();
|
||||
@@ -26,7 +60,7 @@ describe("buzzerView", () => {
|
||||
});
|
||||
|
||||
it("is open and empty before anyone buzzes", () => {
|
||||
expect(buzzerView(live, teams, [])).toEqual({ open: true, buzzes: [] });
|
||||
expect(buzzerView(live, teams, [])).toEqual({ open: true, buzzes: [], answeringTeamId: null });
|
||||
});
|
||||
|
||||
it("keeps the given order and measures each team against the first buzz", () => {
|
||||
@@ -34,26 +68,91 @@ describe("buzzerView", () => {
|
||||
buzz("eagles", "2026-10-05T19:00:00.100000+00:00"),
|
||||
buzz("lions", "2026-10-05T19:00:00.520000+00:00"),
|
||||
]);
|
||||
expect(view?.buzzes).toEqual([
|
||||
{ teamId: "eagles", name: "Eagles", color: "#3A86FF", afterFirstMs: 0 },
|
||||
{ teamId: "lions", name: "Lions", color: "#E63946", afterFirstMs: 420 },
|
||||
]);
|
||||
expect(view).toEqual({
|
||||
open: true,
|
||||
answeringTeamId: "eagles",
|
||||
buzzes: [
|
||||
{ teamId: "eagles", name: "Eagles", color: "#3A86FF", afterFirstMs: 0, result: null },
|
||||
{ teamId: "lions", name: "Lions", color: "#E63946", afterFirstMs: 420, result: null },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("stays open after a wrong answer, with the next team answering", () => {
|
||||
const view = buzzerView(live, teams, order(["lions", "wrong"], ["eagles"]));
|
||||
expect(view?.open).toBe(true);
|
||||
expect(view?.answeringTeamId).toBe("eagles");
|
||||
expect(view?.buzzes.map((entry) => entry.result)).toEqual(["wrong", null]);
|
||||
});
|
||||
|
||||
it("closes once a team got it", () => {
|
||||
const view = buzzerView(live, teams, order(["lions", "correct"], ["eagles"]));
|
||||
expect(view?.open).toBe(false);
|
||||
expect(view?.answeringTeamId).toBeNull();
|
||||
});
|
||||
|
||||
it("still shows who buzzed once the game is done, but closed", () => {
|
||||
const view = buzzerView({ buzzer_enabled: true, status: "done" }, teams, [
|
||||
buzz("lions", "2026-10-05T19:00:00.000Z"),
|
||||
]);
|
||||
const view = buzzerView({ buzzer_enabled: true, status: "done" }, teams, order(["lions"]));
|
||||
expect(view?.open).toBe(false);
|
||||
expect(view?.buzzes).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("skips a buzz from a team that has since been deleted", () => {
|
||||
const view = buzzerView(live, teams, [
|
||||
buzz("gone", "2026-10-05T19:00:00.000Z"),
|
||||
buzz("lions", "2026-10-05T19:00:01.000Z"),
|
||||
const view = buzzerView(live, teams, order(["gone"], ["lions"]));
|
||||
expect(view?.buzzes).toEqual([
|
||||
{ teamId: "lions", name: "Lions", color: "#E63946", afterFirstMs: 0, result: null },
|
||||
]);
|
||||
expect(view?.buzzes.map((entry) => entry.teamId)).toEqual(["lions"]);
|
||||
expect(view?.answeringTeamId).toBe("lions");
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkMark", () => {
|
||||
const waiting = order(["lions"], ["eagles"]);
|
||||
|
||||
it("lets the host mark the answering team, and a correct answer earns a point", () => {
|
||||
expect(checkMark(waiting, "lions", "correct")).toEqual({ ok: true, changed: true, pointDelta: 1 });
|
||||
expect(checkMark(waiting, "lions", "wrong")).toEqual({ ok: true, changed: true, pointDelta: 0 });
|
||||
});
|
||||
|
||||
it("refuses a team that hasn't buzzed or whose turn it isn't", () => {
|
||||
expect(checkMark(waiting, "sharks", "correct").ok).toBe(false);
|
||||
expect(checkMark(waiting, "eagles", "correct").ok).toBe(false);
|
||||
// Decided already: nobody else can be marked.
|
||||
expect(checkMark(order(["lions", "correct"], ["eagles"]), "eagles", "wrong").ok).toBe(false);
|
||||
});
|
||||
|
||||
it("moves on to the next team after a wrong answer", () => {
|
||||
const afterWrong = order(["lions", "wrong"], ["eagles"]);
|
||||
expect(checkMark(afterWrong, "eagles", "correct")).toEqual({ ok: true, changed: true, pointDelta: 1 });
|
||||
expect(checkMark(afterWrong, "lions", "correct").ok).toBe(false);
|
||||
});
|
||||
|
||||
it("does nothing when the mark is already what was asked (a double click)", () => {
|
||||
expect(checkMark(order(["lions", "correct"]), "lions", "correct")).toEqual({ ok: true, changed: false, pointDelta: 0 });
|
||||
expect(checkMark(waiting, "lions", null)).toEqual({ ok: true, changed: false, pointDelta: 0 });
|
||||
});
|
||||
|
||||
it("undoes a mark, taking the point back if it was correct", () => {
|
||||
expect(checkMark(order(["lions", "correct"]), "lions", null)).toEqual({ ok: true, changed: true, pointDelta: -1 });
|
||||
expect(checkMark(order(["lions", "wrong"], ["eagles"]), "lions", null)).toEqual({
|
||||
ok: true,
|
||||
changed: true,
|
||||
pointDelta: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("bumpScore", () => {
|
||||
it("adds a point, starting from zero when there is no score yet", () => {
|
||||
expect(bumpScore(null, 1, 0)).toBe(1);
|
||||
expect(bumpScore(4, 1, 0)).toBe(5);
|
||||
});
|
||||
|
||||
it("takes the point back, clearing the cell rather than going below the minimum", () => {
|
||||
expect(bumpScore(5, -1, 0)).toBe(4);
|
||||
expect(bumpScore(1, -1, 0)).toBe(0);
|
||||
expect(bumpScore(1, -1, 1)).toBeNull();
|
||||
expect(bumpScore(null, -1, 0)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+65
-15
@@ -5,21 +5,28 @@ export const buzzerMessages = {
|
||||
linkOff: "This link has been turned off. Ask the host for a new one.",
|
||||
noGame: "No buzzer for this game. Keep this page open.",
|
||||
closed: "This game is finished, so the buzzers are closed.",
|
||||
answered: "This question has been answered. Wait for the next one.",
|
||||
} as const;
|
||||
|
||||
/** How the host marked a team's answer; null until they do. */
|
||||
export type BuzzResult = "correct" | "wrong" | null;
|
||||
|
||||
export type BuzzEntry = {
|
||||
teamId: string;
|
||||
name: string;
|
||||
color: string;
|
||||
/** How long after the first buzz this team buzzed; 0 for the first. */
|
||||
afterFirstMs: number;
|
||||
result: BuzzResult;
|
||||
};
|
||||
|
||||
export type BuzzerView = {
|
||||
/** Teams can buzz right now. False once the game is marked done. */
|
||||
/** Teams can buzz right now. False once someone got it, or the game is marked done. */
|
||||
open: boolean;
|
||||
/** In the order the teams buzzed. Empty until someone does, and again after a clear. */
|
||||
buzzes: BuzzEntry[];
|
||||
/** Whose turn it is to answer, or null: nobody is waiting, or the question is decided. */
|
||||
answeringTeamId: string | null;
|
||||
};
|
||||
|
||||
/** What a team's phone gets. Nothing here is a score, so it ignores scoreboard visibility. */
|
||||
@@ -34,12 +41,22 @@ export type BuzzerPayload = {
|
||||
};
|
||||
|
||||
type GameFacts = { buzzer_enabled: boolean; status: string };
|
||||
type BuzzFacts = { team_id: string; result: BuzzResult };
|
||||
|
||||
/** Buzzers work only for the current game, only if it uses them, and only until it's done. */
|
||||
export function buzzerOpen(game: GameFacts | null | undefined): boolean {
|
||||
return Boolean(game?.buzzer_enabled) && game?.status !== "done";
|
||||
}
|
||||
|
||||
/**
|
||||
* The team whose turn it is: the earliest buzz the host hasn't marked. A wrong answer passes
|
||||
* the turn down the order; a correct one ends the question. `buzzes` must be in buzz order.
|
||||
*/
|
||||
export function answeringTeamId(buzzes: BuzzFacts[]): string | null {
|
||||
if (buzzes.some((buzz) => buzz.result === "correct")) return null;
|
||||
return buzzes.find((buzz) => buzz.result === null)?.team_id ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The buzzer state for the current game, or null when it doesn't use buzzers.
|
||||
* `buzzes` must already be in buzz order (the database's clock decides, see 0003_buzzer.sql).
|
||||
@@ -47,29 +64,62 @@ export function buzzerOpen(game: GameFacts | null | undefined): boolean {
|
||||
export function buzzerView(
|
||||
game: GameFacts | null | undefined,
|
||||
teams: Array<{ id: string; name: string; color: string }>,
|
||||
buzzes: Array<{ team_id: string; buzzed_at: string }>,
|
||||
buzzes: Array<BuzzFacts & { buzzed_at: string }>,
|
||||
): BuzzerView | null {
|
||||
if (!game?.buzzer_enabled) return null;
|
||||
|
||||
const teamsById = new Map(teams.map((team) => [team.id, team]));
|
||||
const firstAt = buzzes.length ? Date.parse(buzzes[0].buzzed_at) : 0;
|
||||
// A deleted team's buzzes are deleted with it; this only covers the moment in between.
|
||||
const known = buzzes.filter((buzz) => teamsById.has(buzz.team_id));
|
||||
const firstAt = known.length ? Date.parse(known[0].buzzed_at) : 0;
|
||||
|
||||
return {
|
||||
open: buzzerOpen(game),
|
||||
buzzes: buzzes.flatMap((buzz) => {
|
||||
const team = teamsById.get(buzz.team_id);
|
||||
if (!team) return [];
|
||||
return [
|
||||
{
|
||||
teamId: team.id,
|
||||
name: team.name,
|
||||
color: team.color,
|
||||
afterFirstMs: Math.max(0, Date.parse(buzz.buzzed_at) - firstAt),
|
||||
},
|
||||
];
|
||||
open: buzzerOpen(game) && !known.some((buzz) => buzz.result === "correct"),
|
||||
buzzes: known.map((buzz) => {
|
||||
const team = teamsById.get(buzz.team_id)!;
|
||||
return {
|
||||
teamId: team.id,
|
||||
name: team.name,
|
||||
color: team.color,
|
||||
afterFirstMs: Math.max(0, Date.parse(buzz.buzzed_at) - firstAt),
|
||||
result: buzz.result,
|
||||
};
|
||||
}),
|
||||
answeringTeamId: answeringTeamId(known),
|
||||
};
|
||||
}
|
||||
|
||||
export type MarkCheck =
|
||||
| { ok: true; changed: boolean; pointDelta: -1 | 0 | 1 }
|
||||
| { ok: false; error: string };
|
||||
|
||||
/**
|
||||
* May the host mark this team's buzz as `result`, and does the team's score move?
|
||||
* Marking correct or wrong is only for the team answering; `null` undoes an earlier mark.
|
||||
*/
|
||||
export function checkMark(buzzes: BuzzFacts[], teamId: string, result: BuzzResult): MarkCheck {
|
||||
const buzz = buzzes.find((candidate) => candidate.team_id === teamId);
|
||||
if (!buzz) return { ok: false, error: "That team hasn't buzzed for this question. Reload the page." };
|
||||
if (buzz.result === result) return { ok: true, changed: false, pointDelta: 0 };
|
||||
if (result !== null && answeringTeamId(buzzes) !== teamId) {
|
||||
return { ok: false, error: "It isn't that team's turn to answer. Reload the page." };
|
||||
}
|
||||
|
||||
const pointDelta = Number(result === "correct") - Number(buzz.result === "correct");
|
||||
return { ok: true, changed: true, pointDelta: pointDelta as -1 | 0 | 1 };
|
||||
}
|
||||
|
||||
/**
|
||||
* A team's score in the buzzer criterion after gaining a point for a correct answer, or
|
||||
* losing it again when the host undoes that. null = no score. Taking the point back never
|
||||
* goes below the criterion's minimum: the cell is cleared instead.
|
||||
*/
|
||||
export function bumpScore(current: number | null, pointDelta: -1 | 1, minValue: number): number | null {
|
||||
if (pointDelta === 1) return (current ?? 0) + 1;
|
||||
if (current === null) return null;
|
||||
return current - 1 < minValue ? null : current - 1;
|
||||
}
|
||||
|
||||
/** "+0.42s" behind the first buzz, for the host's list. */
|
||||
export function formatBuzzGap(afterFirstMs: number): string {
|
||||
return `+${(afterFirstMs / 1000).toFixed(2)}s`;
|
||||
|
||||
@@ -63,18 +63,21 @@ export type Database = {
|
||||
buzzed_at: string;
|
||||
game_id: string;
|
||||
id: string;
|
||||
result: Database["public"]["Enums"]["buzz_result"] | null;
|
||||
team_id: string;
|
||||
};
|
||||
Insert: {
|
||||
buzzed_at?: string;
|
||||
game_id: string;
|
||||
id?: string;
|
||||
result?: Database["public"]["Enums"]["buzz_result"] | null;
|
||||
team_id: string;
|
||||
};
|
||||
Update: {
|
||||
buzzed_at?: string;
|
||||
game_id?: string;
|
||||
id?: string;
|
||||
result?: Database["public"]["Enums"]["buzz_result"] | null;
|
||||
team_id?: string;
|
||||
};
|
||||
Relationships: [
|
||||
@@ -430,6 +433,7 @@ export type Database = {
|
||||
[_ in never]: never;
|
||||
};
|
||||
Enums: {
|
||||
buzz_result: "correct" | "wrong";
|
||||
criterion_input: "blocks" | "number";
|
||||
game_status: "upcoming" | "live" | "done";
|
||||
judge_source: "nominated" | "open_link";
|
||||
|
||||
@@ -67,13 +67,37 @@ describe("soundsForUpdate", () => {
|
||||
expect(soundsForUpdate(payload(reveal(["a", "b", "c"])), payload(), motion)).toEqual([]);
|
||||
});
|
||||
|
||||
it("dings for a new first buzz only", () => {
|
||||
const buzzed = (...ids: string[]): Partial<ScoreboardPayload> => ({
|
||||
buzzer: {
|
||||
open: true,
|
||||
buzzes: ids.map((teamId, index) => ({ teamId, name: teamId, color: "#000000", afterFirstMs: index * 100 })),
|
||||
},
|
||||
// Team ids in buzz order; "a:wrong" / "a:correct" is a buzz the host has marked.
|
||||
const buzzed = (...marks: string[]): Partial<ScoreboardPayload> => {
|
||||
const buzzes = marks.map((mark, index) => {
|
||||
const [teamId, result = null] = mark.split(":") as [string, ("correct" | "wrong")?];
|
||||
return { teamId, name: teamId, color: "#000000", afterFirstMs: index * 100, result };
|
||||
});
|
||||
const decided = buzzes.some((entry) => entry.result === "correct");
|
||||
return {
|
||||
buzzer: {
|
||||
open: !decided,
|
||||
buzzes,
|
||||
answeringTeamId: decided ? null : (buzzes.find((entry) => entry.result === null)?.teamId ?? null),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
it("plays the verdict when the host marks an answer", () => {
|
||||
expect(soundsForUpdate(payload(buzzed("a")), payload(buzzed("a:correct")), motion)).toEqual(["correct"]);
|
||||
// Wrong with the next team already waiting: just the "wrong", no ding on top.
|
||||
expect(soundsForUpdate(payload(buzzed("a", "b")), payload(buzzed("a:wrong", "b")), motion)).toEqual(["wrong"]);
|
||||
expect(soundsForUpdate(payload(buzzed("a")), payload(buzzed("a:wrong")), motion)).toEqual(["wrong"]);
|
||||
// Already marked: silent on the next poll.
|
||||
expect(soundsForUpdate(payload(buzzed("a:correct")), payload(buzzed("a:correct")), motion)).toEqual([]);
|
||||
});
|
||||
|
||||
it("dings when a team buzzes in after everyone before it was wrong, or a mark is undone", () => {
|
||||
expect(soundsForUpdate(payload(buzzed("a:wrong")), payload(buzzed("a:wrong", "b")), motion)).toEqual(["buzzIn"]);
|
||||
expect(soundsForUpdate(payload(buzzed("a:correct")), payload(buzzed("a")), motion)).toEqual(["buzzIn"]);
|
||||
});
|
||||
|
||||
it("dings for a new first buzz only", () => {
|
||||
expect(soundsForUpdate(payload(buzzed()), payload(buzzed("a")), motion)).toEqual(["buzzIn"]);
|
||||
// A second team buzzing, or nothing changing, is silent.
|
||||
expect(soundsForUpdate(payload(buzzed("a")), payload(buzzed("a", "b")), motion)).toEqual([]);
|
||||
|
||||
@@ -33,9 +33,16 @@ export function soundsForUpdate(
|
||||
}
|
||||
}
|
||||
|
||||
// A new first buzz. Later buzzes are silent: the room only needs to hear who won the race.
|
||||
const firstBuzz = after.buzzer?.buzzes[0]?.teamId;
|
||||
if (firstBuzz && firstBuzz !== before.buzzer?.buzzes[0]?.teamId) sounds.push("buzzIn");
|
||||
// Buzzers: the host's verdict if there is a new one, otherwise a ding for a new team
|
||||
// answering. Teams that buzz in behind it are silent: the room only needs to hear who's up.
|
||||
const marksBefore = new Map(before.buzzer?.buzzes.map((entry) => [entry.teamId, entry.result]));
|
||||
const newlyMarked = (result: "correct" | "wrong") =>
|
||||
after.buzzer?.buzzes.some((entry) => entry.result === result && marksBefore.get(entry.teamId) !== result) ?? false;
|
||||
const answering = after.buzzer?.answeringTeamId;
|
||||
if (newlyMarked("correct")) sounds.push("correct");
|
||||
// The next team's turn starts with this same update; one sound, not two on top of each other.
|
||||
else if (newlyMarked("wrong")) sounds.push("wrong");
|
||||
else if (answering && answering !== before.buzzer?.answeringTeamId) sounds.push("buzzIn");
|
||||
|
||||
return sounds;
|
||||
}
|
||||
|
||||
@@ -185,7 +185,8 @@ describe("timer", () => {
|
||||
describe("buzzer", () => {
|
||||
const buzzer = {
|
||||
open: true,
|
||||
buzzes: [{ teamId: "sharks", name: "Sharks", color: "#2EC4B6", afterFirstMs: 0 }],
|
||||
buzzes: [{ teamId: "sharks", name: "Sharks", color: "#2EC4B6", afterFirstMs: 0, result: null }],
|
||||
answeringTeamId: "sharks",
|
||||
};
|
||||
|
||||
it("is null when the current game has no buzzers", () => {
|
||||
|
||||
@@ -11,6 +11,8 @@ export type SoundName =
|
||||
| "crash" // a team appears with no drum-roll (reduced motion)
|
||||
| "game" // a new game starts
|
||||
| "buzzIn" // a team is first on the buzzer
|
||||
| "correct" // the answering team got it
|
||||
| "wrong" // the answering team got it wrong
|
||||
| "chime"; // confirmation when sound is switched on
|
||||
|
||||
/** Matches the scoreboard's reveal animation: the cover lifts 70% into 1800ms. */
|
||||
@@ -142,6 +144,18 @@ export function createSoundPlayer(): SoundPlayer {
|
||||
tone(start + 0.11, 1318.51, 0.45, 0.4, "square");
|
||||
tone(start + 0.11, 2637.02, 0.3, 0.1, "sine");
|
||||
},
|
||||
correct: (start) => {
|
||||
// A quick rising arpeggio that lands on a bright chord.
|
||||
[659.25, 830.61, 987.77, 1318.51].forEach((frequency, index) => {
|
||||
tone(start + index * 0.07, frequency, index === 3 ? 0.7 : 0.18, 0.3, "triangle");
|
||||
tone(start + index * 0.07, frequency * 2, index === 3 ? 0.5 : 0.12, 0.08, "sine");
|
||||
});
|
||||
},
|
||||
wrong: (start) => {
|
||||
// Two short falling notes: "uh-oh". Shorter and higher than the time's-up buzzer.
|
||||
tone(start, 311.13, 0.22, 0.4, "sawtooth");
|
||||
tone(start + 0.24, 233.08, 0.45, 0.4, "sawtooth");
|
||||
},
|
||||
chime: (start) => {
|
||||
tone(start, 783.99, 0.35, 0.3, "sine");
|
||||
tone(start + 0.12, 1046.5, 0.5, 0.3, "sine");
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
-- Game Night Live — marking buzzer answers.
|
||||
-- The host marks the answering team's buzz correct or wrong. Run after 0003_buzzer.sql.
|
||||
--
|
||||
-- result null : not judged yet. The first such buzz (in buzz order) is the team answering,
|
||||
-- as long as nobody has been marked correct.
|
||||
-- result wrong : that team is out for this question; the turn passes to the next buzz.
|
||||
-- result correct : the question is decided; no more buzzes until the host clears.
|
||||
|
||||
create type buzz_result as enum ('correct', 'wrong');
|
||||
|
||||
alter table buzzes
|
||||
add column result buzz_result;
|
||||
Reference in New Issue
Block a user