5 Commits
Author SHA1 Message Date
joshuaandClaude Opus 5.5 825eb14f2e Phase 13: questions, sounds and videos on the scoreboard
A game can now carry a list of items the host puts on the scoreboard one at
a time: a question (shown above the standings, with an optional picture and
an answer to reveal), a sound (played through the scoreboard's speakers) or
a video such as a karaoke track (takes over the screen).

- Media is a YouTube link, an https link to a file, or an upload to the
  private game-media bucket. The scoreboard API signs a URL only for the
  item that is on screen.
- What is on screen is state on the event, like the timer, so the ping
  still carries no data. An unrevealed answer and a sound's or video's name
  never reach the scoreboard payload.
- Control room: click an item to show it, or "Random" to have one picked
  (prefers items not played yet). Show/hide answer, pause, take off screen;
  shortcuts Q / A / X.
- Duplicating a game or event copies its items and files; deleting removes
  the files.

Needs supabase/migrations/0006_screen_items.sql applied before deploying.
database.types.ts was edited by hand to match; re-run gen:types after.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 10:54:08 +02:00
joshuaandClaude Opus 5.5 c27a3890f8 Phase 12: share an event with co-hosts
The owner shares an event by email; a co-host gets full access inside it
but can't delete it or change who it's shared with. Shares are keyed by
email so an admin can be added before their first login, and RLS (not the
UI) is what enforces the owner-only parts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 10:14:29 +02:00
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
joshuaandClaude Opus 5.5 bbcf84bbc3 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>
2026-10-05 09:33:44 +02:00
joshuaandClaude Opus 5.5 48cecfff7f Fix magic link redirect to localhost behind a reverse proxy
After verifying the link, /auth/confirm built its redirect from
request.nextUrl. Behind a proxy or tunnel a route handler sees the
address the server is bound to (localhost:4322), so the browser was
sent off the public domain and away from the session cookie it had
just been given. Build the redirect from NEXT_PUBLIC_SITE_URL instead,
the same value the magic link email already uses.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 08:56:53 +02:00
55 changed files with 3737 additions and 135 deletions
+73 -7
View File
@@ -8,7 +8,7 @@ Next.js (App Router) on Vercel · Supabase (Postgres + Auth + Realtime Broadcast
|---|---|---|
| `/login` | admin | Supabase magic link |
| `/admin` | admin | Events list |
| `/admin/events/[eventId]` | admin | Setup: teams, games, criteria, judges, share links |
| `/admin/events/[eventId]` | admin | Setup: teams, games, criteria, judges, share links, co-hosts |
| `/admin/events/[eventId]/live` | admin | Control room |
| `/s/[scoreboardToken]` | public | Scoreboard |
| `/j/[judgeToken]` | judge | Personal judge page |
@@ -17,28 +17,38 @@ Next.js (App Router) on Vercel · Supabase (Postgres + Auth + Realtime Broadcast
| `GET /api/judge/[token]` | judge | Game, criteria, teams, this judge's scores |
| `PUT /api/judge/[token]/scores` | judge | Upsert one score `{teamId, criterionId, value}` |
| `POST /api/judge-open/[openToken]` | judge | `{name}` → `{judgeToken}` |
| `/b/[buzzerToken]` | team | Team buzzer page |
| `GET /api/buzzer/[token]` | team | Team, current game, buzz order |
| `POST /api/buzzer/[token]/buzz` | team | Buzz in (no body) → the same payload |
Admin mutations = server actions. `src/proxy.ts` (Next.js 16's name for middleware) protects `/admin/*`: must be logged in AND email in `ADMIN_EMAILS`. Every server action re-checks via `requireAdmin()`.
## Supabase clients (`src/lib/supabase/`)
- `browser.ts` — anon key, used for Realtime subscribe + admin auth session.
- `browser.ts` — anon key, used for Realtime subscribe + admin auth session (which is also what
uploads an admin's media file straight to storage).
- `server.ts` — cookie-based, for admin server actions (RLS applies as the admin user).
- `admin.ts` — service role, **server-only** (`import 'server-only'`). Used by public/judge route handlers after token validation.
## Security model
- RLS ON for every table. Policies only for the authenticated event owner. Anon role has no policies → no direct table access.
- RLS ON for every table. Policies only for the authenticated event owner and the co-hosts the owner shared it with. Anon role has no policies → no direct table access.
- Co-hosts are rows in `event_cohosts`, matched on the email in the login token. They get the same access as the owner inside the event; only the owner can delete the event or change its co-hosts (enforced by RLS — the UI just hides those buttons). Sharing is refused for an email that isn't in `ADMIN_EMAILS`, since that person couldn't log in.
- 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`.
- Light rate limiting on judge PUT (20 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.
- 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.
- Uploaded media lives in the private `game-media` bucket under `{eventId}/…`. Storage policies
allow the event's admins only; anon has none. The scoreboard API hands out a signed URL (12 h)
for the one item that is on screen, and nothing else. Links (YouTube, files) must be `https://`.
- 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")
Channels (names use tokens/ids; payload is always just `{ type: "refresh", scope }`):
- `sb:{scoreboardToken}` — scoreboard listens
- `game:{gameId}` — all judges of that game listen (pinged for lock, spotlight, revoke and game edits — not for other judges' scores, which they never see)
- `admin:{eventId}` — admin control room listens (judge submissions land here)
- `admin:{eventId}` — admin control room listens (judge submissions and buzzes land here)
- `buzzer:{eventId}` — team buzzer pages listen (pinged for buzzes, clears, game switches and edits to games/teams — not for scores)
`src/lib/realtime.ts` exposes `notify({ eventId, gameId? })` which looks up the scoreboard token and sends to the right channels from the server.
`src/lib/realtime.ts` exposes `notify({ eventId, gameId?, buzzer? })` which looks up the scoreboard token and sends to the right channels from the server. `buzzer: true` adds the buzzer channel; score saves leave it out so they don't cost an extra request.
Use Supabase's server-side broadcast (REST endpoint / `httpSend` — check current docs).
Throttle refetches client-side: the first ping refetches immediately, further pings within ~150ms collapse into one trailing refetch, so a burst of taps causes at most two fetches.
Clients also poll every 10s as a safety net, and refetch on reconnect / tab focus.
@@ -66,9 +76,27 @@ type ScoreboardPayload = {
endsAt: string | null; // set while running
durationMs: number;
} | null;
// null unless the current game uses buzzers; sent regardless of score visibility
buzzer: {
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;
// null with nothing on screen; sent regardless of score visibility (see "On screen")
onScreen:
| { kind: 'question'; id: string; shownAt: string; text: string;
imageUrl: string | null; answer: string | null } // answer only once revealed
| { kind: 'sound'; id: string; shownAt: string; media: ScreenMedia; paused: boolean;
answer: string | null }
| { kind: 'video'; id: string; shownAt: string; media: ScreenMedia; paused: boolean }
| null;
updatedAt: string; // server clock; screens use it to correct their own
};
```
`ScreenMedia` = `{ type: 'youtube'; videoId; startSeconds }` or `{ type: 'file'; url }`.
During reveal mode, teams not yet revealed are sent without rank/total, alphabetically and before the revealed ones, so their order leaks nothing.
When frozen, rank/total come from `frozen_snapshot` but `currentGame` stays live. A missing or invalid snapshot is treated as hidden, never as live.
@@ -80,11 +108,49 @@ When frozen, rank/total come from `frozen_snapshot` but `currentGame` stays live
change triggers is decided by pure functions in `src/lib/scoreboard-sounds.ts`; the
scoreboard compares each new payload with the previous one. No sound data travels over Realtime.
## Buzzers
- A buzz is a row in `buzzes`, stamped by the database's clock (`clock_timestamp()`), so the order
doesn't depend on phone clocks or on which server instance took the request. Network delay
between a thumb and the database still counts — fair on shared Wi-Fi, not tournament-grade.
- `src/lib/buzzer.ts` holds the rules as pure functions (`buzzerView` builds the same
`{ open, buzzes }` for the team page, the control room and the scoreboard);
`src/lib/buzzer.server.ts` does the token-gated reads and the write.
- 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.
## On screen (questions, sounds, videos)
- A game's items are rows in `game_items`. What is on screen is state on `events`
(`screen_item_id`, `screen_shown_at`, `screen_answer_shown`, `screen_paused`), like the timer.
An item only counts while its game is the current game; switching game clears it.
- `src/lib/screen.ts` holds the rules as pure functions: `buildOnScreen` (the sanitised payload
part — no unrevealed answer, no sound/video name), `parseYouTube`, `pickRandom`,
`nextQuestion`, and the upload checks. Server actions in `live-actions.ts` (show, random,
answer, pause, clear) and `item-actions.ts` (setup CRUD).
- Rule 4 still holds: the ping carries nothing; the scoreboard refetches and plays what the
payload says. Each player is mounted with a key of item id + `shownAt`, so showing an item
again restarts it, and it keeps the media URL it started with (a signed URL differs in every
payload; following it would restart the file on every refetch).
- Playback: files use `<video>` / `<audio>`; YouTube uses the `youtube-nocookie.com` embed, with
mute and pause sent as `postMessage` player commands. The control room decides play/pause and
restart only — there is no shared playback position, so a scoreboard opened mid-song starts
the song from the beginning.
- Uploads go from the admin's browser to storage (a server action can't carry 50 MB); the
action then records the path, after checking it is inside that event's folder. Deleting an
item, game or event deletes its files; duplicating copies them.
## Folder layout
```
src/
app/ (routes above)
components/ (ui/, scoreboard/, judge/, admin/)
components/ (ui/, scoreboard/, judge/, buzzer/, admin/)
lib/
scoring/ (index.ts, *.test.ts)
supabase/ (browser.ts, server.ts, admin.ts)
+36 -1
View File
@@ -65,5 +65,40 @@ human a short "how to test this manually" checklist. Don't start the next phase
- Scoreboard sound effects: reveal drum-roll + crash/fanfare, game-change chime, last-10-seconds ticks + buzzer; "Turn sound on" button.
**Done when:** a timer started in the control room counts down on the scoreboard with ticks and a buzzer, and a reveal plays its drum-roll.
## Phase 10 — Team buzzers
- Apply `supabase/migrations/0003_buzzer.sql`, then `npm run gen:types` and `npm run check:rls`.
- Buzzer rules in `src/lib/buzzer.ts` (tested); `/b/[token]` phone page + `GET`/`POST` buzzer API.
- "Teams buzz in" checkbox per game; buzzer links per team in the share panel.
- Control room: buzz order + "Clear buzzers" (`B`). Scoreboard: "{Team} buzzed!" banner + ding.
**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.
## Phase 12 — Co-hosts
- Apply `supabase/migrations/0005_cohosts.sql`, then `npm run gen:types` and `npm run check:rls`.
- `event_cohosts` table; RLS widened from owner to owner-or-co-host, with delete and sharing owner-only.
- "Co-hosts" section on the setup page (`addCohost` / `removeCohost` server actions); "Shared with you" in the events list.
**Done when:** admin A shares an event with admin B's email; B sees it in their list, can edit it and
run the control room alongside A, but has no Delete button and can't change the co-hosts; removing B
makes the event disappear from B's list.
## Phase 13 — Questions, sounds and videos on the scoreboard
- Apply `supabase/migrations/0006_screen_items.sql` (table, event columns, the `game-media`
bucket and its policies), then `npm run gen:types` and `npm run check:rls`.
- Rules in `src/lib/screen.ts` (tested); setup editor per game with links and uploads;
control-room panel (show, random, answer, pause, take off screen; `Q` / `A` / `X`).
- Scoreboard: question above the standings, sound banner, video takeover.
**Done when:** a quiz game shows its questions one by one with `Q` and reveals an answer with `A`
(the answer is not in the scoreboard's network tab before that); an uploaded mp3 plays on the
scoreboard; a karaoke game with several YouTube links plays the one I click full screen, "Random
video" picks one that hasn't been played, and "Take off screen" brings the standings back.
## Stretch (only if asked)
Participants within teams · co-host admins · CSV export of results.
Participants within teams · CSV export of results.
+11 -4
View File
@@ -1,20 +1,27 @@
# Data model
Full SQL in `supabase/migrations/0001_init.sql`.
Full SQL in `supabase/migrations/` (`0001_init.sql`, then `0002_timer.sql`, `0003_buzzer.sql`, `0004_buzz_result.sql`, `0005_cohosts.sql`, `0006_screen_items.sql`).
| Table | Purpose | Key columns |
|---|---|---|
| `events` | A game night | `owner_id`, `scoreboard_token`, `scoreboard_visibility`, `frozen_snapshot`, `current_game_id`, `reveal_active`, `reveal_count`, `timer_visible`, `timer_duration_ms`, `timer_remaining_ms`, `timer_ends_at` |
| `teams` | Teams in an event | `name`, `color` (#RRGGBB), `sort_order` |
| `games` | Activities | `scoring_mode`, `points_mode`, `multiplier`, `placement_points int[]`, `status`, `admin_notes`, `judge_notes`, `open_token`, `open_link_enabled`, `judging_locked`, `spotlight_team_id` |
| `events` | A game night | `owner_id`, `scoreboard_token`, `scoreboard_visibility`, `frozen_snapshot`, `current_game_id`, `reveal_active`, `reveal_count`, `timer_visible`, `timer_duration_ms`, `timer_remaining_ms`, `timer_ends_at`, `screen_item_id`, `screen_shown_at`, `screen_answer_shown`, `screen_paused` |
| `teams` | Teams in an event | `name`, `color` (#RRGGBB), `sort_order`, `buzzer_token` |
| `games` | Activities | `scoring_mode`, `points_mode`, `multiplier`, `placement_points int[]`, `status`, `admin_notes`, `judge_notes`, `open_token`, `open_link_enabled`, `judging_locked`, `spotlight_team_id`, `buzzer_enabled` |
| `criteria` | Scoring blocks per game | `label`, `description`, `input_type`, `min_value`, `max_value`, `step`, `weight` |
| `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`, `result` (correct/wrong/NULL) |
| `event_cohosts` | Admins an event is shared with | `event_id`, `email` (lower case), unique together |
| `game_items` | Questions, sounds and videos a game can put on the scoreboard | `game_id`, `kind` (question/sound/video), `title`, `answer`, `media_url`, `storage_path`, `sort_order`, `played_at` |
## 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.
- In **judged** games, an admin row (`judge_id IS NULL`) is an **override** for that cell.
- 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.
- Co-hosts (`0005_cohosts.sql`): a share is keyed by email, not user id, so an admin can be added before they have ever logged in. RLS matches it against the email in the login token. `is_event_admin(event)` = owner or co-host and guards every table inside an event; deleting the event and editing `event_cohosts` stay owner-only. A trigger stops `events.owner_id` from changing.
- On-screen items (`0006_screen_items.sql`): `title` is the question's text, or the host's name for a sound or video. Media is `media_url` (https link) or `storage_path` (a file in the private `game-media` storage bucket, `{event id}/{uuid}.{ext}`) — never both; a sound or video needs one of them. `played_at` is set when an item goes on screen and only feeds the random pick. `events.screen_item_id` is the item on screen (set null if the item is deleted); `screen_shown_at` changes on every show so screens restart it. The bucket has storage policies for the event's admins only, a 50 MB limit and audio/video/image types.
- `frozen_snapshot` holds the `ScoreboardPayload` captured at freeze time.
+83 -5
View File
@@ -7,9 +7,10 @@ live scoreboard, and judges score from their phones via links. Versatile enough
## 2. Roles
| Role | Access | How they get in |
|---|---|---|
| **Admin** | Everything | Logs in (magic link). Email must be in `ADMIN_EMAILS`. |
| **Admin** | Everything in the events they own or co-host | Logs in (magic link). Email must be in `ADMIN_EMAILS`. |
| **Scoreboard viewer** | Read-only scoreboard for ONE event | Public link `/s/{scoreboardToken}` |
| **Judge** | Score ONE game | Personal link `/j/{judgeToken}` OR open game link `/jo/{openToken}` (enters name first) |
| **Team** | Buzz in for ONE team | Team buzzer link `/b/{buzzerToken}`, one per team for the whole night |
## 3. Core concepts
- **Event** — one game night. Has teams, games, one scoreboard link, visibility state.
@@ -38,11 +39,20 @@ live scoreboard, and judges score from their phones via links. Versatile enough
- Criteria list: add/remove/reorder; label, description, input type, min/max/step, weight.
- Quick presets: "Karaoke (5 × 1–5)", "Single score (0–10)", "Points (number)", "Win/Lose (0–1)".
- Admin notes + judge notes (multiline).
- "Teams buzz in" checkbox: this game uses the team buzzers (see 4.7).
- Questions, sounds and videos to put on the scoreboard during the game (see 4.8).
- Judges (judged games only):
- Nominate judges by name → each gets a personal link (copy button + QR code).
- Toggle the open link on/off; anyone opening it types their name and becomes a judge.
- Revoke a judge (link stops working, their scores excluded).
- Share panel: scoreboard link + QR, all judge links per game.
- Share panel: scoreboard link + QR, all judge links per game, and one buzzer link + QR per team
(shown once any game uses buzzers).
- Co-hosts: the admin who created an event (its owner) shares it with other admins by email.
- The email must already be in `ADMIN_EMAILS`; it can be added before that admin's first login.
- A co-host sees the event in their list ("Shared with you") and can do everything in it —
setup, control room, scores — except delete it or change who it is shared with.
- The owner can remove a co-host at any time. Duplicating a shared event gives the co-host
their own copy, which they own.
### 4.2 Admin — Control room (`/admin/events/[id]/live`)
This is the screen used during the night. Must be fast and hard to mess up.
@@ -62,7 +72,13 @@ 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).
- Keyboard shortcuts: `N` next game, `P` prev game, `H` cycle visibility, `T` start/pause timer, `?` help.
- 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".
- On the scoreboard (games with questions, sounds or videos): the game's list, a button per item
to show or play it, "Random" per kind, show/hide answer, pause/resume, take off screen (see 4.8).
- Keyboard shortcuts: `N` next game, `P` prev game, `H` cycle visibility, `T` start/pause timer,
`G` got it, `W` wrong, `B` next question (clears the buzzers), `Q` show the next question,
`A` show/hide the answer, `X` take it off screen, `?` help.
### 4.3 Scoreboard (`/s/[token]`)
- Projector-first, 16:9, readable from the back of a hall.
@@ -106,8 +122,66 @@ 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 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.
- 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 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.
### 4.8 Questions, sounds and videos on the scoreboard
A game can carry a list of things the host puts on the scoreboard, one at a time.
- Three kinds:
- **Question** — text, an optional picture and an optional answer. Shows above the standings.
- **Sound** — plays through the scoreboard's speakers; the screen only says "Listen…".
Optional answer (e.g. the song's name).
- **Video** — a karaoke track or a clip. Takes over the whole screen until taken off.
- Media is a link or an upload:
- a YouTube link (for a sound, only the audio is used), or an `https://` link to a file;
- or a file uploaded from the setup page: image for a question, audio for a sound, video for
a video, up to 50 MB each.
- Setup: per game, add / edit / reorder / delete items. Duplicating a game or an event copies
them, uploaded files included.
- Control room: the current game's list. Click an item to put it on screen; clicking the one
already showing starts it again. Only one item is on screen at a time, and switching game
clears it.
- **Random** (per kind, when there are at least two): picks one for the host — e.g. the next
karaoke song. It prefers items that haven't been shown yet and never repeats the one on
screen. Shown items get a ✓; "Clear the ✓ ticks" starts over.
- **Show answer** (`A`) reveals the answer under the question or sound; again to hide it.
- **Pause / Resume** for a sound or video. **Take off screen** (`X`) returns to the scoreboard.
- `Q` shows the next question in list order.
- Nothing here is a score, so it shows whether scores are visible, frozen or hidden.
- Secrets stay on the server: an answer is not sent to the scoreboard until it is revealed, and a
sound's or video's name is never sent (it is often the answer).
- Sound needs the same "Turn sound on" click as the sound effects (4.6). Until then a video
plays muted and the screen says the sound is off.
## 5. Non-functional
- Live latency target < 1s on normal Wi-Fi.
- Works with ~10 teams, ~20 games, ~10 judges per game without lag.
@@ -116,5 +190,9 @@ Played by the scoreboard page, so they come out of the projector PC's speakers.
## 6. Out of scope (v1)
- Individual contestants within teams (stretch: `participants` per team).
- Multiple admins / co-hosts (stretch).
- Per-game timers and buzzers for contestants (stretch). The event countdown timer is in 4.5.
- Transferring an event to another owner, and read-only co-hosts.
- Per-game timers (stretch). The event countdown timer is in 4.5; team buzzers are in 4.7.
- Buzzers for individual contestants, a configurable points value per correct answer, and
penalties for a wrong answer (use the bonus/penalty panel).
- Multiple-choice questions, questions on the judges' or teams' phones, importing a list of
questions from a file, and seeking within a video from the control room.
+49
View File
@@ -75,6 +75,55 @@ Collapses to stacked panels on tablet.
```
Selected block filled in team colour. Tap the selected block again to clear.
### Buzzer (phone)
```
┌─────────────────────────┐
│ ● Lions │
│ Bible quiz │
│ │
│ ╭─────────╮ │
│ │ │ │
│ │ Buzz │ │
│ │ │ │
│ ╰─────────╯ │
│ │
│ Ready. Tap to buzz in. │
└─────────────────────────┘
```
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"), 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 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".
### On screen: questions, sounds, videos
```
┌──────────────────────────────────────────────────────────────┐
│ Worship Game Night Game 5 of 8: Bible quiz │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ Who built the ark? │ │
│ │ ▓ Noah ▓ │ │
│ └──────────────────────────────────────────────────────────┘ │
│ 1 ████ Lions 142 │
│ 2 ████ Eagles 128 │
└──────────────────────────────────────────────────────────────┘
```
- Question: a bordered panel above the standings, text in Big Shoulders, sized down as it gets
longer; an optional picture above the text. The revealed answer is a Floodlight block that
pops in. The standings rows shrink to make room.
- Sound: a slim panel "♪ Listen…" ("Paused" while paused), with the answer under it once revealed.
- Video: fills the screen. The header and standings make way and the page padding shrinks; the
timer, the buzzer banner and the footer stay.
- While the browser still blocks sound, a Floodlight note says "Sound is off — click “Turn sound on”".
- Control room: a list like the buzzer panel — the item on screen is highlighted (Floodlight),
shown items carry a ✓, answers are visible to the host in small text.
## Copy
Plain verbs: "Next game", "Hide scores", "Freeze scores", "Show scores", "Add bonus", "Copy judge link".
Errors say what happened + what to do: "This link has been turned off. Ask the host for a new one."
+1 -1
View File
@@ -2,7 +2,7 @@
// Run with: npm run check:rls
import { createClient } from "@supabase/supabase-js";
const TABLES = ["events", "teams", "games", "criteria", "judges", "scores", "adjustments"];
const TABLES = ["events", "teams", "games", "criteria", "judges", "scores", "adjustments", "buzzes", "event_cohosts", "game_items"];
const url = process.env.NEXT_PUBLIC_SUPABASE_URL;
const anonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
+103 -19
View File
@@ -4,10 +4,14 @@ import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { requireAdmin } from "@/lib/auth";
import { serverEnv } from "@/lib/env.server";
import { copyMedia, removeMedia } from "@/lib/media.server";
import { criteriaPresets, type PresetKey } from "@/lib/presets";
import { notify } from "@/lib/realtime";
import { moved } from "@/lib/reorder";
import { idSchema } from "@/lib/tokens";
import {
cohostEmailSchema,
eventSchema,
firstError,
gameSchema,
@@ -23,22 +27,13 @@ const dbFail = (what: string, message: string) => fail(`${what} failed: ${messag
const validIds = (...ids: string[]) => ids.every((id) => idSchema.safeParse(id).success);
const BAD_ID = fail("That item no longer exists. Reload the page.");
const OWNER_ONLY = fail("Only the person who created this game night can do that.");
const eventPath = (eventId: string) => `/admin/events/${eventId}`;
// New teams cycle through these so they're distinguishable without any effort.
const TEAM_COLORS = ["#E63946", "#3A86FF", "#2EC4B6", "#FFB703", "#8338EC", "#FB5607", "#06D6A0", "#FF006E"];
/** Returns the row order after moving `id` one place up or down. */
function moved<T extends { id: string }>(rows: T[], id: string, direction: "up" | "down"): T[] {
const from = rows.findIndex((row) => row.id === id);
const to = direction === "up" ? from - 1 : from + 1;
if (from < 0 || to < 0 || to >= rows.length) return rows;
const next = [...rows];
[next[from], next[to]] = [next[to], next[from]];
return next;
}
// ---------- events ----------
export async function createEvent(_previous: ActionResult | null, formData: FormData): Promise<ActionResult> {
@@ -77,7 +72,7 @@ export async function updateEvent(
revalidatePath("/admin");
revalidatePath(eventPath(eventId));
await notify({ eventId });
await notify({ eventId, buzzer: true });
return ok;
}
@@ -85,14 +80,67 @@ export async function deleteEvent(eventId: string): Promise<ActionResult> {
const { supabase } = await requireAdmin();
if (!validIds(eventId)) return BAD_ID;
const { error } = await supabase.from("events").delete().eq("id", eventId);
// Deleting the event cascades to its items, so note their uploaded files first.
const uploads = await supabase
.from("game_items")
.select("storage_path, games!inner()")
.eq("games.event_id", eventId)
.not("storage_path", "is", null);
if (uploads.error) return dbFail("Deleting the event", uploads.error.message);
// RLS lets only the owner delete; for a co-host the delete matches nothing instead of failing.
const { data, error } = await supabase.from("events").delete().eq("id", eventId).select("id");
if (error) return dbFail("Deleting the event", error.message);
if (data.length === 0) return OWNER_ONLY;
await removeMedia(supabase, uploads.data.map((item) => item.storage_path));
revalidatePath("/admin");
return ok;
}
/** Copies teams, games and criteria — not scores, judges or adjustments. */
// ---------- co-hosts ----------
/** Shares the event with another admin, by email. Owner only (RLS enforces it). */
export async function addCohost(eventId: string, email: string): Promise<ActionResult> {
const { supabase, email: ownEmail } = await requireAdmin();
if (!validIds(eventId)) return BAD_ID;
const parsed = cohostEmailSchema.safeParse(email);
if (!parsed.success) return fail(firstError(parsed.error));
const cohostEmail = parsed.data;
if (cohostEmail === ownEmail) return fail("That's your own email — you already have access.");
// Sharing with someone who can't log in would look like it worked and then not work.
if (!serverEnv.ADMIN_EMAILS.includes(cohostEmail)) {
return fail(`${cohostEmail} can't log in yet. Add it to ADMIN_EMAILS first, then share again.`);
}
const { error } = await supabase.from("event_cohosts").insert({ event_id: eventId, email: cohostEmail });
if (error?.code === "23505") return fail(`Already shared with ${cohostEmail}.`);
if (error?.code === "42501") return OWNER_ONLY;
if (error) return dbFail("Sharing the event", error.message);
revalidatePath(eventPath(eventId));
return ok;
}
export async function removeCohost(eventId: string, cohostId: string): Promise<ActionResult> {
const { supabase } = await requireAdmin();
if (!validIds(eventId, cohostId)) return BAD_ID;
const { data, error } = await supabase
.from("event_cohosts")
.delete()
.eq("id", cohostId)
.eq("event_id", eventId)
.select("id");
if (error) return dbFail("Removing the co-host", error.message);
if (data.length === 0) return OWNER_ONLY;
revalidatePath(eventPath(eventId));
return ok;
}
/** Copies teams, games, criteria and on-screen items — not scores, judges or adjustments. */
export async function duplicateEvent(eventId: string): Promise<ActionResult> {
const { supabase, userId } = await requireAdmin();
if (!validIds(eventId)) return BAD_ID;
@@ -100,7 +148,7 @@ export async function duplicateEvent(eventId: string): Promise<ActionResult> {
const [source, teams, games] = await Promise.all([
supabase.from("events").select("name, event_date").eq("id", eventId).maybeSingle(),
supabase.from("teams").select("name, color, sort_order").eq("event_id", eventId),
supabase.from("games").select("*, criteria(*)").eq("event_id", eventId),
supabase.from("games").select("*, criteria(*), game_items(*)").eq("event_id", eventId),
]);
const loadError = source.error ?? teams.error ?? games.error;
if (loadError) return dbFail("Copying the event", loadError.message);
@@ -178,7 +226,7 @@ export async function updateTeam(
if (error) return dbFail("Saving the team", error.message);
revalidatePath(eventPath(eventId));
await notify({ eventId });
await notify({ eventId, buzzer: true });
return ok;
}
@@ -190,7 +238,7 @@ export async function deleteTeam(eventId: string, teamId: string): Promise<Actio
if (error) return dbFail("Deleting the team", error.message);
revalidatePath(eventPath(eventId));
await notify({ eventId });
await notify({ eventId, buzzer: true });
return ok;
}
@@ -236,7 +284,7 @@ type GameRowWithCriteria = NonNullable<
function loadGame(supabase: Supabase, eventId: string, gameId: string) {
return supabase
.from("games")
.select("*, criteria(*)")
.select("*, criteria(*), game_items(*)")
.eq("id", gameId)
.eq("event_id", eventId)
.maybeSingle();
@@ -261,6 +309,7 @@ async function insertGameCopy(
points_mode: game.points_mode,
multiplier: game.multiplier,
placement_points: game.placement_points,
buzzer_enabled: game.buzzer_enabled,
admin_notes: game.admin_notes,
judge_notes: game.judge_notes,
})
@@ -284,6 +333,30 @@ async function insertGameCopy(
);
if (error) return error.message;
}
// Questions, sounds and videos. An uploaded file is copied, so each item owns its own.
const items = [];
for (const item of game.game_items) {
let storagePath: string | null = null;
if (item.storage_path) {
const copied = await copyMedia(supabase, item.storage_path, eventId);
if (copied.error !== null) return copied.error;
storagePath = copied.path;
}
items.push({
game_id: copy.data.id,
kind: item.kind,
title: item.title,
answer: item.answer,
media_url: item.media_url,
storage_path: storagePath,
sort_order: item.sort_order,
});
}
if (items.length) {
const { error } = await supabase.from("game_items").insert(items);
if (error) return error.message;
}
return null;
}
@@ -345,6 +418,7 @@ export async function saveGame(eventId: string, gameId: string, input: GameDraft
points_mode: game.pointsMode,
multiplier: game.multiplier,
placement_points: game.placementPoints,
buzzer_enabled: game.buzzerEnabled,
admin_notes: game.adminNotes || null,
judge_notes: game.judgeNotes || null,
})
@@ -389,7 +463,7 @@ export async function saveGame(eventId: string, gameId: string, input: GameDraft
if (failed?.error) return dbFail("Saving the criteria", failed.error.message);
revalidatePath(eventPath(eventId));
await notify({ eventId });
await notify({ eventId, buzzer: true });
return ok;
}
@@ -419,11 +493,21 @@ export async function deleteGame(eventId: string, gameId: string): Promise<Actio
const { supabase } = await requireAdmin();
if (!validIds(eventId, gameId)) return BAD_ID;
// Deleting the game cascades to its items, so note their uploaded files first.
const uploads = await supabase
.from("game_items")
.select("storage_path, games!inner()")
.eq("game_id", gameId)
.eq("games.event_id", eventId)
.not("storage_path", "is", null);
if (uploads.error) return dbFail("Deleting the game", uploads.error.message);
const { error } = await supabase.from("games").delete().eq("id", gameId).eq("event_id", eventId);
if (error) return dbFail("Deleting the game", error.message);
await removeMedia(supabase, uploads.data.map((item) => item.storage_path));
revalidatePath(eventPath(eventId));
await notify({ eventId });
await notify({ eventId, buzzer: true });
return ok;
}
+13 -3
View File
@@ -1,6 +1,7 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { CohostsEditor } from "@/components/admin/cohosts-editor";
import { EventDetailsForm } from "@/components/admin/event-details-form";
import { GamesEditor } from "@/components/admin/games-editor";
import { SharePanel } from "@/components/admin/share-panel";
@@ -11,12 +12,16 @@ import { loadEventSetup } from "@/lib/queries/event";
import { idSchema } from "@/lib/tokens";
export default async function EventSetupPage(props: PageProps<"/admin/events/[eventId]">) {
const { supabase } = await requireAdmin();
const { supabase, userId } = await requireAdmin();
const { eventId } = await props.params;
if (!idSchema.safeParse(eventId).success) notFound();
const setup = await loadEventSetup(supabase, eventId);
const [setup, cohosts] = await Promise.all([
loadEventSetup(supabase, eventId),
supabase.from("event_cohosts").select("id, email").eq("event_id", eventId).order("created_at"),
]);
if (!setup) notFound();
if (cohosts.error) throw new Error(`Could not load the co-hosts: ${cohosts.error.message}`);
const { event, teams, games } = setup;
return (
@@ -63,7 +68,12 @@ export default async function EventSetupPage(props: PageProps<"/admin/events/[ev
<section className="rounded-lg border border-ink/15 bg-white p-4">
<h2 className="text-xl font-bold">Share links</h2>
<SharePanel scoreboardToken={event.scoreboard_token} games={games} />
<SharePanel scoreboardToken={event.scoreboard_token} teams={teams} games={games} />
</section>
<section className="rounded-lg border border-ink/15 bg-white p-4">
<h2 className="text-xl font-bold">Co-hosts</h2>
<CohostsEditor eventId={event.id} cohosts={cohosts.data} isOwner={event.owner_id === userId} />
</section>
</div>
);
+158
View File
@@ -0,0 +1,158 @@
"use server";
import { revalidatePath } from "next/cache";
import { requireAdmin } from "@/lib/auth";
import { removeMedia } from "@/lib/media.server";
import { loadItems } from "@/lib/queries/scoring";
import { notify } from "@/lib/realtime";
import { moved } from "@/lib/reorder";
import { isStoragePathOf } from "@/lib/screen";
import { idSchema } from "@/lib/tokens";
import { firstError, itemSchema, MAX_ITEMS_PER_GAME, type ItemDraft } from "@/lib/validation/admin";
import type { ActionResult } from "./actions";
// Server actions for a game's questions, sounds and videos (setup page).
// The file itself is uploaded by the browser straight to storage; these only record its path.
const ok: ActionResult = { ok: true };
const fail = (error: string): ActionResult => ({ ok: false, error });
const dbFail = (what: string, message: string) => fail(`${what} failed: ${message}. Try again.`);
const validIds = (...ids: string[]) => ids.every((id) => idSchema.safeParse(id).success);
const BAD_ID = fail("That item no longer exists. Reload the page.");
type Supabase = Awaited<ReturnType<typeof requireAdmin>>["supabase"];
/** The game must be in this event; RLS alone would allow a game from another of your events. */
async function gameInEvent(supabase: Supabase, eventId: string, gameId: string): Promise<boolean> {
const { data } = await supabase.from("games").select("id").eq("id", gameId).eq("event_id", eventId).maybeSingle();
return data !== null;
}
/** The setup page lists the items; the control room and the scoreboard may be showing one. */
async function changed(eventId: string): Promise<ActionResult> {
revalidatePath(`/admin/events/${eventId}`);
revalidatePath(`/admin/events/${eventId}/live`);
await notify({ eventId });
return ok;
}
export async function addItem(eventId: string, gameId: string, input: ItemDraft): Promise<ActionResult> {
const { supabase } = await requireAdmin();
if (!validIds(eventId, gameId)) return BAD_ID;
const parsed = itemSchema.safeParse(input);
if (!parsed.success) return fail(firstError(parsed.error));
const item = parsed.data;
// The path comes from the browser: it must point into this event's own folder.
if (item.storagePath && !isStoragePathOf(eventId, item.storagePath)) return BAD_ID;
if (!(await gameInEvent(supabase, eventId, gameId))) return BAD_ID;
const existing = await supabase.from("game_items").select("sort_order").eq("game_id", gameId);
if (existing.error) return dbFail("Adding it", existing.error.message);
if (existing.data.length >= MAX_ITEMS_PER_GAME) {
return fail(`A game can have at most ${MAX_ITEMS_PER_GAME} questions, sounds and videos.`);
}
const { error } = await supabase.from("game_items").insert({
game_id: gameId,
kind: item.kind,
title: item.title,
answer: item.kind === "video" ? null : item.answer || null,
media_url: item.mediaUrl || null,
storage_path: item.storagePath,
sort_order: Math.max(-1, ...existing.data.map((row) => row.sort_order)) + 1,
});
if (error) return dbFail("Adding it", error.message);
return changed(eventId);
}
/** Edits the text, the answer and the link. An uploaded file is replaced by deleting and re-adding. */
export async function updateItem(
eventId: string,
gameId: string,
itemId: string,
input: Pick<ItemDraft, "title" | "answer" | "mediaUrl">,
): Promise<ActionResult> {
const { supabase } = await requireAdmin();
if (!validIds(eventId, gameId, itemId)) return BAD_ID;
if (!(await gameInEvent(supabase, eventId, gameId))) return BAD_ID;
const current = await supabase
.from("game_items")
.select("kind, storage_path")
.eq("id", itemId)
.eq("game_id", gameId)
.maybeSingle();
if (current.error) return dbFail("Saving it", current.error.message);
if (!current.data) return BAD_ID;
const parsed = itemSchema.safeParse({
kind: current.data.kind,
title: input.title,
answer: input.answer,
// An item with an upload keeps it; it can't also have a link.
mediaUrl: current.data.storage_path ? "" : input.mediaUrl,
storagePath: current.data.storage_path,
});
if (!parsed.success) return fail(firstError(parsed.error));
const item = parsed.data;
const { error } = await supabase
.from("game_items")
.update({
title: item.title,
answer: item.kind === "video" ? null : item.answer || null,
media_url: item.mediaUrl || null,
})
.eq("id", itemId)
.eq("game_id", gameId);
if (error) return dbFail("Saving it", error.message);
return changed(eventId);
}
export async function deleteItem(eventId: string, gameId: string, itemId: string): Promise<ActionResult> {
const { supabase } = await requireAdmin();
if (!validIds(eventId, gameId, itemId)) return BAD_ID;
if (!(await gameInEvent(supabase, eventId, gameId))) return BAD_ID;
const { data, error } = await supabase
.from("game_items")
.delete()
.eq("id", itemId)
.eq("game_id", gameId)
.select("storage_path");
if (error) return dbFail("Deleting it", error.message);
await removeMedia(supabase, data.map((row) => row.storage_path));
return changed(eventId);
}
export async function moveItem(
eventId: string,
gameId: string,
itemId: string,
direction: "up" | "down",
): Promise<ActionResult> {
const { supabase } = await requireAdmin();
if (!validIds(eventId, gameId, itemId)) return BAD_ID;
if (!(await gameInEvent(supabase, eventId, gameId))) return BAD_ID;
const { data, error } = await loadItems(supabase, gameId);
if (error) return dbFail("Reordering", error.message);
// Rewrite every position so gaps or duplicates in sort_order heal themselves.
const results = await Promise.all(
moved(data, itemId, direction).map((item, index) =>
item.sort_order === index
? null
: supabase.from("game_items").update({ sort_order: index }).eq("id", item.id),
),
);
const failed = results.find((result) => result?.error);
if (failed?.error) return dbFail("Reordering", failed.error.message);
return changed(eventId);
}
+241 -26
View File
@@ -5,10 +5,12 @@ 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, loadItems } from "@/lib/queries/scoring";
import { notify } from "@/lib/realtime";
import { buildFrozenSnapshot } from "@/lib/scoreboard";
import { toScoreboardInput } from "@/lib/scoreboard.server";
import { ITEM_KINDS, pickRandom, type ItemKind } from "@/lib/screen";
import {
durationPatch,
MAX_TIMER_SECONDS,
@@ -47,14 +49,19 @@ export async function setCurrentGame(eventId: string, gameId: string): Promise<A
.eq("id", gameId)
.eq("event_id", eventId)
.eq("status", "upcoming"),
supabase.from("events").update({ current_game_id: gameId }).eq("id", eventId).select("id"),
// Whatever the last game had on screen goes with it.
supabase
.from("events")
.update({ current_game_id: gameId, ...CLEAR_SCREEN })
.eq("id", eventId)
.select("id"),
]);
const error = game.error ?? event.error;
if (error) return dbFail("Switching game", error.message);
if (!event.data?.length) return BAD_ID;
revalidatePath(livePath(eventId));
await notify({ eventId, gameId });
await notify({ eventId, gameId, buzzer: true });
return ok;
}
@@ -74,7 +81,148 @@ export async function setGameStatus(
if (error) return dbFail("Updating the game", error.message);
revalidatePath(livePath(eventId));
await notify({ eventId, gameId });
// Marking a game done closes its buzzers, so the teams' phones need to know.
await notify({ eventId, gameId, buzzer: true });
return ok;
}
// ---------- on screen: questions, sounds, videos ----------
const CLEAR_SCREEN = {
screen_item_id: null,
screen_shown_at: null,
screen_answer_shown: false,
screen_paused: false,
};
const NOT_CURRENT = fail("That game isn't the current one any more. Reload the page.");
/**
* Puts one of the current game's items on the scoreboard, from the start and with its answer
* hidden. Showing the item that is already up starts it again.
*/
async function putOnScreen(supabase: Supabase, eventId: string, gameId: string, itemId: string): Promise<ActionResult> {
const shownAt = new Date().toISOString();
// Only while its game is the current one: this is also what ties the item to this event.
const event = await supabase
.from("events")
.select("id")
.eq("id", eventId)
.eq("current_game_id", gameId)
.maybeSingle();
if (event.error) return dbFail("Putting it on screen", event.error.message);
if (!event.data) return NOT_CURRENT;
const item = await supabase
.from("game_items")
.update({ played_at: shownAt })
.eq("id", itemId)
.eq("game_id", gameId)
.select("id");
if (item.error) return dbFail("Putting it on screen", item.error.message);
if (item.data.length === 0) return BAD_ID;
const { error } = await supabase
.from("events")
.update({ ...CLEAR_SCREEN, screen_item_id: itemId, screen_shown_at: shownAt })
.eq("id", eventId)
.eq("current_game_id", gameId);
if (error) return dbFail("Putting it on screen", error.message);
revalidatePath(livePath(eventId));
await notify({ eventId });
return ok;
}
export async function showItem(eventId: string, gameId: string, itemId: string): Promise<ActionResult> {
const { supabase } = await requireAdmin();
if (!validIds(eventId, gameId, itemId)) return BAD_ID;
return putOnScreen(supabase, eventId, gameId, itemId);
}
/** "Pick one for me": a random item of this kind, preferring ones not played yet. */
export async function showRandomItem(eventId: string, gameId: string, kind: ItemKind): Promise<ActionResult> {
const { supabase } = await requireAdmin();
if (!validIds(eventId, gameId) || !ITEM_KINDS.includes(kind)) return BAD_ID;
const [items, event] = await Promise.all([
loadItems(supabase, gameId),
supabase.from("events").select("screen_item_id").eq("id", eventId).maybeSingle(),
]);
const loadError = items.error ?? event.error;
if (loadError) return dbFail("Picking one", loadError.message);
if (!event.data) return BAD_ID;
const pick = pickRandom(items.data ?? [], kind, event.data.screen_item_id);
if (!pick) return fail(`This game has no ${kind}s yet. Add some on the setup page.`);
return putOnScreen(supabase, eventId, gameId, pick.id);
}
/** Changes how the item on screen is shown; `what` names the action in an error message. */
async function updateScreen(
eventId: string,
what: string,
patch: Partial<typeof CLEAR_SCREEN> | { screen_answer_shown: boolean } | { screen_paused: boolean },
): Promise<ActionResult> {
const { supabase } = await requireAdmin();
if (!validIds(eventId)) return BAD_ID;
const { error } = await supabase.from("events").update(patch).eq("id", eventId);
if (error) return dbFail(what, error.message);
revalidatePath(livePath(eventId));
await notify({ eventId });
return ok;
}
/** Takes the question, sound or video off the scoreboard. */
export async function clearScreen(eventId: string): Promise<ActionResult> {
return updateScreen(eventId, "Clearing the screen", CLEAR_SCREEN);
}
export async function setAnswerShown(eventId: string, shown: boolean): Promise<ActionResult> {
return updateScreen(eventId, "Showing the answer", { screen_answer_shown: shown === true });
}
export async function setScreenPaused(eventId: string, paused: boolean): Promise<ActionResult> {
return updateScreen(eventId, "Pausing", { screen_paused: paused === true });
}
/** Forgets which items have been played, so "pick one for me" starts over. */
export async function resetPlayed(eventId: string, gameId: string): Promise<ActionResult> {
const { supabase } = await requireAdmin();
if (!validIds(eventId, gameId)) return BAD_ID;
// The game must be in this event; RLS alone would allow a game from another of your events.
const game = await supabase.from("games").select("id").eq("id", gameId).eq("event_id", eventId).maybeSingle();
if (game.error) return dbFail("Resetting", game.error.message);
if (!game.data) return BAD_ID;
const { error } = await supabase.from("game_items").update({ played_at: null }).eq("game_id", gameId);
if (error) return dbFail("Resetting", error.message);
revalidatePath(livePath(eventId));
return ok;
}
// ---------- buzzers ----------
/** Clears who buzzed, ready for the next question. */
export async function clearBuzzes(eventId: string, gameId: string): Promise<ActionResult> {
const { supabase } = await requireAdmin();
if (!validIds(eventId, gameId)) return BAD_ID;
// The game must be in this event; RLS alone would allow a game from another of your events.
const game = await supabase.from("games").select("id").eq("id", gameId).eq("event_id", eventId).maybeSingle();
if (game.error) return dbFail("Clearing the buzzers", game.error.message);
if (!game.data) return BAD_ID;
const { error } = await supabase.from("buzzes").delete().eq("game_id", gameId);
if (error) return dbFail("Clearing the buzzers", error.message);
revalidatePath(livePath(eventId));
await notify({ eventId, buzzer: true });
return ok;
}
@@ -109,28 +257,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.
@@ -138,6 +266,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({
+9 -3
View File
@@ -7,10 +7,11 @@ import { requireAdmin } from "@/lib/auth";
const dateFormat = new Intl.DateTimeFormat("en-GB", { dateStyle: "medium", timeZone: "UTC" });
export default async function AdminHome() {
const { supabase } = await requireAdmin();
const { supabase, userId } = await requireAdmin();
// RLS returns the events you own plus the ones shared with you.
const { data: events, error } = await supabase
.from("events")
.select("id, name, event_date")
.select("id, name, event_date, owner_id")
.order("created_at", { ascending: false });
if (error) throw new Error(`Could not load events: ${error.message}`);
@@ -33,9 +34,14 @@ export default async function AdminHome() {
</span>
<span className="text-sm text-ink/70">
{event.event_date ? dateFormat.format(new Date(event.event_date)) : "No date set"}
{event.owner_id !== userId && " · Shared with you"}
</span>
</Link>
<EventRowActions eventId={event.id} eventName={event.name} />
<EventRowActions
eventId={event.id}
eventName={event.name}
canDelete={event.owner_id === userId}
/>
</li>
))}
</ul>
+28
View File
@@ -0,0 +1,28 @@
import { NextResponse } from "next/server";
import { buzz } from "@/lib/buzzer.server";
import { createRateLimiter } from "@/lib/rate-limit";
const headers = { "Cache-Control": "no-store" };
// A team buzzes once per question; this only stops a stuck phone or a script.
const allow = createRateLimiter(5);
/** Buzzes in for this team. No body: the token says who, the server says when. */
export async function POST(_request: Request, context: RouteContext<"/api/buzzer/[token]/buzz">) {
const { token } = await context.params;
if (!allow(token)) {
return NextResponse.json(
{ error: "Too many taps at once. Wait a second and try again." },
{ status: 429, headers },
);
}
try {
const result = await buzz(token);
if (!result.ok) return NextResponse.json({ error: result.error }, { status: result.status, headers });
return NextResponse.json(result.payload, { headers });
} catch (error) {
console.error("buzz failed", error);
return NextResponse.json({ error: "That buzz didn't go through. Try again." }, { status: 500, headers });
}
}
+17
View File
@@ -0,0 +1,17 @@
import { NextResponse } from "next/server";
import { getBuzzerPayload } from "@/lib/buzzer.server";
const headers = { "Cache-Control": "no-store" };
export async function GET(_request: Request, context: RouteContext<"/api/buzzer/[token]">) {
const { token } = await context.params;
try {
const result = await getBuzzerPayload(token);
if (!result.ok) return NextResponse.json({ error: result.error }, { status: result.status, headers });
return NextResponse.json(result.payload, { headers });
} catch (error) {
console.error("buzzer API failed", error);
return NextResponse.json({ error: "Could not load the buzzer. Try again." }, { status: 500, headers });
}
}
+6 -4
View File
@@ -1,6 +1,7 @@
import { NextResponse, type NextRequest } from "next/server";
import { z } from "zod";
import { serverEnv } from "@/lib/env.server";
import { createClient } from "@/lib/supabase/server";
const otpTypeSchema = z.enum(["magiclink", "email", "signup", "invite", "recovery", "email_change"]);
@@ -23,8 +24,9 @@ export async function GET(request: NextRequest) {
signedIn = !error;
}
const url = request.nextUrl.clone();
url.pathname = signedIn ? "/admin" : "/login";
url.search = signedIn ? "" : "?error=link";
return NextResponse.redirect(url);
// Built from the configured site URL, not request.nextUrl: behind a reverse proxy or
// tunnel a route handler sees the address the server is bound to (e.g. localhost:4322),
// and redirecting there leaves the session cookie behind on the public domain.
const path = signedIn ? "/admin" : "/login?error=link";
return NextResponse.redirect(`${serverEnv.NEXT_PUBLIC_SITE_URL}${path}`);
}
+18
View File
@@ -0,0 +1,18 @@
import type { Metadata } from "next";
import { BuzzerMessage, BuzzerView } from "@/components/buzzer/buzzer-view";
import { getBuzzerPayload } from "@/lib/buzzer.server";
export const metadata: Metadata = {
title: "Buzzer",
// The link is the team's only credential; keep it out of search engines.
robots: { index: false, follow: false },
};
export default async function BuzzerPage(props: PageProps<"/b/[token]">) {
const { token } = await props.params;
const result = await getBuzzerPayload(token);
if (!result.ok) return <BuzzerMessage>{result.error}</BuzzerMessage>;
return <BuzzerView token={token} initial={result.payload} />;
}
+22
View File
@@ -119,3 +119,25 @@ body {
animation: none;
}
}
/* Scoreboard buzzer: the banner pops in when a team is first to buzz. */
@keyframes buzz-pop {
from {
transform: scale(0.6);
opacity: 0;
}
60% {
transform: scale(1.06);
opacity: 1;
}
}
.buzz-pop {
animation: buzz-pop 350ms ease-out;
}
@media (prefers-reduced-motion: reduce) {
.buzz-pop {
animation: none;
}
}
+111
View File
@@ -0,0 +1,111 @@
"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>
);
}
+88
View File
@@ -0,0 +1,88 @@
"use client";
import { useState } from "react";
import { addCohost, removeCohost } from "@/app/admin/actions";
import { Button } from "@/components/ui/button";
import { inputClass } from "@/components/ui/field";
import type { Tables } from "@/lib/database.types";
import { ErrorText } from "./error-text";
import { useAction } from "./use-action";
type Cohost = Pick<Tables<"event_cohosts">, "id" | "email">;
/** Who the event is shared with. Only the owner can change the list; co-hosts just see it. */
export function CohostsEditor({
eventId,
cohosts,
isOwner,
}: {
eventId: string;
cohosts: Cohost[];
isOwner: boolean;
}) {
const [email, setEmail] = useState("");
const { pending, error, run } = useAction();
return (
<div className="mt-3 flex flex-col gap-3">
<p className="text-sm text-ink/70">
{isOwner
? "Co-hosts can set up and run this game night with you. They can't delete it or change who it's shared with."
: "This game night was shared with you. Only the person who created it can change who it's shared with."}
</p>
{cohosts.length === 0 ? (
<p>Not shared with anyone yet.</p>
) : (
<ul className="flex flex-col gap-2">
{cohosts.map((cohost) => (
<li key={cohost.id} className="flex flex-wrap items-center justify-between gap-2">
<span className="min-w-0 break-all">{cohost.email}</span>
{isOwner && (
<Button
variant="danger"
size="sm"
disabled={pending}
onClick={() => {
if (window.confirm(`Stop sharing with ${cohost.email}?`)) {
run(() => removeCohost(eventId, cohost.id));
}
}}
>
Remove
</Button>
)}
</li>
))}
</ul>
)}
{isOwner && (
<form
className="flex flex-wrap items-center gap-2"
onSubmit={(event) => {
event.preventDefault();
run(() => addCohost(eventId, email), () => setEmail(""));
}}
>
<input
type="email"
aria-label="Co-host's email"
value={email}
onChange={(event) => setEmail(event.target.value)}
required
maxLength={254}
placeholder="name@example.com"
className={`${inputClass} max-w-xs`}
/>
<Button type="submit" disabled={pending}>
Share
</Button>
</form>
)}
<ErrorText>{error}</ErrorText>
</div>
);
}
+68 -1
View File
@@ -5,10 +5,15 @@ import { useRouter } from "next/navigation";
import { useCallback, useEffect, useState } from "react";
import {
clearBuzzes,
clearScreen,
markBuzz,
pauseTimer,
setAnswerShown,
setCurrentGame,
setGameStatus,
setVisibility,
showItem,
startTimer,
} from "@/app/admin/live-actions";
import { Button } from "@/components/ui/button";
@@ -16,12 +21,15 @@ import { formatScore } from "@/lib/criteria";
import { useRefreshChannel } from "@/lib/hooks/use-refresh-channel";
import type { LiveView } from "@/lib/live-view";
import { channelNames } from "@/lib/realtime-channels";
import { nextQuestion } from "@/lib/screen";
import { BonusPanel } from "./bonus-panel";
import { BuzzerPanel } from "./buzzer-panel";
import { ErrorText } from "./error-text";
import { JudgedPanel } from "./judged-panel";
import { ScoreboardPanel, VISIBILITY_ORDER } from "./scoreboard-panel";
import { ScoringGrid } from "./scoring-grid";
import { ScreenPanel } from "./screen-panel";
import { TimerPanel } from "./timer-panel";
import { useAction } from "./use-action";
@@ -33,6 +41,12 @@ const shortcuts = [
["P", "Previous game"],
["H", "Cycle what the room sees: visible → frozen → hidden"],
["T", "Start or pause the timer"],
["G", "Buzzer: the answering team got it"],
["W", "Buzzer: wrong, pass to the next team"],
["B", "Buzzer: next question (clears the buzzers)"],
["Q", "Scoreboard: show the next question"],
["A", "Scoreboard: show or hide the answer"],
["X", "Scoreboard: take the question, sound or video off screen"],
["?", "Show or hide this help"],
] as const;
@@ -71,6 +85,38 @@ export function ControlRoom({ view }: { view: LiveView }) {
run(() => (timer.status === "running" ? pauseTimer(event.id) : startTimer(event.id)));
}, [event.id, pending, run, timer.status]);
const buzzerGameId = currentGame?.buzzer?.buzzes.length ? currentGame.id : undefined;
const clearBuzzers = useCallback(() => {
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],
);
const screen = currentGame?.screen;
const onScreenItem = currentGame?.items.find((item) => item.id === screen?.itemId);
const showNextQuestion = useCallback(() => {
if (!currentGame || pending) return;
const question = nextQuestion(currentGame.items, screen?.itemId ?? null);
if (question) run(() => showItem(event.id, currentGame.id, question.id));
}, [currentGame, event.id, pending, run, screen?.itemId]);
const hasAnswer = Boolean(onScreenItem?.answer);
const toggleAnswer = useCallback(() => {
if (screen && hasAnswer && !pending) run(() => setAnswerShown(event.id, !screen.answerShown));
}, [event.id, hasAnswer, pending, run, screen]);
const takeOffScreen = useCallback(() => {
if (screen && !pending) run(() => clearScreen(event.id));
}, [event.id, pending, run, screen]);
useEffect(() => {
const onKeyDown = (keyEvent: KeyboardEvent) => {
// Never steal keys from a field the host is typing in.
@@ -83,12 +129,29 @@ export function ControlRoom({ view }: { view: LiveView }) {
else if (key === "p") goTo(previousGame?.id);
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 === "q") showNextQuestion();
else if (key === "a") toggleAnswer();
else if (key === "x") takeOffScreen();
else if (key === "?") setShowHelp((shown) => !shown);
else if (key === "escape") setShowHelp(false);
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [cycleVisibility, goTo, nextGame?.id, previousGame?.id, toggleTimer]);
}, [
clearBuzzers,
cycleVisibility,
goTo,
markAnswer,
nextGame?.id,
previousGame?.id,
showNextQuestion,
takeOffScreen,
toggleAnswer,
toggleTimer,
]);
const teamsById = new Map(teams.map((team) => [team.id, team]));
@@ -211,6 +274,10 @@ export function ControlRoom({ view }: { view: LiveView }) {
{currentGame ? (
<>
{currentGame.items.length > 0 && <ScreenPanel eventId={event.id} game={currentGame} />}
{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} />
{currentGame.scoringMode === "judged" && (
+24 -13
View File
@@ -6,7 +6,16 @@ import { Button } from "@/components/ui/button";
import { ErrorText } from "./error-text";
import { useAction } from "./use-action";
export function EventRowActions({ eventId, eventName }: { eventId: string; eventName: string }) {
export function EventRowActions({
eventId,
eventName,
canDelete,
}: {
eventId: string;
eventName: string;
/** Only the owner can delete; the server enforces it too. */
canDelete: boolean;
}) {
const { pending, error, run } = useAction();
return (
@@ -19,18 +28,20 @@ export function EventRowActions({ eventId, eventName }: { eventId: string; event
>
Duplicate
</Button>
<Button
variant="danger"
size="sm"
disabled={pending}
onClick={() => {
if (window.confirm(`Delete "${eventName}" and all its teams, games and scores? This can't be undone.`)) {
run(() => deleteEvent(eventId));
}
}}
>
Delete
</Button>
{canDelete && (
<Button
variant="danger"
size="sm"
disabled={pending}
onClick={() => {
if (window.confirm(`Delete "${eventName}" and all its teams, games and scores? This can't be undone.`)) {
run(() => deleteEvent(eventId));
}
}}
>
Delete
</Button>
)}
<ErrorText>{error}</ErrorText>
</div>
);
+16
View File
@@ -41,6 +41,7 @@ function toDraft(game: GameWithCriteria): Draft {
pointsMode: game.points_mode,
multiplier: game.multiplier,
placementPoints: game.placement_points.join(", "),
buzzerEnabled: game.buzzer_enabled,
adminNotes: game.admin_notes ?? "",
judgeNotes: game.judge_notes ?? "",
criteria: game.criteria.map((criterion) => ({
@@ -176,6 +177,21 @@ export function GameForm({ eventId, game }: { eventId: string; game: GameWithCri
/>
</Field>
)}
<label className="flex min-h-11 items-start gap-3 sm:col-span-2">
<input
type="checkbox"
checked={draft.buzzerEnabled}
onChange={(event) => change({ buzzerEnabled: event.target.checked })}
className="mt-1 size-5 shrink-0 accent-ink"
/>
<span>
<span className="font-bold">Teams buzz in</span>
<span className="block text-sm text-ink/70">
Each team buzzes from its own link (under Share links). The control room and the
scoreboard show who was first. Scoring stays as set above.
</span>
</span>
</label>
</div>
<fieldset className="flex flex-col gap-3">
+2
View File
@@ -10,6 +10,7 @@ import type { GameWithCriteria } from "@/lib/queries/event";
import { ErrorText } from "./error-text";
import { GameForm } from "./game-form";
import { ItemsEditor } from "./items-editor";
import { JudgesEditor } from "./judges-editor";
import { useAction } from "./use-action";
@@ -132,6 +133,7 @@ function GameCard({
{/* Re-mount after each save so the form picks up ids of newly created criteria. */}
<GameForm key={game.updated_at} eventId={eventId} game={game} />
<ItemsEditor eventId={eventId} game={game} />
{game.scoring_mode === "judged" && <JudgesEditor eventId={eventId} game={game} />}
</div>
</details>
+306
View File
@@ -0,0 +1,306 @@
"use client";
import { useState } from "react";
import { addItem, deleteItem, moveItem, updateItem } from "@/app/admin/item-actions";
import type { ActionResult } from "@/app/admin/actions";
import { Button } from "@/components/ui/button";
import { Field, inputClass } from "@/components/ui/field";
import type { Tables } from "@/lib/database.types";
import type { GameWithCriteria } from "@/lib/queries/event";
import {
MEDIA_BUCKET,
parseYouTube,
storagePathFor,
uploadAccept,
uploadProblem,
type ItemKind,
} from "@/lib/screen";
import { createClient } from "@/lib/supabase/browser";
import { ErrorText } from "./error-text";
import { useAction } from "./use-action";
type Item = Tables<"game_items">;
export const kindLabel: Record<ItemKind, string> = { question: "Question", sound: "Sound", video: "Video" };
const titleLabel: Record<ItemKind, string> = {
question: "Question",
sound: "Name (only you see it)",
video: "Name (only you see it)",
};
const linkLabel: Record<ItemKind, string> = {
question: "Picture link (optional)",
sound: "Link to the sound",
video: "Link to the video",
};
const linkHint: Record<ItemKind, string> = {
question: "A link to an image, starting with https://",
sound: "A YouTube link (only the sound plays) or a link to an audio file.",
video: "A YouTube link, or a link to a video file.",
};
function sourceLabel(item: Item): string | null {
if (item.storage_path) return "Uploaded file";
if (!item.media_url) return null;
return parseYouTube(item.media_url) ? "YouTube" : "Link";
}
/** A game's questions, sounds and videos: what the host can put on the scoreboard. */
export function ItemsEditor({ eventId, game }: { eventId: string; game: GameWithCriteria }) {
const items = game.game_items;
return (
<section className="flex flex-col gap-3 rounded-md border border-ink/15 bg-chalk p-3">
<div>
<h3 className="text-lg font-bold">On the scoreboard: questions, sounds and videos</h3>
<p className="text-sm text-ink/70">
In the control room you click one to show it. A question appears above the scores, a sound
plays through the scoreboard&apos;s speakers, and a video (like a karaoke track) takes over
the whole screen.
</p>
</div>
{items.length > 0 && (
<ol className="flex flex-col gap-2">
{items.map((item, index) => (
<ItemRow
key={item.id}
eventId={eventId}
gameId={game.id}
item={item}
isFirst={index === 0}
isLast={index === items.length - 1}
/>
))}
</ol>
)}
<AddItemForm eventId={eventId} gameId={game.id} />
</section>
);
}
function ItemRow({
eventId,
gameId,
item,
isFirst,
isLast,
}: {
eventId: string;
gameId: string;
item: Item;
isFirst: boolean;
isLast: boolean;
}) {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState({ title: item.title, answer: item.answer ?? "", mediaUrl: item.media_url ?? "" });
const { pending, error, run } = useAction();
const source = sourceLabel(item);
return (
<li className="rounded-md bg-white p-2">
<div className="flex flex-wrap items-center gap-2">
<span className="rounded bg-ink/10 px-2 py-0.5 text-sm font-bold">{kindLabel[item.kind]}</span>
<span className="min-w-40 flex-1">
<span className="font-bold">{item.title}</span>
{item.answer && <span className="block text-sm text-ink/70">Answer: {item.answer}</span>}
</span>
{source && <span className="text-sm text-ink/70">{source}</span>}
<Button
variant="secondary"
size="sm"
aria-label={`Move ${item.title} up`}
disabled={pending || isFirst}
onClick={() => run(() => moveItem(eventId, gameId, item.id, "up"))}
>
↑
</Button>
<Button
variant="secondary"
size="sm"
aria-label={`Move ${item.title} down`}
disabled={pending || isLast}
onClick={() => run(() => moveItem(eventId, gameId, item.id, "down"))}
>
↓
</Button>
<Button variant="secondary" size="sm" aria-expanded={editing} onClick={() => setEditing(!editing)}>
{editing ? "Close" : "Edit"}
</Button>
<Button
variant="danger"
size="sm"
disabled={pending}
onClick={() => {
if (window.confirm(`Delete "${item.title}"? This can't be undone.`)) {
run(() => deleteItem(eventId, gameId, item.id));
}
}}
>
Delete
</Button>
</div>
{editing && (
<form
className="mt-2 grid gap-3 border-t border-ink/15 pt-2 sm:grid-cols-2"
onSubmit={(event) => {
event.preventDefault();
run(() => updateItem(eventId, gameId, item.id, draft), () => setEditing(false));
}}
>
<Field label={titleLabel[item.kind]} className="sm:col-span-2">
<input
value={draft.title}
onChange={(event) => setDraft({ ...draft, title: event.target.value })}
required
maxLength={500}
className={inputClass}
/>
</Field>
{item.kind !== "video" && (
<Field label="Answer (optional)" hint="Stays secret until you click “Show answer”.">
<input
value={draft.answer}
onChange={(event) => setDraft({ ...draft, answer: event.target.value })}
maxLength={500}
className={inputClass}
/>
</Field>
)}
{item.storage_path ? (
<p className="self-end text-sm text-ink/70">
Uses an uploaded file. To change the file, delete this and add it again.
</p>
) : (
<Field label={linkLabel[item.kind]} hint={linkHint[item.kind]}>
<input
type="url"
value={draft.mediaUrl}
onChange={(event) => setDraft({ ...draft, mediaUrl: event.target.value })}
maxLength={2000}
className={inputClass}
/>
</Field>
)}
<div className="sm:col-span-2">
<Button type="submit" variant="secondary" disabled={pending}>
{pending ? "Saving…" : "Save"}
</Button>
</div>
</form>
)}
<ErrorText>{error}</ErrorText>
</li>
);
}
function AddItemForm({ eventId, gameId }: { eventId: string; gameId: string }) {
const [kind, setKind] = useState<ItemKind>("question");
const [title, setTitle] = useState("");
const [answer, setAnswer] = useState("");
const [mediaUrl, setMediaUrl] = useState("");
const [file, setFile] = useState<File | null>(null);
// Bumped to empty the file input, which React can't control directly.
const [fileKey, setFileKey] = useState(0);
const { pending, error, run } = useAction();
const clearFile = () => {
setFile(null);
setFileKey((key) => key + 1);
};
/** Uploads the chosen file (if any) straight to storage, then records the item. */
const save = async (): Promise<ActionResult> => {
const draft = { kind, title, answer, mediaUrl: file ? "" : mediaUrl };
if (!file) return addItem(eventId, gameId, { ...draft, storagePath: null });
const problem = uploadProblem(kind, file);
const storagePath = storagePathFor(eventId, file.name, crypto.randomUUID());
if (problem || !storagePath) return { ok: false, error: problem ?? "That file can't be uploaded." };
const storage = createClient().storage.from(MEDIA_BUCKET);
const upload = await storage.upload(storagePath, file, { contentType: file.type });
if (upload.error) return { ok: false, error: `The upload failed: ${upload.error.message}. Try again.` };
const result = await addItem(eventId, gameId, { ...draft, storagePath });
// Don't leave a file behind that no item points to.
if (!result.ok) await storage.remove([storagePath]);
return result;
};
return (
<form
className="grid gap-3 border-t border-ink/15 pt-3 sm:grid-cols-2"
onSubmit={(event) => {
event.preventDefault();
run(save, () => {
setTitle("");
setAnswer("");
setMediaUrl("");
clearFile();
});
}}
>
<Field label="Add a">
<select
value={kind}
onChange={(event) => {
setKind(event.target.value as ItemKind);
// A file chosen for another kind would be the wrong type.
clearFile();
}}
className={inputClass}
>
<option value="question">Question</option>
<option value="sound">Sound</option>
<option value="video">Video (karaoke, clip)</option>
</select>
</Field>
<Field label={titleLabel[kind]}>
<input
value={title}
onChange={(event) => setTitle(event.target.value)}
required
maxLength={500}
className={inputClass}
placeholder={kind === "question" ? "Who built the ark?" : kind === "sound" ? "Mystery song 1" : "Oceans — karaoke"}
/>
</Field>
{kind !== "video" && (
<Field label="Answer (optional)" hint="Stays secret until you click “Show answer”." className="sm:col-span-2">
<input value={answer} onChange={(event) => setAnswer(event.target.value)} maxLength={500} className={inputClass} />
</Field>
)}
<Field label={linkLabel[kind]} hint={linkHint[kind]}>
<input
type="url"
value={mediaUrl}
onChange={(event) => setMediaUrl(event.target.value)}
maxLength={2000}
disabled={file !== null}
className={`${inputClass} disabled:bg-ink/5`}
placeholder="https://"
/>
</Field>
<Field label="…or upload a file" hint="Up to 50 MB. Used instead of the link.">
<input
key={fileKey}
type="file"
accept={uploadAccept(kind)}
onChange={(event) => setFile(event.target.files?.[0] ?? null)}
className={`${inputClass} py-2`}
/>
</Field>
<div className="flex flex-wrap items-center gap-3 sm:col-span-2">
<Button type="submit" variant="secondary" disabled={pending}>
{pending ? (file ? "Uploading…" : "Adding…") : `Add ${kindLabel[kind].toLowerCase()}`}
</Button>
<ErrorText>{error}</ErrorText>
</div>
</form>
);
}
+134
View File
@@ -0,0 +1,134 @@
"use client";
import {
clearScreen,
resetPlayed,
setAnswerShown,
setScreenPaused,
showItem,
showRandomItem,
} from "@/app/admin/live-actions";
import { Button } from "@/components/ui/button";
import type { LiveGame } from "@/lib/live-view";
import { ITEM_KINDS, type ItemKind } from "@/lib/screen";
import { ErrorText } from "./error-text";
import { kindLabel } from "./items-editor";
import { useAction } from "./use-action";
const showLabel: Record<ItemKind, string> = { question: "Show", sound: "Play", video: "Play" };
const plural: Record<ItemKind, string> = { question: "question", sound: "sound", video: "video" };
/**
* The current game's questions, sounds and videos. Clicking one puts it on the scoreboard;
* "Random" picks one for the host, preferring ones that haven't been played yet.
*/
export function ScreenPanel({ eventId, game }: { eventId: string; game: LiveGame }) {
const { pending, error, run } = useAction();
const { items, screen } = game;
const onScreen = items.find((item) => item.id === screen?.itemId);
const isMedia = onScreen !== undefined && onScreen.kind !== "question";
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">On the scoreboard</h3>
<div className="flex flex-wrap gap-2">
{onScreen?.answer && screen && (
<Button disabled={pending} onClick={() => run(() => setAnswerShown(eventId, !screen.answerShown))}>
{screen.answerShown ? "Hide answer (A)" : "Show answer (A)"}
</Button>
)}
{isMedia && screen && (
<Button
variant="secondary"
disabled={pending}
onClick={() => run(() => setScreenPaused(eventId, !screen.paused))}
>
{screen.paused ? "Resume" : "Pause"}
</Button>
)}
<Button variant="secondary" disabled={pending || !onScreen} onClick={() => run(() => clearScreen(eventId))}>
Take off screen (X)
</Button>
</div>
</div>
<p role="status" className="mt-1">
{onScreen ? (
<>
Showing: <strong>{onScreen.title}</strong>
{screen?.paused && isMedia && " — paused"}
{onScreen.answer && (screen?.answerShown ? " — the room sees the answer" : " — answer still hidden")}
</>
) : (
"Nothing extra on screen — the room sees the scoreboard."
)}
</p>
<div className="mt-2 flex flex-wrap items-center gap-2">
{ITEM_KINDS.map((kind) => {
// With a single item there is nothing to choose between.
if (items.filter((item) => item.kind === kind).length < 2) return null;
return (
<Button
key={kind}
variant="secondary"
size="sm"
disabled={pending}
onClick={() => run(() => showRandomItem(eventId, game.id, kind))}
>
Random {plural[kind]}
</Button>
);
})}
{items.some((item) => item.played) && (
<Button
variant="secondary"
size="sm"
disabled={pending}
onClick={() => run(() => resetPlayed(eventId, game.id))}
>
Clear the ✓ ticks
</Button>
)}
</div>
<ol className="mt-2 flex flex-col gap-1">
{items.map((item) => {
const showing = item.id === screen?.itemId;
return (
<li
key={item.id}
aria-current={showing ? "true" : undefined}
className={`flex min-h-11 flex-wrap items-center gap-2 rounded-md px-2 py-1 ${
showing ? "bg-floodlight font-bold" : ""
}`}
>
<span className="w-20 shrink-0 text-sm">{kindLabel[item.kind]}</span>
<span className={`min-w-40 flex-1 ${item.played && !showing ? "text-ink/70" : ""}`}>
{item.title}
{item.answer && <span className="block text-sm font-normal">Answer: {item.answer}</span>}
</span>
{item.played && !showing && (
<span className="text-sm" title="Already shown">
✓ <span className="sr-only">already shown</span>
</span>
)}
<Button
variant={showing ? "secondary" : "primary"}
size="sm"
disabled={pending}
aria-label={`${showing ? "Restart" : showLabel[item.kind]}: ${item.title}`}
onClick={() => run(() => showItem(eventId, game.id, item.id))}
>
{showing ? (item.kind === "question" ? "Showing" : "Restart") : showLabel[item.kind]}
</Button>
</li>
);
})}
</ol>
<ErrorText>{error}</ErrorText>
</div>
);
}
+13 -1
View File
@@ -2,7 +2,7 @@ import { describe, expect, it, vi } from "vitest";
vi.mock("@/lib/env.public", () => ({ publicEnv: { NEXT_PUBLIC_SITE_URL: "https://gamenight.example" } }));
const { judgeLinksText } = await import("./share-panel");
const { buzzerLinksText, judgeLinksText } = await import("./share-panel");
const judge = (name: string, token: string, revoked = false) =>
({ id: token, game_id: "g", name, token, revoked, source: "nominated", created_at: "" }) as const;
@@ -18,3 +18,15 @@ describe("judgeLinksText", () => {
);
});
});
describe("buzzerLinksText", () => {
it("lists one line per team", () => {
const text = buzzerLinksText([
{ name: "Lions", buzzer_token: "aaa" },
{ name: "Eagles", buzzer_token: "bbb" },
]);
expect(text).toBe(
"Buzzer links\nLions: https://gamenight.example/b/aaa\nEagles: https://gamenight.example/b/bbb",
);
});
});
+50 -5
View File
@@ -3,6 +3,7 @@
import { useState } from "react";
import { Button } from "@/components/ui/button";
import type { Tables } from "@/lib/database.types";
import { publicEnv } from "@/lib/env.public";
import type { GameWithCriteria } from "@/lib/queries/event";
@@ -18,9 +19,25 @@ export function judgeLinksText(game: Pick<GameWithCriteria, "title" | "judges">)
return [`${game.title} — judge links`, ...lines].join("\n");
}
/** Every link for the night in one place: the scoreboard, and each judged game's judges. */
export function SharePanel({ scoreboardToken, games }: { scoreboardToken: string; games: GameWithCriteria[] }) {
type BuzzerTeam = Pick<Tables<"teams">, "id" | "name" | "buzzer_token">;
/** Plain text for pasting into WhatsApp: one line per team. */
export function buzzerLinksText(teams: Array<Pick<BuzzerTeam, "name" | "buzzer_token">>): string {
return ["Buzzer links", ...teams.map((team) => `${team.name}: ${site}/b/${team.buzzer_token}`)].join("\n");
}
/** Every link for the night in one place: the scoreboard, the team buzzers, and each judged game's judges. */
export function SharePanel({
scoreboardToken,
teams,
games,
}: {
scoreboardToken: string;
teams: BuzzerTeam[];
games: GameWithCriteria[];
}) {
const judgedGames = games.filter((game) => game.scoring_mode === "judged");
const usesBuzzers = games.some((game) => game.buzzer_enabled);
const localOnly = /\/\/(localhost|127\.0\.0\.1)/.test(site);
return (
@@ -41,6 +58,32 @@ export function SharePanel({ scoreboardToken, games }: { scoreboardToken: string
/>
</div>
{/* One link per team for the whole night; it only does something during a buzzer game. */}
{usesBuzzers && (
<div className="flex flex-col gap-2 border-t border-ink/15 pt-3">
<div className="flex flex-wrap items-center gap-2">
<h3 className="min-w-40 flex-1 text-lg font-bold">Team buzzers</h3>
{teams.length > 0 && <CopyAllButton text={buzzerLinksText(teams)} label="Copy all buzzer links" />}
</div>
{teams.length === 0 ? (
<p>No teams yet. Add teams above and each gets a buzzer link.</p>
) : (
<ul className="flex flex-col gap-2">
{teams.map((team) => (
<li key={team.id} className="flex flex-wrap items-center gap-2">
<span className="min-w-32 flex-1">{team.name}</span>
<ShareLink
url={`${site}/b/${team.buzzer_token}`}
copyLabel="Copy buzzer link"
qrLabel={`QR code for the ${team.name} buzzer`}
/>
</li>
))}
</ul>
)}
</div>
)}
{judgedGames.length === 0 ? (
<p>No judged games yet. Set a game to “Judged” to give judges their own links.</p>
) : (
@@ -50,7 +93,9 @@ export function SharePanel({ scoreboardToken, games }: { scoreboardToken: string
<div key={game.id} className="flex flex-col gap-2 border-t border-ink/15 pt-3">
<div className="flex flex-wrap items-center gap-2">
<h3 className="min-w-40 flex-1 text-lg font-bold">{game.title}</h3>
{activeJudges.length > 0 && <CopyAllButton text={judgeLinksText(game)} />}
{activeJudges.length > 0 && (
<CopyAllButton text={judgeLinksText(game)} label="Copy all judge links" />
)}
</div>
{activeJudges.length === 0 && !game.open_link_enabled && (
<p>No judges yet. Open this game below to add judges or turn on its open link.</p>
@@ -85,7 +130,7 @@ export function SharePanel({ scoreboardToken, games }: { scoreboardToken: string
);
}
function CopyAllButton({ text }: { text: string }) {
function CopyAllButton({ text, label }: { text: string; label: string }) {
const [state, setState] = useState<"idle" | "copied" | "failed">("idle");
return (
@@ -103,7 +148,7 @@ function CopyAllButton({ text }: { text: string }) {
}
}}
>
{state === "copied" ? "Copied ✓" : "Copy all judge links"}
{state === "copied" ? "Copied ✓" : label}
</Button>
{state === "failed" && (
<div className="basis-full">
+183
View File
@@ -0,0 +1,183 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { buzzerMessages, ordinal, type BuzzerPayload } from "@/lib/buzzer";
import { readableTextColor } from "@/lib/color";
import { useRefreshChannel } from "@/lib/hooks/use-refresh-channel";
import { channelNames } from "@/lib/realtime-channels";
/** A team's phone page: one big button. The server decides who was first. */
export function BuzzerView({ token, initial }: { token: string; initial: BuzzerPayload }) {
const [payload, setPayload] = useState(initial);
// Set when the link stops working (the team was deleted) while the page is open.
const [linkOff, setLinkOff] = useState<string | null>(null);
const [offline, setOffline] = useState(false);
const [sending, setSending] = useState(false);
const [error, setError] = useState<string | null>(null);
// A press fires on pointer-down and again as a click; only the first may send.
const busy = useRef(false);
const refetch = useCallback(async () => {
try {
const response = await fetch(`/api/buzzer/${token}`, { cache: "no-store" });
if (response.status === 404) {
setLinkOff(((await response.json()) as { error: string }).error);
return;
}
if (!response.ok) throw new Error(String(response.status));
setPayload((await response.json()) as BuzzerPayload);
setOffline(false);
} catch {
setOffline(true);
}
}, [token]);
// Pinged when anyone buzzes, the host clears the buzzers, or the game changes.
useRefreshChannel(channelNames.buzzer(payload.eventId), refetch);
useScreenAwake();
const { team, currentGame, buzzer } = payload;
const position = buzzer ? buzzer.buzzes.findIndex((entry) => entry.teamId === team.id) : -1;
const canBuzz = Boolean(buzzer?.open) && position < 0 && !sending;
const press = async () => {
if (!canBuzz || busy.current) return;
busy.current = true;
setSending(true);
setError(null);
// A small kick so the player knows the tap landed, where the phone supports it.
navigator.vibrate?.(60);
try {
const response = await fetch(`/api/buzzer/${token}/buzz`, { method: "POST" });
const body = (await response.json().catch(() => ({}))) as BuzzerPayload & { error?: string };
if (response.ok) {
setPayload(body);
setOffline(false);
} else if (response.status === 404) {
setLinkOff(body.error ?? buzzerMessages.linkOff);
} else {
setError(body.error ?? "That buzz didn't go through. Try again.");
// The game changed or finished under us: show why.
if (response.status === 409) void refetch();
}
} catch {
setError("That buzz didn't go through. Check your connection and try again.");
} finally {
busy.current = false;
setSending(false);
}
};
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
: 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.";
// 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"
// 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">
<h1 className="flex items-center justify-center gap-2 font-display text-4xl font-bold">
{!won && (
<span aria-hidden className="inline-block size-6 rounded-full" style={{ backgroundColor: team.color }} />
)}
{team.name}
</h1>
<p className="text-lg">{currentGame ? currentGame.title : payload.eventName}</p>
</header>
{offline && (
<p role="status" className="w-full rounded-md border-2 border-alert bg-white p-3 font-bold text-ink">
You&apos;re offline. The buzzer won&apos;t work until the connection is back.
</p>
)}
<div className="flex w-full flex-1 items-center justify-center">
<button
type="button"
disabled={!canBuzz}
onPointerDown={() => void press()}
onClick={() => void press()}
className={`flex aspect-square w-full max-w-sm touch-manipulation items-center justify-center rounded-full border-8 font-display text-7xl font-bold shadow-xl transition-transform select-none active:scale-95 disabled:shadow-none ${
position < 0 ? "disabled:opacity-40" : ""
} ${won ? "border-current" : "border-ink"}`}
style={{ backgroundColor: team.color, color: textColor }}
>
{mine?.result === "correct" ? "✓" : mine?.result === "wrong" ? "✗" : mine ? ordinal(position + 1) : "Buzz"}
</button>
</div>
<p role="status" className="min-h-16 text-center text-2xl font-bold">
{error ? <span className={won ? "" : "text-alert-strong"}>{error}</span> : status}
</p>
</main>
);
}
export function BuzzerMessage({ children }: { children: string }) {
return (
<main className="mx-auto flex min-h-dvh max-w-xl items-center justify-center p-6 text-center">
<p className="text-2xl font-bold">{children}</p>
</main>
);
}
/** Keeps the phone's screen on while the buzzer is open: a sleeping phone can't buzz. */
function useScreenAwake() {
useEffect(() => {
let lock: WakeLockSentinel | null = null;
let stopped = false;
const request = async () => {
try {
const next = await navigator.wakeLock?.request("screen");
if (stopped) void next?.release();
else lock = next ?? null;
} catch {
// Not supported, or refused (e.g. battery saver): the screen just dims as usual.
}
};
// The browser drops the lock whenever the tab is hidden, so take it again on return.
const onVisible = () => {
if (document.visibilityState === "visible") void request();
};
void request();
document.addEventListener("visibilitychange", onVisible);
return () => {
stopped = true;
document.removeEventListener("visibilitychange", onVisible);
void lock?.release();
};
}, []);
}
+179
View File
@@ -0,0 +1,179 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import type { OnScreen, ScreenMedia } from "@/lib/screen";
// What the host has put on the scoreboard: a question above the standings, a sound, or a
// video that takes over the screen.
//
// Each of these is mounted with a key of item + shownAt. That restarts it when the host shows
// it again, and lets it keep the media URL it started with: an upload's signed URL is
// different in every payload, and following it would restart the file on every refetch.
type Question = Extract<OnScreen, { kind: "question" }>;
type Sound = Extract<OnScreen, { kind: "sound" }>;
type Video = Extract<OnScreen, { kind: "video" }>;
export const onScreenKey = (item: OnScreen) => `${item.id}:${item.shownAt}`;
const SOUND_OFF_NOTE = "Sound is off — click “Turn sound on”";
/** Long questions get smaller type so they still fit above the standings. */
function questionSize(length: number): string {
if (length <= 60) return "text-[clamp(1.5rem,8vh,7rem)]";
if (length <= 140) return "text-[clamp(1.25rem,6vh,5rem)]";
if (length <= 280) return "text-[clamp(1.125rem,4.5vh,3.5rem)]";
return "text-[clamp(1rem,3.4vh,2.5rem)]";
}
export function QuestionPanel({ question }: { question: Question }) {
const [imageUrl] = useState(question.imageUrl);
return (
<section
role="status"
className="buzz-pop flex max-h-[62vh] min-h-0 shrink-0 flex-col items-center justify-center gap-[1.5vh] overflow-hidden rounded-[1vh] border-[0.4vh] border-white/25 bg-white/5 px-[3vw] py-[2vh] text-center"
>
{imageUrl && (
// A plain <img>: the picture comes from any host or a signed storage URL, which
// next/image would need configuring for and can't cache anyway.
// eslint-disable-next-line @next/next/no-img-element
<img src={imageUrl} alt="" className="max-h-[30vh] min-h-0 rounded-[1vh] object-contain" />
)}
<p className={`font-display leading-[1.1] font-bold text-balance ${questionSize(question.text.length)}`}>
{question.text}
</p>
{question.answer && <Answer text={question.answer} />}
</section>
);
}
function Answer({ text }: { text: string }) {
return (
<p className="buzz-pop max-w-full rounded-[1vh] bg-floodlight px-[2.5vw] py-[1vh] font-display text-[clamp(1.25rem,6vh,5rem)] leading-[1.1] font-bold text-ink">
{text}
</p>
);
}
/** A sound has nothing to look at: a slim banner says something is playing. */
export function SoundBanner({ sound, audible }: { sound: Sound; audible: boolean }) {
return (
<section
role="status"
className="buzz-pop relative flex shrink-0 flex-col items-center gap-[1vh] overflow-hidden rounded-[1vh] border-[0.4vh] border-white/25 bg-white/5 px-[3vw] py-[1.5vh] text-center"
>
<p className="font-display text-[clamp(1.25rem,6vh,5rem)] leading-none font-bold">
<span aria-hidden>♪ </span>
{sound.paused ? "Paused" : "Listen…"}
</p>
{!audible && <p className="text-[clamp(0.875rem,2.4vh,1.75rem)] text-floodlight">{SOUND_OFF_NOTE}</p>}
{sound.answer && <Answer text={sound.answer} />}
{/* A YouTube link used as a sound: the player is there, but nobody sees the video. */}
<div aria-hidden className="pointer-events-none absolute top-0 left-0 size-[200px] opacity-0">
<Player media={sound.media} paused={sound.paused} audible={audible} audioOnly />
</div>
</section>
);
}
/** A video (karaoke, a clip) replaces the standings and fills the screen. */
export function VideoStage({ video, audible }: { video: Video; audible: boolean }) {
return (
<section aria-label="Video" className="relative min-h-0 flex-1 overflow-hidden rounded-[1vh] bg-black">
<Player media={video.media} paused={video.paused} audible={audible} />
{!audible && (
<p className="absolute top-[2vh] left-1/2 -translate-x-1/2 rounded-full bg-stage/90 px-[2vw] py-[1vh] text-[clamp(0.875rem,2.4vh,1.75rem)] font-bold text-floodlight">
{SOUND_OFF_NOTE}
</p>
)}
</section>
);
}
type PlayerProps = {
media: ScreenMedia;
paused: boolean;
/** False until someone has clicked the page: browsers only allow muted playback before that. */
audible: boolean;
audioOnly?: boolean;
};
function Player(props: PlayerProps) {
// Pinned: see the note at the top of this file.
const [media] = useState(props.media);
return media.type === "youtube" ? (
<YouTubePlayer {...props} videoId={media.videoId} startSeconds={media.startSeconds} />
) : (
<FilePlayer {...props} url={media.url} />
);
}
function FilePlayer({ url, paused, audible, audioOnly }: PlayerProps & { url: string }) {
const element = useRef<HTMLVideoElement & HTMLAudioElement>(null);
useEffect(() => {
const media = element.current;
if (!media) return;
// Set here, not as a prop: React doesn't keep the `muted` attribute in sync.
media.muted = !audible;
if (paused) media.pause();
// Rejected if the browser still blocks playback; the next change tries again.
else void media.play().catch(() => {});
}, [paused, audible]);
return audioOnly ? (
<audio ref={element} src={url} preload="auto" />
) : (
<video ref={element} src={url} preload="auto" playsInline className="size-full object-contain" />
);
}
const YOUTUBE_ORIGIN = "https://www.youtube-nocookie.com";
function YouTubePlayer({
videoId,
startSeconds,
paused,
audible,
}: PlayerProps & { videoId: string; startSeconds: number }) {
const frame = useRef<HTMLIFrameElement>(null);
// The URL carries the state the player starts in; later changes are sent as commands.
// Changing the URL instead would reload the video from the start.
const [src] = useState(() => {
const params = new URLSearchParams({
autoplay: paused ? "0" : "1",
mute: audible ? "0" : "1",
start: String(startSeconds),
enablejsapi: "1",
playsinline: "1",
rel: "0",
});
return `${YOUTUBE_ORIGIN}/embed/${videoId}?${params}`;
});
// YouTube's embed takes player commands as postMessage JSON once enablejsapi=1 is set.
const sync = useCallback(() => {
const send = (func: string) =>
frame.current?.contentWindow?.postMessage(JSON.stringify({ event: "command", func, args: [] }), YOUTUBE_ORIGIN);
send(audible ? "unMute" : "mute");
send(paused ? "pauseVideo" : "playVideo");
}, [audible, paused]);
useEffect(() => {
sync();
}, [sync]);
return (
<iframe
ref={frame}
src={src}
title="Video"
onLoad={sync}
allow="autoplay; encrypted-media; fullscreen"
referrerPolicy="strict-origin-when-cross-origin"
className="size-full border-0"
/>
);
}
+86 -33
View File
@@ -2,6 +2,7 @@
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
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";
@@ -10,6 +11,7 @@ import type { ScoreboardPayload, ScoreboardTeam } from "@/lib/scoreboard";
import { soundsForUpdate } from "@/lib/scoreboard-sounds";
import { CountUp } from "./count-up";
import { onScreenKey, QuestionPanel, SoundBanner, VideoStage } from "./on-screen";
import { ScoreboardTimer } from "./scoreboard-timer";
const MOVE_MS = 600;
@@ -45,7 +47,7 @@ export function Scoreboard({ token, initial }: { token: string; initial: Scorebo
() => new Set(initial.teams.filter((team) => team.rank !== undefined).map((team) => team.id)),
);
// Sound effects for what just changed: a new game, a team being revealed.
// Sound effects for what just changed: a new game, a team being revealed, a first buzz.
const sound = useSound();
const { play } = sound;
const previousPayload = useRef(initial);
@@ -103,13 +105,19 @@ export function Scoreboard({ token, initial }: { token: string; initial: Scorebo
const revealing = payload.reveal?.active ?? false;
const hidden = payload.visibility === "hidden" && !revealing;
const { currentGame } = payload;
const { currentGame, onScreen } = payload;
// A video takes over: the header and the standings make way, and the padding shrinks.
const video = onScreen?.kind === "video" ? onScreen : null;
// Media may only play with sound once someone has clicked the page ("Turn sound on").
const audible = sound.state === "on";
return (
<div
className={`flex h-dvh flex-col gap-[2.5vh] overflow-hidden bg-stage px-[4vw] py-[3.5vh] text-white ${idle ? "cursor-none" : ""}`}
className={`flex h-dvh flex-col overflow-hidden bg-stage text-white ${
video ? "gap-[1vh] p-[1vh]" : "gap-[2.5vh] px-[4vw] py-[3.5vh]"
} ${idle ? "cursor-none" : ""}`}
>
<header className="flex items-baseline justify-between gap-[3vw] font-display text-[clamp(1.25rem,5vh,4.5rem)] leading-none font-bold">
<header hidden={video !== null} className="flex items-baseline justify-between gap-[3vw] font-display text-[clamp(1.25rem,5vh,4.5rem)] leading-none font-bold">
<h1 className="truncate">{payload.eventName}</h1>
{currentGame && (
<p className="shrink-0 text-floodlight">
@@ -123,40 +131,48 @@ export function Scoreboard({ token, initial }: { token: string; initial: Scorebo
{payload.timer && <ScoreboardTimer timer={payload.timer} serverNow={payload.updatedAt} play={play} />}
{hidden && (
{payload.buzzer && <BuzzBanner buzzer={payload.buzzer} />}
{onScreen?.kind === "question" && <QuestionPanel key={onScreenKey(onScreen)} question={onScreen} />}
{onScreen?.kind === "sound" && <SoundBanner key={onScreenKey(onScreen)} sound={onScreen} audible={audible} />}
{video && <VideoStage key={onScreenKey(video)} video={video} audible={audible} />}
{hidden && !video && (
<p className="self-center rounded-full border-2 border-alert px-[2.5vw] py-[1vh] text-[clamp(1rem,3.2vh,2.5rem)] font-bold">
Scores are hidden — final reveal soon
</p>
)}
<ol className="flex min-h-0 flex-1 flex-col justify-center gap-[1.2vh]">
{payload.teams.length === 0 && (
<li className="self-center text-[clamp(1rem,3.2vh,2.5rem)] text-white/70">No teams yet</li>
)}
{payload.teams.map((team, index) =>
revealing && team.rank === undefined ? (
// Keyed by slot, not team: a mystery row must say nothing about who is in it.
<li
key={`slot-${index}`}
aria-label="Not revealed yet"
className="flex max-h-[20vh] min-h-0 flex-1 basis-0 items-center justify-center rounded-[1vh] border-2 border-dashed border-white/25 font-display text-[4vh] text-white/40"
>
?
</li>
) : (
<TeamRow
key={team.id}
team={team}
showScore={!hidden}
drumRoll={revealing && !revealedOnLoad.has(team.id)}
register={(element) => {
if (element) rowElements.current.set(team.id, element);
else rowElements.current.delete(team.id);
}}
/>
),
)}
</ol>
{!video && (
<ol className="flex min-h-0 flex-1 flex-col justify-center gap-[1.2vh]">
{payload.teams.length === 0 && (
<li className="self-center text-[clamp(1rem,3.2vh,2.5rem)] text-white/70">No teams yet</li>
)}
{payload.teams.map((team, index) =>
revealing && team.rank === undefined ? (
// Keyed by slot, not team: a mystery row must say nothing about who is in it.
<li
key={`slot-${index}`}
aria-label="Not revealed yet"
className="flex max-h-[20vh] min-h-0 flex-1 basis-0 items-center justify-center rounded-[1vh] border-2 border-dashed border-white/25 font-display text-[4vh] text-white/40"
>
?
</li>
) : (
<TeamRow
key={team.id}
team={team}
showScore={!hidden}
drumRoll={revealing && !revealedOnLoad.has(team.id)}
register={(element) => {
if (element) rowElements.current.set(team.id, element);
else rowElements.current.delete(team.id);
}}
/>
),
)}
</ol>
)}
<footer
className={`flex items-center justify-end gap-[2vw] text-[clamp(0.875rem,2.2vh,1.5rem)] transition-opacity duration-500 ${idle ? "opacity-70" : ""}`}
@@ -244,6 +260,43 @@ function TeamRow({
);
}
/**
* 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: team.color, color: readableTextColor(team.color) }}
>
<span className="truncate text-[clamp(1.5rem,9vh,8rem)]">{headline}</span>
{waiting.length > 0 && (
<span className="truncate text-[clamp(1rem,3.5vh,3rem)]">
then {waiting.map((entry) => entry.name).join(", ")}
</span>
)}
</div>
);
}
/** True after `ms` without mouse or keyboard activity (hides the cursor on the projector). */
function useIdle(ms: number) {
const [idle, setIdle] = useState(false);
+102
View File
@@ -0,0 +1,102 @@
import "server-only";
import { buzzerMessages, buzzerOpen, buzzerView, type BuzzerPayload } from "@/lib/buzzer";
import { loadBuzzes } from "@/lib/queries/scoring";
import { notify } from "@/lib/realtime";
import { createAdminClient } from "@/lib/supabase/admin";
import { buzzerTokenSchema } from "@/lib/tokens";
// Everything here uses the service-role client, so the token check at the top of each
// function is the only gate (CLAUDE.md rule 2). Every query is scoped by what that token grants.
type Supabase = ReturnType<typeof createAdminClient>;
const linkOff = { ok: false, status: 404, error: buzzerMessages.linkOff } as const;
/** The team behind a buzzer link, with its event's current game (if any). */
async function loadTeam(supabase: Supabase, token: string) {
const parsed = buzzerTokenSchema.safeParse(token);
if (!parsed.success) return null;
const { data: team, error } = await supabase
.from("teams")
.select("id, name, color, events!inner(id, name, current_game_id)")
.eq("buzzer_token", parsed.data)
.maybeSingle();
if (error) throw new Error(`Could not load the team: ${error.message}`);
if (!team) return null;
const event = team.events;
if (!event.current_game_id) return { team, event, game: null };
const { data: game, error: gameError } = await supabase
.from("games")
.select("id, title, status, buzzer_enabled")
.eq("id", event.current_game_id)
.maybeSingle();
if (gameError) throw new Error(`Could not load the game: ${gameError.message}`);
return { team, event, game };
}
export type BuzzerLookup =
| { ok: true; payload: BuzzerPayload }
| { ok: false; status: 404 | 409; error: string };
export async function getBuzzerPayload(token: string): Promise<BuzzerLookup> {
const supabase = createAdminClient();
const found = await loadTeam(supabase, token);
if (!found) return linkOff;
const { team, event, game } = found;
const [teams, buzzes] = await Promise.all([
supabase.from("teams").select("id, name, color").eq("event_id", event.id),
game?.buzzer_enabled ? loadBuzzes(supabase, game.id) : { data: [], error: null },
]);
const loadError = teams.error ?? buzzes.error;
if (loadError) throw new Error(`Could not load the buzzers: ${loadError.message}`);
return {
ok: true,
payload: {
eventId: event.id,
eventName: event.name,
// Only what a team should see: no tokens, no scores.
team: { id: team.id, name: team.name, color: team.color },
currentGame: game ? { id: game.id, title: game.title } : null,
buzzer: buzzerView(game, teams.data ?? [], buzzes.data ?? []),
},
};
}
/**
* Records this team's buzz for the current game and returns what its phone should now show.
* A team buzzes once per question; pressing again changes nothing, so a double tap or a
* retry after a dropped connection can't move it down the order.
*/
export async function buzz(token: string): Promise<BuzzerLookup> {
const supabase = createAdminClient();
const found = await loadTeam(supabase, token);
if (!found) return linkOff;
const { team, event, game } = found;
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")
.upsert({ game_id: game.id, team_id: team.id }, { onConflict: "game_id,team_id", ignoreDuplicates: true });
if (error) throw new Error(`Could not save the buzz: ${error.message}`);
const [payload] = await Promise.all([getBuzzerPayload(token), notify({ eventId: event.id, buzzer: true })]);
return payload;
}
+170
View File
@@ -0,0 +1,170 @@
import { describe, expect, it } from "vitest";
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, 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", () => {
expect(buzzerOpen(live)).toBe(true);
expect(buzzerOpen({ buzzer_enabled: true, status: "done" })).toBe(false);
expect(buzzerOpen({ buzzer_enabled: false, status: "live" })).toBe(false);
expect(buzzerOpen(null)).toBe(false);
expect(buzzerOpen(undefined)).toBe(false);
});
});
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();
expect(buzzerView({ buzzer_enabled: false, status: "live" }, teams, [])).toBeNull();
});
it("is open and empty before anyone buzzes", () => {
expect(buzzerView(live, teams, [])).toEqual({ open: true, buzzes: [], answeringTeamId: null });
});
it("keeps the given order and measures each team against the first buzz", () => {
const view = buzzerView(live, teams, [
buzz("eagles", "2026-10-05T19:00:00.100000+00:00"),
buzz("lions", "2026-10-05T19:00:00.520000+00:00"),
]);
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, 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, order(["gone"], ["lions"]));
expect(view?.buzzes).toEqual([
{ teamId: "lions", name: "Lions", color: "#E63946", afterFirstMs: 0, result: null },
]);
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();
});
});
describe("formatting", () => {
it("shows the gap behind the first buzz in hundredths of a second", () => {
expect(formatBuzzGap(420)).toBe("+0.42s");
expect(formatBuzzGap(1500)).toBe("+1.50s");
});
it("writes positions as ordinals", () => {
expect([1, 2, 3, 4, 11, 12, 13, 21, 22, 103].map(ordinal)).toEqual([
"1st", "2nd", "3rd", "4th", "11th", "12th", "13th", "21st", "22nd", "103rd",
]);
});
});
+133
View File
@@ -0,0 +1,133 @@
// Types and rules for the team buzzers, shared by the buzzer API, the scoreboard and the
// control room. Pure: no DB access.
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 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. */
export type BuzzerPayload = {
/** For the realtime channel name only. */
eventId: string;
eventName: string;
team: { id: string; name: string; color: string };
currentGame: { id: string; title: string } | null;
/** Null when there is no current game or it doesn't use buzzers. */
buzzer: BuzzerView | null;
};
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).
*/
export function buzzerView(
game: GameFacts | null | undefined,
teams: Array<{ id: string; name: string; color: 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]));
// 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) && !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`;
}
/** 1 → "1st", 2 → "2nd", 11 → "11th". */
export function ordinal(position: number): string {
const lastTwo = position % 100;
if (lastTwo >= 11 && lastTwo <= 13) return `${position}th`;
return `${position}${["th", "st", "nd", "rd"][position % 10] ?? "th"}`;
}
+142
View File
@@ -58,6 +58,45 @@ export type Database = {
},
];
};
buzzes: {
Row: {
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: [
{
foreignKeyName: "buzzes_game_id_fkey";
columns: ["game_id"];
isOneToOne: false;
referencedRelation: "games";
referencedColumns: ["id"];
},
{
foreignKeyName: "buzzes_team_id_fkey";
columns: ["team_id"];
isOneToOne: false;
referencedRelation: "teams";
referencedColumns: ["id"];
},
];
};
criteria: {
Row: {
description: string | null;
@@ -105,6 +144,35 @@ export type Database = {
},
];
};
event_cohosts: {
Row: {
created_at: string;
email: string;
event_id: string;
id: string;
};
Insert: {
created_at?: string;
email: string;
event_id: string;
id?: string;
};
Update: {
created_at?: string;
email?: string;
event_id?: string;
id?: string;
};
Relationships: [
{
foreignKeyName: "event_cohosts_event_id_fkey";
columns: ["event_id"];
isOneToOne: false;
referencedRelation: "events";
referencedColumns: ["id"];
},
];
};
events: {
Row: {
created_at: string;
@@ -118,6 +186,10 @@ export type Database = {
reveal_count: number;
scoreboard_token: string;
scoreboard_visibility: Database["public"]["Enums"]["scoreboard_visibility"];
screen_answer_shown: boolean;
screen_item_id: string | null;
screen_paused: boolean;
screen_shown_at: string | null;
timer_duration_ms: number;
timer_ends_at: string | null;
timer_remaining_ms: number | null;
@@ -136,6 +208,10 @@ export type Database = {
reveal_count?: number;
scoreboard_token?: string;
scoreboard_visibility?: Database["public"]["Enums"]["scoreboard_visibility"];
screen_answer_shown?: boolean;
screen_item_id?: string | null;
screen_paused?: boolean;
screen_shown_at?: string | null;
timer_duration_ms?: number;
timer_ends_at?: string | null;
timer_remaining_ms?: number | null;
@@ -154,6 +230,10 @@ export type Database = {
reveal_count?: number;
scoreboard_token?: string;
scoreboard_visibility?: Database["public"]["Enums"]["scoreboard_visibility"];
screen_answer_shown?: boolean;
screen_item_id?: string | null;
screen_paused?: boolean;
screen_shown_at?: string | null;
timer_duration_ms?: number;
timer_ends_at?: string | null;
timer_remaining_ms?: number | null;
@@ -168,11 +248,66 @@ export type Database = {
referencedRelation: "games";
referencedColumns: ["id"];
},
{
foreignKeyName: "events_screen_item_id_fkey";
columns: ["screen_item_id"];
isOneToOne: false;
referencedRelation: "game_items";
referencedColumns: ["id"];
},
];
};
game_items: {
Row: {
answer: string | null;
created_at: string;
game_id: string;
id: string;
kind: Database["public"]["Enums"]["game_item_kind"];
media_url: string | null;
played_at: string | null;
sort_order: number;
storage_path: string | null;
title: string;
};
Insert: {
answer?: string | null;
created_at?: string;
game_id: string;
id?: string;
kind: Database["public"]["Enums"]["game_item_kind"];
media_url?: string | null;
played_at?: string | null;
sort_order?: number;
storage_path?: string | null;
title: string;
};
Update: {
answer?: string | null;
created_at?: string;
game_id?: string;
id?: string;
kind?: Database["public"]["Enums"]["game_item_kind"];
media_url?: string | null;
played_at?: string | null;
sort_order?: number;
storage_path?: string | null;
title?: string;
};
Relationships: [
{
foreignKeyName: "game_items_game_id_fkey";
columns: ["game_id"];
isOneToOne: false;
referencedRelation: "games";
referencedColumns: ["id"];
},
];
};
games: {
Row: {
admin_notes: string | null;
buzzer_enabled: boolean;
created_at: string;
description: string | null;
event_id: string;
@@ -193,6 +328,7 @@ export type Database = {
};
Insert: {
admin_notes?: string | null;
buzzer_enabled?: boolean;
created_at?: string;
description?: string | null;
event_id: string;
@@ -213,6 +349,7 @@ export type Database = {
};
Update: {
admin_notes?: string | null;
buzzer_enabled?: boolean;
created_at?: string;
description?: string | null;
event_id?: string;
@@ -347,6 +484,7 @@ export type Database = {
};
teams: {
Row: {
buzzer_token: string;
color: string;
created_at: string;
event_id: string;
@@ -355,6 +493,7 @@ export type Database = {
sort_order: number;
};
Insert: {
buzzer_token?: string;
color?: string;
created_at?: string;
event_id: string;
@@ -363,6 +502,7 @@ export type Database = {
sort_order?: number;
};
Update: {
buzzer_token?: string;
color?: string;
created_at?: string;
event_id?: string;
@@ -388,7 +528,9 @@ export type Database = {
[_ in never]: never;
};
Enums: {
buzz_result: "correct" | "wrong";
criterion_input: "blocks" | "number";
game_item_kind: "question" | "sound" | "video";
game_status: "upcoming" | "live" | "done";
judge_source: "nominated" | "open_link";
points_mode: "raw" | "placement";
+34
View File
@@ -1,3 +1,4 @@
import { buzzerView, type BuzzerView } from "@/lib/buzzer";
import type { EventData } from "@/lib/queries/scoring";
import { toScoringInput } from "@/lib/queries/scoring";
import {
@@ -9,6 +10,7 @@ import {
type Standing,
} from "@/lib/scoring";
import type { Visibility } from "@/lib/scoreboard";
import { activeItem, type ItemKind } from "@/lib/screen";
import { timerState, type TimerState } from "@/lib/timer";
// The control room's data, shaped on the server so the client component holds no score math
@@ -37,6 +39,16 @@ export type LiveCell = CellResult & {
adminValue: number | null;
};
export type LiveItem = {
id: string;
kind: ItemKind;
/** The question's text, or the host's name for a sound or video. */
title: string;
answer: string | null;
/** Has been on screen at least once since the ticks were last reset. */
played: boolean;
};
export type LiveGame = {
id: string;
title: string;
@@ -47,6 +59,12 @@ export type LiveGame = {
adminNotes: string | null;
judgingLocked: boolean;
spotlightTeamId: string | null;
/** Null when this game doesn't use buzzers. */
buzzer: BuzzerView | null;
/** Questions, sounds and videos, in list order. */
items: LiveItem[];
/** What the host has put on the scoreboard, or null. */
screen: { itemId: string; answerShown: boolean; paused: boolean } | null;
/** Judged games only; never includes judge tokens. */
judges: LiveJudge[];
criteria: LiveCriterion[];
@@ -108,6 +126,7 @@ export function buildLiveView(data: EventData, now = new Date()): LiveView {
.map((score) => [`${score.team_id}|${score.criterion_id}`, score.value]),
);
const gameResult = result.games[game.id];
const onScreen = activeItem(event, data.items);
currentGame = {
id: game.id,
title: game.title,
@@ -118,6 +137,21 @@ export function buildLiveView(data: EventData, now = new Date()): LiveView {
adminNotes: game.admin_notes,
judgingLocked: game.judging_locked,
spotlightTeamId: game.spotlight_team_id,
buzzer: buzzerView(game, data.teams, data.buzzes),
items: data.items
.filter((item) => item.game_id === game.id)
.map((item) => ({
id: item.id,
kind: item.kind,
title: item.title,
answer: item.answer,
played: item.played_at !== null,
})),
screen: onScreen && {
itemId: onScreen.id,
answerShown: event.screen_answer_shown,
paused: event.screen_paused,
},
judges: data.judges
.filter((judge) => judge.game_id === game.id)
.map((judge) => ({
+34
View File
@@ -0,0 +1,34 @@
import "server-only";
import type { SupabaseClient } from "@supabase/supabase-js";
import type { Database } from "@/lib/database.types";
import { MEDIA_BUCKET, storagePathFor } from "@/lib/screen";
type Client = SupabaseClient<Database>;
/**
* Deletes uploaded files whose items are gone. Never fails the caller: the rows are already
* deleted, and a leftover file is only wasted space that nothing links to.
*/
export async function removeMedia(supabase: Client, paths: Array<string | null>): Promise<void> {
const files = paths.filter((path): path is string => path !== null);
if (files.length === 0) return;
const { error } = await supabase.storage.from(MEDIA_BUCKET).remove(files);
if (error) console.error("Could not delete uploaded files", error.message);
}
/**
* Copies an upload into `eventId`'s folder, so a duplicated item owns its own file and
* deleting one never breaks the other. Returns the new path, or an error message.
*/
export async function copyMedia(
supabase: Client,
path: string,
eventId: string,
): Promise<{ path: string; error: null } | { path: null; error: string }> {
const copyPath = storagePathFor(eventId, path, crypto.randomUUID());
if (!copyPath) return { path: null, error: "an uploaded file has an unexpected name" };
const { error } = await supabase.storage.from(MEDIA_BUCKET).copy(path, copyPath);
return error ? { path: null, error: error.message } : { path: copyPath, error: null };
}
+4 -2
View File
@@ -7,6 +7,7 @@ type Client = SupabaseClient<Database>;
export type GameWithCriteria = Tables<"games"> & {
criteria: Tables<"criteria">[];
judges: Tables<"judges">[];
game_items: Tables<"game_items">[];
};
export type EventSetup = {
@@ -27,12 +28,13 @@ export async function loadEventSetup(supabase: Client, eventId: string): Promise
.order("created_at"),
supabase
.from("games")
.select("*, criteria(*), judges(*)")
.select("*, criteria(*), judges(*), game_items(*)")
.eq("event_id", eventId)
.order("sort_order")
.order("created_at")
.order("sort_order", { referencedTable: "criteria" })
.order("created_at", { referencedTable: "judges" }),
.order("created_at", { referencedTable: "judges" })
.order("sort_order", { referencedTable: "game_items" }),
]);
const error = event.error ?? teams.error ?? games.error;
+21 -2
View File
@@ -13,8 +13,22 @@ export type EventData = {
judges: Tables<"judges">[];
scores: Tables<"scores">[];
adjustments: Tables<"adjustments">[];
/** The current game's buzzes only, in buzz order. Not scoring data. */
buzzes: Tables<"buzzes">[];
/** The current game's questions, sounds and videos only, in list order. Not scoring data. */
items: Tables<"game_items">[];
};
/** One game's buzzes, first buzz first. The id breaks an exact tie the same way every time. */
export function loadBuzzes(supabase: Client, gameId: string) {
return supabase.from("buzzes").select("*").eq("game_id", gameId).order("buzzed_at").order("id");
}
/** One game's questions, sounds and videos, in the host's order. */
export function loadItems(supabase: Client, gameId: string) {
return supabase.from("game_items").select("*").eq("game_id", gameId).order("sort_order").order("created_at");
}
const PAGE_SIZE = 1000;
// `games!inner()` joins through games purely to filter by event, so child rows load in
@@ -44,7 +58,7 @@ async function loadAllScores(supabase: Client, eventId: string): Promise<Tables<
* after the token has been validated).
*/
export async function loadEventData(supabase: Client, event: Tables<"events">): Promise<EventData> {
const [teams, games, adjustments, criteria, judges, scores] = await Promise.all([
const [teams, games, adjustments, criteria, judges, buzzes, items, scores] = await Promise.all([
supabase.from("teams").select("*").eq("event_id", event.id).order("sort_order").order("created_at"),
supabase.from("games").select("*").eq("event_id", event.id).order("sort_order").order("created_at"),
supabase.from("adjustments").select("*").eq("event_id", event.id).order("created_at"),
@@ -60,9 +74,12 @@ export async function loadEventData(supabase: Client, event: Tables<"events">):
.eq("games.event_id", event.id)
.order("created_at")
.overrideTypes<Tables<"judges">[], { merge: false }>(),
event.current_game_id ? loadBuzzes(supabase, event.current_game_id) : { data: [], error: null },
event.current_game_id ? loadItems(supabase, event.current_game_id) : { data: [], error: null },
loadAllScores(supabase, event.id),
]);
const error = teams.error ?? games.error ?? adjustments.error ?? criteria.error ?? judges.error;
const error =
teams.error ?? games.error ?? adjustments.error ?? criteria.error ?? judges.error ?? buzzes.error ?? items.error;
if (error) throw new Error(`Could not load the event: ${error.message}`);
return {
@@ -72,6 +89,8 @@ export async function loadEventData(supabase: Client, event: Tables<"events">):
adjustments: adjustments.data ?? [],
criteria: criteria.data ?? [],
judges: judges.data ?? [],
buzzes: buzzes.data ?? [],
items: items.data ?? [],
scores,
};
}
+1
View File
@@ -5,4 +5,5 @@ export const channelNames = {
scoreboard: (scoreboardToken: string) => `sb:${scoreboardToken}`,
game: (gameId: string) => `game:${gameId}`,
admin: (eventId: string) => `admin:${eventId}`,
buzzer: (eventId: string) => `buzzer:${eventId}`,
};
+8 -2
View File
@@ -4,7 +4,12 @@ import { createAdminClient } from "@/lib/supabase/admin";
import { channelNames, REFRESH_EVENT } from "./realtime-channels";
type NotifyArgs = { eventId: string; gameId?: string };
type NotifyArgs = {
eventId: string;
gameId?: string;
/** Also ping the teams' buzzer pages: for buzzes, and for changes to what those pages show. */
buzzer?: boolean;
};
// A scoreboard token never changes, so remember it and save a DB round trip per ping.
const scoreboardTokens = new Map<string, string>();
@@ -14,7 +19,7 @@ const scoreboardTokens = new Map<string, string>();
* The message carries no data (CLAUDE.md rule 4): hidden/frozen rules live only in the API.
* Never throws: the mutation already succeeded, and clients poll every 10s as a fallback.
*/
export async function notify({ eventId, gameId }: NotifyArgs): Promise<void> {
export async function notify({ eventId, gameId, buzzer }: NotifyArgs): Promise<void> {
try {
const supabase = createAdminClient();
let scoreboardToken = scoreboardTokens.get(eventId);
@@ -31,6 +36,7 @@ export async function notify({ eventId, gameId }: NotifyArgs): Promise<void> {
const topics = [channelNames.admin(eventId)];
if (scoreboardToken) topics.push(channelNames.scoreboard(scoreboardToken));
if (gameId) topics.push(channelNames.game(gameId));
if (buzzer) topics.push(channelNames.buzzer(eventId));
await Promise.all(
topics.map(async (topic) => {
+9
View File
@@ -0,0 +1,9 @@
/** Returns the row order after moving `id` one place up or down. */
export function moved<T extends { id: string }>(rows: T[], id: string, direction: "up" | "down"): T[] {
const from = rows.findIndex((row) => row.id === id);
const to = direction === "up" ? from - 1 : from + 1;
if (from < 0 || to < 0 || to >= rows.length) return rows;
const next = [...rows];
[next[from], next[to]] = [next[to], next[from]];
return next;
}
+45
View File
@@ -18,6 +18,8 @@ const payload = (overrides: Partial<ScoreboardPayload> = {}): ScoreboardPayload
reveal: null,
teams: [team("a", 1), team("b", 2), team("c", 3)],
timer: null,
buzzer: null,
onScreen: null,
updatedAt: "2026-10-03T12:00:00.000Z",
...overrides,
});
@@ -65,6 +67,49 @@ describe("soundsForUpdate", () => {
it("is silent when the reveal finishes and the normal scoreboard returns", () => {
expect(soundsForUpdate(payload(reveal(["a", "b", "c"])), payload(), motion)).toEqual([]);
});
// 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([]);
// Cleared for the next question: silent, then a ding again even for the same team.
expect(soundsForUpdate(payload(buzzed("a", "b")), payload(buzzed()), motion)).toEqual([]);
expect(soundsForUpdate(payload(buzzed()), payload(buzzed("a")), motion)).toEqual(["buzzIn"]);
// Cleared and re-buzzed by another team between two fetches.
expect(soundsForUpdate(payload(buzzed("a")), payload(buzzed("b")), motion)).toEqual(["buzzIn"]);
// Moving to a game without buzzers.
expect(soundsForUpdate(payload(buzzed("a")), payload(), motion)).toEqual([]);
});
});
describe("soundForCountdown", () => {
+12
View File
@@ -32,6 +32,18 @@ export function soundsForUpdate(
else sounds.push(isWinner ? "revealWinner" : "reveal");
}
}
// 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;
}
+35 -2
View File
@@ -1,13 +1,39 @@
import "server-only";
import { buzzerView } from "@/lib/buzzer";
import type { Tables } from "@/lib/database.types";
import { loadEventData, toScoringInput, type EventData } from "@/lib/queries/scoring";
import { computeEvent } from "@/lib/scoring";
import { buildScoreboardPayload, type ScoreboardInput, type ScoreboardPayload } from "@/lib/scoreboard";
import { activeItem, buildOnScreen, MEDIA_BUCKET, type OnScreen } from "@/lib/screen";
import { createAdminClient } from "@/lib/supabase/admin";
import { scoreboardTokenSchema } from "@/lib/tokens";
export function toScoreboardInput(data: EventData): ScoreboardInput {
// Long enough for a whole game night: a screen keeps the URL it got when the item went up.
const SIGNED_URL_SECONDS = 12 * 60 * 60;
/**
* The item on screen, with a temporary URL for its upload if it has one. The bucket is
* private, so this is the only way a file reaches the room — and only while it is on screen.
*/
async function loadOnScreen(
supabase: ReturnType<typeof createAdminClient>,
data: EventData,
): Promise<OnScreen | null> {
const item = activeItem(data.event, data.items);
if (!item) return null;
let signedUrl: string | null = null;
if (item.storage_path) {
const signed = await supabase.storage.from(MEDIA_BUCKET).createSignedUrl(item.storage_path, SIGNED_URL_SECONDS);
// The scores must still load; buildOnScreen leaves out media it has no URL for.
if (signed.error) console.error("Could not sign the on-screen file", signed.error.message);
signedUrl = signed.data?.signedUrl ?? null;
}
return buildOnScreen(data.event, item, signedUrl);
}
export function toScoreboardInput(data: EventData, onScreen: OnScreen | null = null): ScoreboardInput {
const { event } = data;
return {
event: {
@@ -22,6 +48,12 @@ export function toScoreboardInput(data: EventData): ScoreboardInput {
teams: data.teams,
games: data.games,
standings: computeEvent(toScoringInput(data)).standings,
buzzer: buzzerView(
data.games.find((game) => game.id === event.current_game_id),
data.teams,
data.buzzes,
),
onScreen,
now: new Date().toISOString(),
};
}
@@ -43,5 +75,6 @@ export async function getScoreboardPayload(token: string): Promise<ScoreboardPay
if (error) throw new Error(`Could not load the scoreboard: ${error.message}`);
if (!event) return null;
return buildScoreboardPayload(toScoreboardInput(await loadEventData(supabase, event)));
const data = await loadEventData(supabase, event);
return buildScoreboardPayload(toScoreboardInput(data, await loadOnScreen(supabase, data)));
}
+45
View File
@@ -42,6 +42,8 @@ const input = (event: Partial<ScoreboardInput["event"]> = {}): ScoreboardInput =
standing("eagles", 2, 20),
standing("sharks", 4, 5),
],
buzzer: null,
onScreen: null,
now: "2026-10-03T12:00:00.000Z",
});
@@ -180,3 +182,46 @@ describe("timer", () => {
expect(hasNoScores(payload.teams)).toBe(true);
});
});
describe("buzzer", () => {
const buzzer = {
open: true,
buzzes: [{ teamId: "sharks", name: "Sharks", color: "#2EC4B6", afterFirstMs: 0, result: null }],
answeringTeamId: "sharks",
};
it("is null when the current game has no buzzers", () => {
expect(buildScoreboardPayload(input()).buzzer).toBeNull();
});
it("is sent whatever the score visibility, without adding any scores", () => {
for (const visibility of ["visible", "frozen", "hidden"] as const) {
const payload = buildScoreboardPayload({ ...input({ visibility }), buzzer });
expect(payload.buzzer).toEqual(buzzer);
if (visibility !== "visible") expect(hasNoScores(payload.teams)).toBe(true);
}
});
});
describe("on screen", () => {
const onScreen = {
kind: "question" as const,
id: "i1",
shownAt: "2026-10-03T11:59:00.000Z",
text: "Who built the ark?",
imageUrl: null,
answer: null,
};
it("is null with nothing on screen", () => {
expect(buildScoreboardPayload(input()).onScreen).toBeNull();
});
it("is sent whatever the score visibility, without adding any scores", () => {
for (const visibility of ["visible", "frozen", "hidden"] as const) {
const payload = buildScoreboardPayload({ ...input({ visibility }), onScreen });
expect(payload.onScreen).toEqual(onScreen);
if (visibility !== "visible") expect(hasNoScores(payload.teams)).toBe(true);
}
});
});
+12
View File
@@ -1,6 +1,8 @@
import { z } from "zod";
import type { BuzzerView } from "@/lib/buzzer";
import type { Standing } from "@/lib/scoring";
import type { OnScreen } from "@/lib/screen";
import { timerState, type TimerRow, type TimerState } from "@/lib/timer";
// The ONE place that decides what the public scoreboard may see (CLAUDE.md rule 3).
@@ -25,6 +27,10 @@ export type ScoreboardPayload = {
teams: ScoreboardTeam[];
/** Null while the host has the timer hidden. Not a score, so it ignores frozen/hidden. */
timer: TimerState | null;
/** Null unless the current game uses buzzers. Not a score either, so it is always live. */
buzzer: BuzzerView | null;
/** The question, sound or video the host has put on screen. Not a score, so always live. */
onScreen: OnScreen | null;
/** The server's clock when this was built; screens use it to correct their own clocks. */
updatedAt: string;
};
@@ -44,6 +50,10 @@ export type ScoreboardInput = {
games: Array<{ id: string; title: string }>;
/** From the scoring engine, already sorted for display. */
standings: Standing[];
/** The current game's buzzer state (see buzzerView), or null when it has no buzzers. */
buzzer: BuzzerView | null;
/** Already sanitised (see buildOnScreen): no hidden answer, no sound or video name. */
onScreen: OnScreen | null;
now: string;
};
@@ -128,6 +138,8 @@ export function buildScoreboardPayload(input: ScoreboardInput): ScoreboardPayloa
: null,
teams: teamsFor(input),
timer: event.timer.timer_visible ? timerState(event.timer, Date.parse(input.now)) : null,
buzzer: input.buzzer,
onScreen: input.onScreen,
updatedAt: input.now,
};
}
+255
View File
@@ -0,0 +1,255 @@
import { describe, expect, it } from "vitest";
import {
activeItem,
buildOnScreen,
isHttpsUrl,
isStoragePathOf,
MAX_UPLOAD_BYTES,
nextQuestion,
parseYouTube,
pickRandom,
storagePathFor,
uploadProblem,
type ItemKind,
} from "./screen";
const EVENT = "11111111-1111-4111-8111-111111111111";
const FILE = "22222222-2222-4222-8222-222222222222";
describe("parseYouTube", () => {
it.each([
"https://www.youtube.com/watch?v=dQw4w9WgXcQ",
"https://youtube.com/watch?feature=share&v=dQw4w9WgXcQ",
"https://m.youtube.com/watch?v=dQw4w9WgXcQ",
"https://music.youtube.com/watch?v=dQw4w9WgXcQ&list=abc",
"https://youtu.be/dQw4w9WgXcQ?si=xyz",
"https://www.youtube.com/embed/dQw4w9WgXcQ",
"https://www.youtube.com/shorts/dQw4w9WgXcQ",
"https://www.youtube.com/live/dQw4w9WgXcQ",
"https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ",
])("finds the video id in %s", (link) => {
expect(parseYouTube(link)).toEqual({ videoId: "dQw4w9WgXcQ", startSeconds: 0 });
});
it("reads a start time in seconds or as 1m30s", () => {
expect(parseYouTube("https://youtu.be/dQw4w9WgXcQ?t=90")?.startSeconds).toBe(90);
expect(parseYouTube("https://youtu.be/dQw4w9WgXcQ?t=90s")?.startSeconds).toBe(90);
expect(parseYouTube("https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=1m30s")?.startSeconds).toBe(90);
expect(parseYouTube("https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=1h2m3s")?.startSeconds).toBe(3723);
expect(parseYouTube("https://www.youtube.com/embed/dQw4w9WgXcQ?start=12")?.startSeconds).toBe(12);
expect(parseYouTube("https://youtu.be/dQw4w9WgXcQ?t=soon")?.startSeconds).toBe(0);
});
it.each([
"not a link",
"https://example.com/watch?v=dQw4w9WgXcQ",
"https://notyoutube.com/watch?v=dQw4w9WgXcQ",
"https://www.youtube.com/watch?v=short",
"https://www.youtube.com/playlist?list=PL123",
"https://www.youtube.com/",
"https://example.com/song.mp3",
])("is null for %s", (link) => {
expect(parseYouTube(link)).toBeNull();
});
});
describe("links and uploads", () => {
it("accepts https links only", () => {
expect(isHttpsUrl("https://example.com/a.mp3")).toBe(true);
expect(isHttpsUrl("http://example.com/a.mp3")).toBe(false);
expect(isHttpsUrl("javascript:alert(1)")).toBe(false);
expect(isHttpsUrl("example.com")).toBe(false);
});
it("builds a storage path inside the event's folder", () => {
expect(storagePathFor(EVENT, "My Song (final).MP3", FILE)).toBe(`${EVENT}/${FILE}.mp3`);
expect(storagePathFor(EVENT, "no-extension", FILE)).toBeNull();
expect(storagePathFor(EVENT, "weird.extension-name", FILE)).toBeNull();
});
it("only trusts a path in this event's own folder", () => {
expect(isStoragePathOf(EVENT, `${EVENT}/${FILE}.mp4`)).toBe(true);
expect(isStoragePathOf(FILE, `${EVENT}/${FILE}.mp4`)).toBe(false);
expect(isStoragePathOf(EVENT, `${EVENT}/../${FILE}.mp4`)).toBe(false);
expect(isStoragePathOf(EVENT, `${EVENT}/${FILE}`)).toBe(false);
});
it("checks the file type per kind, and the size", () => {
const file = (type: string, size = 1000, name = "clip.bin") => ({ name, type, size });
expect(uploadProblem("sound", file("audio/mpeg"))).toBeNull();
expect(uploadProblem("video", file("video/mp4"))).toBeNull();
expect(uploadProblem("question", file("image/png"))).toBeNull();
expect(uploadProblem("sound", file("video/mp4"))).toBe("Choose an audio file.");
expect(uploadProblem("video", file("image/png"))).toBe("Choose a video file.");
expect(uploadProblem("question", file(""))).toBe("Choose an image.");
expect(uploadProblem("video", file("video/mp4", MAX_UPLOAD_BYTES + 1))).toMatch(/over 50 MB/);
expect(uploadProblem("video", file("video/mp4", MAX_UPLOAD_BYTES))).toBeNull();
expect(uploadProblem("video", file("video/mp4", 1000, "clip"))).toMatch(/extension/);
});
});
const item = (overrides: Partial<Parameters<typeof buildOnScreen>[1] & object> = {}) => ({
id: "i1",
game_id: "g1",
kind: "question" as ItemKind,
title: "Who built the ark?",
answer: "Noah" as string | null,
media_url: null as string | null,
storage_path: null as string | null,
...overrides,
});
const screen = (overrides: Partial<Parameters<typeof buildOnScreen>[0]> = {}) => ({
current_game_id: "g1",
screen_item_id: "i1",
screen_shown_at: "2026-10-05T19:00:00.000Z",
screen_answer_shown: false,
screen_paused: false,
...overrides,
});
describe("activeItem", () => {
const items = [item(), item({ id: "i2", game_id: "g2" })];
it("is the item the event points at", () => {
expect(activeItem(screen(), items)?.id).toBe("i1");
});
it("is nothing when the screen is clear, the item is gone, or there is no current game", () => {
expect(activeItem(screen({ screen_item_id: null }), items)).toBeNull();
expect(activeItem(screen({ screen_item_id: "deleted" }), items)).toBeNull();
expect(activeItem(screen({ current_game_id: null }), items)).toBeNull();
});
it("ignores an item from a game that isn't the current one", () => {
expect(activeItem(screen({ screen_item_id: "i2" }), items)).toBeNull();
});
});
describe("buildOnScreen", () => {
it("is null with nothing on screen", () => {
expect(buildOnScreen(screen(), null, null)).toBeNull();
});
it("sends a question without its answer until the host reveals it", () => {
const hidden = buildOnScreen(screen(), item(), null);
expect(hidden).toEqual({
kind: "question",
id: "i1",
shownAt: "2026-10-05T19:00:00.000Z",
text: "Who built the ark?",
imageUrl: null,
answer: null,
});
expect(JSON.stringify(hidden)).not.toContain("Noah");
expect(buildOnScreen(screen({ screen_answer_shown: true }), item(), null)).toMatchObject({ answer: "Noah" });
});
it("has no answer to reveal when the question has none", () => {
expect(buildOnScreen(screen({ screen_answer_shown: true }), item({ answer: null }), null)).toMatchObject({
answer: null,
});
});
it("gives a question its picture from a link or an upload, but never a YouTube player", () => {
expect(buildOnScreen(screen(), item({ media_url: "https://example.com/ark.png" }), null)).toMatchObject({
imageUrl: "https://example.com/ark.png",
});
expect(buildOnScreen(screen(), item({ storage_path: "e/f.png" }), "https://signed/ark")).toMatchObject({
imageUrl: "https://signed/ark",
});
expect(buildOnScreen(screen(), item({ media_url: "https://youtu.be/dQw4w9WgXcQ" }), null)).toMatchObject({
imageUrl: null,
});
// An upload that couldn't be signed: the question still shows, without the picture.
expect(buildOnScreen(screen(), item({ storage_path: "e/f.png" }), null)).toMatchObject({ imageUrl: null });
});
it("never sends a sound's or video's name, which would give the answer away", () => {
const sound = item({ kind: "sound", title: "Amazing Grace", answer: "Amazing Grace", media_url: "https://example.com/a.mp3" });
const video = item({ kind: "video", title: "Oceans karaoke", answer: null, media_url: "https://youtu.be/dQw4w9WgXcQ?t=5" });
const soundOnScreen = buildOnScreen(screen({ screen_paused: true }), sound, null);
expect(soundOnScreen).toEqual({
kind: "sound",
id: "i1",
shownAt: "2026-10-05T19:00:00.000Z",
media: { type: "file", url: "https://example.com/a.mp3" },
paused: true,
answer: null,
});
expect(JSON.stringify(soundOnScreen)).not.toContain("Amazing");
expect(buildOnScreen(screen({ screen_answer_shown: true }), sound, null)).toMatchObject({ answer: "Amazing Grace" });
const videoOnScreen = buildOnScreen(screen(), video, null);
expect(videoOnScreen).toEqual({
kind: "video",
id: "i1",
shownAt: "2026-10-05T19:00:00.000Z",
media: { type: "youtube", videoId: "dQw4w9WgXcQ", startSeconds: 5 },
paused: false,
});
expect(JSON.stringify(videoOnScreen)).not.toContain("Oceans");
});
it("plays an upload from its signed URL, and shows nothing if it couldn't be signed", () => {
const upload = item({ kind: "video", storage_path: "e/f.mp4" });
expect(buildOnScreen(screen(), upload, "https://signed/clip")).toMatchObject({
media: { type: "file", url: "https://signed/clip" },
});
expect(buildOnScreen(screen(), upload, null)).toBeNull();
expect(buildOnScreen(screen(), item({ kind: "sound" }), null)).toBeNull();
});
});
describe("pickRandom", () => {
const song = (id: string, played = false, kind: ItemKind = "video") => ({
id,
kind,
played_at: played ? "2026-10-05T19:00:00.000Z" : null,
});
const first = () => 0;
const last = () => 0.999;
it("picks among the items of that kind only", () => {
const items = [song("q", false, "question"), song("a"), song("b")];
expect(pickRandom(items, "video", null, first)?.id).toBe("a");
expect(pickRandom(items, "video", null, last)?.id).toBe("b");
expect(pickRandom(items, "sound", null)).toBeNull();
});
it("prefers songs that haven't been played", () => {
const items = [song("a", true), song("b"), song("c", true)];
expect(pickRandom(items, "video", null, first)?.id).toBe("b");
expect(pickRandom(items, "video", null, last)?.id).toBe("b");
});
it("starts over once every song has been played, without repeating the one on screen", () => {
const items = [song("a", true), song("b", true), song("c", true)];
expect(pickRandom(items, "video", "a", first)?.id).toBe("b");
expect(pickRandom(items, "video", "c", last)?.id).toBe("b");
});
it("repeats the song on screen only when it is the only one", () => {
expect(pickRandom([song("a", true)], "video", "a")?.id).toBe("a");
});
});
describe("nextQuestion", () => {
const entry = (id: string, played = false, kind: ItemKind = "question") => ({ id, kind, played });
const items = [entry("q1", true), entry("v1", false, "video"), entry("q2"), entry("q3")];
it("follows the list from the question on screen, skipping other kinds", () => {
expect(nextQuestion(items, "q1")?.id).toBe("q2");
expect(nextQuestion(items, "q2")?.id).toBe("q3");
expect(nextQuestion(items, "q3")).toBeNull();
});
it("starts at the first unplayed question when none is on screen", () => {
expect(nextQuestion(items, null)?.id).toBe("q2");
expect(nextQuestion(items, "v1")?.id).toBe("q2");
expect(nextQuestion([entry("q1", true)], null)).toBeNull();
});
});
+192
View File
@@ -0,0 +1,192 @@
// Types and rules for what a game puts on the scoreboard: questions, sounds and videos.
// Shared by the setup page, the control room and the scoreboard API. Pure: no DB access.
export const ITEM_KINDS = ["question", "sound", "video"] as const;
export type ItemKind = (typeof ITEM_KINDS)[number];
export const MEDIA_BUCKET = "game-media";
/** Mirrors the bucket's file_size_limit in 0006_screen_items.sql. */
export const MAX_UPLOAD_BYTES = 50 * 1024 * 1024;
/** What an item of each kind may upload. A question's file is its picture. */
const UPLOAD_TYPE: Record<ItemKind, { prefix: string; what: string }> = {
question: { prefix: "image/", what: "an image" },
sound: { prefix: "audio/", what: "an audio file" },
video: { prefix: "video/", what: "a video file" },
};
export const uploadAccept = (kind: ItemKind) => `${UPLOAD_TYPE[kind].prefix}*`;
/** Why this file can't be uploaded for this kind of item, or null when it can. */
export function uploadProblem(kind: ItemKind, file: { name: string; type: string; size: number }): string | null {
const { prefix, what } = UPLOAD_TYPE[kind];
if (!file.type.startsWith(prefix)) return `Choose ${what}.`;
if (file.size > MAX_UPLOAD_BYTES) return "That file is over 50 MB. Use a smaller one, or a link instead.";
if (!fileExtension(file.name)) return "That file's name has no usable extension, like .mp3 or .mp4.";
return null;
}
function fileExtension(name: string): string | null {
return /\.([a-z0-9]{1,5})$/.exec(name.toLowerCase())?.[1] ?? null;
}
/** Where an upload lives in the bucket: "{eventId}/{uuid}.{ext}". Null without a usable extension. */
export function storagePathFor(eventId: string, fileName: string, uuid: string): string | null {
const extension = fileExtension(fileName);
return extension ? `${eventId}/${uuid}.${extension}` : null;
}
const STORAGE_PATH = /^[0-9a-f-]{36}\/[0-9a-f-]{36}\.[a-z0-9]{1,5}$/;
/** Is this a well-formed upload path inside this event's own folder? */
export function isStoragePathOf(eventId: string, path: string): boolean {
return STORAGE_PATH.test(path) && path.startsWith(`${eventId}/`);
}
/** Links must be https: the scoreboard is served over https and would block anything else. */
export function isHttpsUrl(value: string): boolean {
try {
return new URL(value).protocol === "https:";
} catch {
return false;
}
}
const YOUTUBE_ID = /^[A-Za-z0-9_-]{11}$/;
/** "90", "90s" or "1m30s" → seconds; anything else → 0. */
function startSeconds(value: string | null): number {
if (!value) return 0;
if (/^\d+s?$/.test(value)) return parseInt(value, 10);
const parts = /^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?$/.exec(value);
if (!parts) return 0;
return Number(parts[1] ?? 0) * 3600 + Number(parts[2] ?? 0) * 60 + Number(parts[3] ?? 0);
}
/** The video id (and start time, if the link has one) of a YouTube link; null for any other link. */
export function parseYouTube(link: string): { videoId: string; startSeconds: number } | null {
let url: URL;
try {
url = new URL(link);
} catch {
return null;
}
const host = url.hostname.replace(/^(www|m|music)\./, "");
const [first, second] = url.pathname.split("/").filter(Boolean);
let videoId: string | null | undefined;
if (host === "youtu.be") videoId = first;
else if (host === "youtube.com" || host === "youtube-nocookie.com") {
if (first === "watch") videoId = url.searchParams.get("v");
else if (first === "embed" || first === "shorts" || first === "live") videoId = second;
}
if (!videoId || !YOUTUBE_ID.test(videoId)) return null;
return { videoId, startSeconds: startSeconds(url.searchParams.get("t") ?? url.searchParams.get("start")) };
}
// ---------- what the scoreboard gets ----------
export type ScreenMedia =
| { type: "youtube"; videoId: string; startSeconds: number }
/** A link to a file, or a signed URL for an upload. */
| { type: "file"; url: string };
/**
* The one item on screen. A sound's or video's name is never part of this, and an answer only
* once the host has revealed it: the room can read the scoreboard's network tab.
*/
export type OnScreen =
| { kind: "question"; id: string; shownAt: string; text: string; imageUrl: string | null; answer: string | null }
| { kind: "sound"; id: string; shownAt: string; media: ScreenMedia; paused: boolean; answer: string | null }
| { kind: "video"; id: string; shownAt: string; media: ScreenMedia; paused: boolean };
type ScreenFacts = {
current_game_id: string | null;
screen_item_id: string | null;
screen_shown_at: string | null;
screen_answer_shown: boolean;
screen_paused: boolean;
};
type ItemFacts = {
id: string;
game_id: string;
kind: ItemKind;
title: string;
answer: string | null;
media_url: string | null;
storage_path: string | null;
};
/** The item on screen, or null. An item only counts while its game is the current game. */
export function activeItem<Item extends { id: string; game_id: string }>(
event: Pick<ScreenFacts, "current_game_id" | "screen_item_id">,
items: Item[],
): Item | null {
if (!event.screen_item_id || !event.current_game_id) return null;
return (
items.find((item) => item.id === event.screen_item_id && item.game_id === event.current_game_id) ?? null
);
}
/**
* The scoreboard's view of the item on screen. `signedUrl` is the temporary URL for the item's
* upload, if it has one. A sound or video whose media can't be resolved is not shown at all.
*/
export function buildOnScreen(event: ScreenFacts, item: ItemFacts | null, signedUrl: string | null): OnScreen | null {
if (!item) return null;
let media: ScreenMedia | null = null;
if (item.storage_path) media = signedUrl ? { type: "file", url: signedUrl } : null;
else if (item.media_url) {
const youTube = parseYouTube(item.media_url);
media = youTube ? { type: "youtube", ...youTube } : { type: "file", url: item.media_url };
}
const shown = { id: item.id, shownAt: event.screen_shown_at ?? "" };
const answer = event.screen_answer_shown ? item.answer : null;
if (item.kind === "question") {
return { kind: "question", ...shown, text: item.title, imageUrl: media?.type === "file" ? media.url : null, answer };
}
if (!media) return null;
if (item.kind === "sound") return { kind: "sound", ...shown, media, paused: event.screen_paused, answer };
return { kind: "video", ...shown, media, paused: event.screen_paused };
}
// ---------- choosing what to show ----------
type Pickable = { id: string; kind: ItemKind; played_at: string | null };
/**
* A random item of this kind for "pick one for me": one that hasn't been played yet while
* there are any, and never the one already on screen unless it is the only one.
* `random` is Math.random's contract: a number in [0, 1).
*/
export function pickRandom<Item extends Pickable>(
items: Item[],
kind: ItemKind,
onScreenId: string | null,
random: () => number = Math.random,
): Item | null {
const ofKind = items.filter((item) => item.kind === kind);
const others = ofKind.filter((item) => item.id !== onScreenId);
const unplayed = others.filter((item) => item.played_at === null);
const pool = unplayed.length ? unplayed : others.length ? others : ofKind;
return pool[Math.floor(random() * pool.length)] ?? null;
}
/**
* The question to show next, in list order: the one after the question on screen, or — with
* no question on screen — the first unplayed one. Null at the end of the list.
*/
export function nextQuestion<Item extends { id: string; kind: ItemKind; played: boolean }>(
items: Item[],
onScreenId: string | null,
): Item | null {
const questions = items.filter((item) => item.kind === "question");
const current = questions.findIndex((item) => item.id === onScreenId);
if (current >= 0) return questions[current + 1] ?? null;
return questions.find((item) => !item.played) ?? null;
}
+21
View File
@@ -10,6 +10,9 @@ export type SoundName =
| "revealWinner" // drum-roll, then a fanfare for first place
| "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. */
@@ -135,6 +138,24 @@ export function createSoundPlayer(): SoundPlayer {
tone(start + index * 0.11, frequency * 2, 0.4, 0.08, "sine");
});
},
buzzIn: (start) => {
// A bright two-note "ding-ding", nothing like the low time's-up buzzer.
tone(start, 987.77, 0.14, 0.4, "square");
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");
+1
View File
@@ -7,5 +7,6 @@ export const tokenSchema = z.string().regex(/^[0-9a-f]{32}$/, "Invalid link");
export const scoreboardTokenSchema = tokenSchema;
export const judgeTokenSchema = tokenSchema;
export const openTokenSchema = tokenSchema;
export const buzzerTokenSchema = tokenSchema;
export const idSchema = z.uuid();
+67 -1
View File
@@ -2,7 +2,15 @@ import { describe, expect, it } from "vitest";
import { criteriaPresets } from "@/lib/presets";
import { criterionSchema, gameSchema, parsePlacementPoints, type GameDraft } from "./admin";
import {
cohostEmailSchema,
criterionSchema,
gameSchema,
itemSchema,
parsePlacementPoints,
type GameDraft,
type ItemDraft,
} from "./admin";
const criterion = { label: "Pitch", description: "", inputType: "blocks", min: 1, max: 5, step: 1, weight: 1 } as const;
@@ -13,6 +21,7 @@ const game: GameDraft = {
pointsMode: "raw",
multiplier: 1,
placementPoints: [10, 7, 5, 3],
buzzerEnabled: false,
adminNotes: "",
judgeNotes: "",
criteria: [criterion],
@@ -64,6 +73,18 @@ describe("gameSchema", () => {
});
});
describe("cohostEmailSchema", () => {
it("trims and lower-cases, so it matches the email in the login token", () => {
expect(cohostEmailSchema.parse(" Sam@Example.COM ")).toBe("sam@example.com");
});
it("rejects things that aren't an email", () => {
expect(cohostEmailSchema.safeParse("").success).toBe(false);
expect(cohostEmailSchema.safeParse("sam").success).toBe(false);
expect(cohostEmailSchema.safeParse("sam@example.com, kim@example.com").success).toBe(false);
});
});
describe("parsePlacementPoints", () => {
it("splits on commas and spaces", () => {
expect(parsePlacementPoints("10, 7,5 3")).toEqual([10, 7, 5, 3]);
@@ -71,3 +92,48 @@ describe("parsePlacementPoints", () => {
expect(parsePlacementPoints("10, x")).toEqual([10, Number.NaN]);
});
});
describe("itemSchema", () => {
const item = (overrides: Partial<ItemDraft> = {}): ItemDraft => ({
kind: "question",
title: "Who built the ark?",
answer: "",
mediaUrl: "",
storagePath: null,
...overrides,
});
const itemMessage = (overrides: Partial<ItemDraft>) => {
const result = itemSchema.safeParse(item(overrides));
return result.success ? null : result.error.issues[0].message;
};
it("accepts a bare question, and one with a picture", () => {
expect(itemMessage({})).toBeNull();
expect(itemMessage({ answer: "Noah", mediaUrl: " https://example.com/ark.png " })).toBeNull();
expect(itemMessage({ storagePath: "event/file.png" })).toBeNull();
});
it("needs a question or a name", () => {
expect(itemMessage({ title: " " })).toBe("Type the question.");
expect(itemMessage({ kind: "video", title: "", mediaUrl: "https://youtu.be/dQw4w9WgXcQ" })).toMatch(/Give it a name/);
});
it("needs something to play for a sound or a video", () => {
expect(itemMessage({ kind: "sound" })).toBe("Paste a link or choose a file for this sound.");
expect(itemMessage({ kind: "video" })).toBe("Paste a link or choose a file for this video.");
expect(itemMessage({ kind: "sound", storagePath: "event/file.mp3" })).toBeNull();
expect(itemMessage({ kind: "video", mediaUrl: "https://youtu.be/dQw4w9WgXcQ" })).toBeNull();
});
it("takes https links only, and a link or a file but not both", () => {
expect(itemMessage({ kind: "sound", mediaUrl: "http://example.com/a.mp3" })).toMatch(/https/);
expect(itemMessage({ kind: "sound", mediaUrl: "a.mp3" })).toMatch(/https/);
expect(itemMessage({ kind: "sound", mediaUrl: "https://example.com/a.mp3", storagePath: "event/file.mp3" })).toBe(
"Use a link or a file, not both.",
);
});
it("won't use a YouTube link as a question's picture", () => {
expect(itemMessage({ mediaUrl: "https://youtu.be/dQw4w9WgXcQ" })).toMatch(/must be an image/);
});
});
+37
View File
@@ -1,5 +1,6 @@
import { z } from "zod";
import { isHttpsUrl, ITEM_KINDS, parseYouTube } from "@/lib/screen";
import { idSchema } from "@/lib/tokens";
// Limits mirror the CHECK constraints in supabase/migrations/0001_init.sql so that
@@ -22,6 +23,13 @@ export const eventSchema = z.object({
eventDate: z.union([z.iso.date("Pick a valid date."), z.literal("")]),
});
/** A co-host's email, normalised the way event_cohosts stores it (trimmed, lower case). */
export const cohostEmailSchema = z
.string()
.trim()
.toLowerCase()
.pipe(z.email("Enter a valid email address.").max(254, "That email is too long."));
export const teamSchema = z.object({
name: text(60, "Team name"),
color: z.string().regex(/^#[0-9A-Fa-f]{6}$/, "Pick a colour."),
@@ -78,11 +86,40 @@ export const gameSchema = z.object({
.array(z.number().int("Placement points must be whole numbers.").min(0).max(100000))
.min(1, "Add at least one placement value, like 10, 7, 5, 3.")
.max(30),
buzzerEnabled: z.boolean(),
adminNotes: optionalText(4000, "Admin notes"),
judgeNotes: optionalText(4000, "Judge notes"),
criteria: z.array(criterionSchema).min(1, "A game needs at least one criterion.").max(20),
});
export const MAX_ITEMS_PER_GAME = 200;
/** A question, sound or video for a game. Media is a link or an uploaded file's path, never both. */
export const itemSchema = z
.object({
kind: z.enum(ITEM_KINDS),
title: optionalText(500, "The text"),
answer: optionalText(500, "The answer"),
mediaUrl: z.string().trim().max(2000, "That link is too long."),
storagePath: z.string().nullable(),
})
.superRefine((item, context) => {
const issue = (message: string) => context.addIssue({ code: "custom", message });
if (!item.title) {
return issue(item.kind === "question" ? "Type the question." : "Give it a name, so you can find it in the list.");
}
if (item.mediaUrl && item.storagePath) return issue("Use a link or a file, not both.");
if (item.mediaUrl && !isHttpsUrl(item.mediaUrl)) return issue("The link must start with https://");
if (item.kind === "question") {
if (item.mediaUrl && parseYouTube(item.mediaUrl)) {
issue("A question's picture must be an image, not a YouTube link. Add the video as its own item.");
}
} else if (!item.mediaUrl && !item.storagePath) {
issue(`Paste a link or choose a file for this ${item.kind}.`);
}
});
export type ItemDraft = z.input<typeof itemSchema>;
export type CriterionDraft = z.input<typeof criterionSchema>;
export type GameDraft = z.input<typeof gameSchema>;
+44
View File
@@ -0,0 +1,44 @@
-- Game Night Live — team buzzers.
-- Each team gets its own buzzer link; a game opts in with buzzer_enabled.
-- Run after 0002_timer.sql.
--
-- A buzz is one row per (game, team). The database's clock stamps it, so the order never
-- depends on a phone's clock or on which server instance handled the request.
-- Clearing the buzzers for the next question = deleting the game's rows.
alter table games
add column buzzer_enabled boolean not null default false;
-- new_token() is volatile, so every existing team gets its own token here.
alter table teams
add column buzzer_token text not null unique default new_token();
create table buzzes (
id uuid primary key default gen_random_uuid(),
game_id uuid not null references games(id) on delete cascade,
team_id uuid not null references teams(id) on delete cascade,
-- clock_timestamp(), not now(): the real moment of the insert, not the transaction's start.
buzzed_at timestamptz not null default clock_timestamp(),
constraint buzzes_one_per_team unique (game_id, team_id)
);
-- the team must be in the same event as the game
create or replace function validate_buzz() returns trigger
language plpgsql as $$
begin
if (select event_id from teams where id = new.team_id)
is distinct from (select event_id from games where id = new.game_id) then
raise exception 'team does not belong to the game''s event';
end if;
return new;
end $$;
create trigger buzzes_validate
before insert or update on buzzes
for each row execute function validate_buzz();
-- Same rule as every other table: owner only, anon has no policies.
alter table buzzes enable row level security;
create policy owner_all on buzzes for all to authenticated
using (is_event_owner(game_event(game_id))) with check (is_event_owner(game_event(game_id)));
+12
View File
@@ -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;
+99
View File
@@ -0,0 +1,99 @@
-- Game Night Live — sharing an event with other admins (co-hosts).
-- Run after 0004_buzz_result.sql.
--
-- The owner shares an event by email. A co-host can do everything in the event (setup, control
-- room, scores) except delete it or change who it is shared with.
--
-- Shares are keyed by email, not user id, so an admin can be added before their first login.
-- The email comes from the signed JWT, which is only as trustworthy as the project's auth
-- settings: keep "Confirm email" on (magic links confirm it anyway). ADMIN_EMAILS relies on
-- the same claim.
create table event_cohosts (
id uuid primary key default gen_random_uuid(),
event_id uuid not null references events(id) on delete cascade,
email text not null check (email = lower(email) and length(email) between 3 and 254),
created_at timestamptz not null default now(),
constraint event_cohosts_one_per_email unique (event_id, email)
);
create index on event_cohosts(email);
-- ---------- helpers ----------
-- security definer: they read events / event_cohosts without going back through RLS.
create or replace function is_event_cohost(eid uuid) returns boolean
language sql stable security definer set search_path = public as $$
select exists (
select 1 from event_cohosts
where event_id = eid and email = lower(auth.jwt() ->> 'email')
)
$$;
create or replace function is_event_admin(eid uuid) returns boolean
language sql stable security definer set search_path = public as $$
select is_event_owner(eid) or is_event_cohost(eid)
$$;
-- A co-host may update the event row, so the policy can't be what protects the owner column.
create or replace function keep_event_owner() returns trigger
language plpgsql as $$
begin
if new.owner_id is distinct from old.owner_id then
raise exception 'the owner of an event cannot be changed';
end if;
return new;
end $$;
create trigger events_keep_owner
before update on events
for each row execute function keep_event_owner();
-- ---------- RLS ----------
-- Still no anon policies anywhere.
alter table event_cohosts enable row level security;
-- Co-hosts can see who else is on the event; only the owner changes the list.
create policy admin_read on event_cohosts for select to authenticated
using (is_event_admin(event_id));
create policy owner_insert on event_cohosts for insert to authenticated
with check (is_event_owner(event_id));
create policy owner_delete on event_cohosts for delete to authenticated
using (is_event_owner(event_id));
-- events: the owner check stays on the column (not is_event_admin) so that
-- INSERT ... RETURNING can see the row it has just inserted.
drop policy owner_all on events;
create policy admin_read on events for select to authenticated
using (owner_id = auth.uid() or is_event_cohost(id));
create policy owner_insert on events for insert to authenticated
with check (owner_id = auth.uid());
create policy admin_update on events for update to authenticated
using (owner_id = auth.uid() or is_event_cohost(id))
with check (owner_id = auth.uid() or is_event_cohost(id));
create policy owner_delete on events for delete to authenticated
using (owner_id = auth.uid());
-- everything inside an event: owner or co-host
drop policy owner_all on teams;
drop policy owner_all on games;
drop policy owner_all on adjustments;
drop policy owner_all on criteria;
drop policy owner_all on judges;
drop policy owner_all on scores;
drop policy owner_all on buzzes;
create policy admin_all on teams for all to authenticated
using (is_event_admin(event_id)) with check (is_event_admin(event_id));
create policy admin_all on games for all to authenticated
using (is_event_admin(event_id)) with check (is_event_admin(event_id));
create policy admin_all on adjustments for all to authenticated
using (is_event_admin(event_id)) with check (is_event_admin(event_id));
create policy admin_all on criteria for all to authenticated
using (is_event_admin(game_event(game_id))) with check (is_event_admin(game_event(game_id)));
create policy admin_all on judges for all to authenticated
using (is_event_admin(game_event(game_id))) with check (is_event_admin(game_event(game_id)));
create policy admin_all on scores for all to authenticated
using (is_event_admin(game_event(game_id))) with check (is_event_admin(game_event(game_id)));
create policy admin_all on buzzes for all to authenticated
using (is_event_admin(game_event(game_id))) with check (is_event_admin(game_event(game_id)));
+74
View File
@@ -0,0 +1,74 @@
-- Game Night Live — things a game can put on the scoreboard: questions, sounds and videos.
-- Run after 0005_cohosts.sql.
--
-- A game has an ordered list of items. The host puts one on screen at a time from the control
-- room; which one is a column on the event, like the timer. A question shows above the
-- standings, a sound plays through the scoreboard's speakers, a video (karaoke) takes over
-- the screen.
--
-- Media is either a link (YouTube, or a file hosted elsewhere) or a file uploaded to the
-- private "game-media" bucket. The scoreboard never reads the bucket itself: its API hands out
-- a short-lived signed URL, and only for the item that is on screen.
create type game_item_kind as enum ('question', 'sound', 'video');
create table game_items (
id uuid primary key default gen_random_uuid(),
game_id uuid not null references games(id) on delete cascade,
kind game_item_kind not null,
-- The question's text, or the host's name for a sound or video (never sent to the scoreboard).
title text not null check (length(title) between 1 and 500),
-- Secret until the host reveals it.
answer text check (length(answer) between 1 and 500),
media_url text check (media_url ~ '^https://' and length(media_url) <= 2000),
-- "{event id}/{random uuid}.{ext}" in the game-media bucket.
storage_path text unique check (storage_path ~ '^[0-9a-f-]{36}/[0-9a-f-]{36}\.[a-z0-9]{1,5}$'),
sort_order int not null default 0,
-- Last time it went on screen; "pick a random one" prefers items without it.
played_at timestamptz,
created_at timestamptz not null default now(),
constraint game_items_one_source check (media_url is null or storage_path is null),
constraint game_items_media_needed
check (kind = 'question' or media_url is not null or storage_path is not null)
);
create index on game_items(game_id, sort_order);
-- What is on the scoreboard right now. Only counts while the item's game is the current game.
alter table events
add column screen_item_id uuid references game_items(id) on delete set null,
-- Changes every time an item is put on screen, so showing the same one again restarts it.
add column screen_shown_at timestamptz,
add column screen_answer_shown boolean not null default false,
add column screen_paused boolean not null default false;
-- ---------- RLS ----------
-- Same rule as every other table: the event's admins only, anon has no policies.
alter table game_items enable row level security;
create policy admin_all on game_items for all to authenticated
using (is_event_admin(game_event(game_id))) with check (is_event_admin(game_event(game_id)));
-- ---------- storage ----------
-- Private bucket, 50 MB per file (the free plan's ceiling).
insert into storage.buckets (id, name, public, file_size_limit, allowed_mime_types)
values ('game-media', 'game-media', false, 52428800, array['audio/*', 'video/*', 'image/*'])
on conflict (id) do nothing;
-- The event a file belongs to, from the first folder of its path; null for any other shape.
create or replace function media_event(object_name text) returns uuid
language sql immutable as $$
select case
when object_name ~ '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/'
then left(object_name, 36)::uuid
end
$$;
-- Admins upload from the browser with their own session. Still nothing for anon.
create policy game_media_read on storage.objects for select to authenticated
using (bucket_id = 'game-media' and is_event_admin(media_event(name)));
create policy game_media_insert on storage.objects for insert to authenticated
with check (bucket_id = 'game-media' and is_event_admin(media_event(name)));
create policy game_media_delete on storage.objects for delete to authenticated
using (bucket_id = 'game-media' and is_event_admin(media_event(name)));