Phase 0: scaffold Next.js, Tailwind tokens, Supabase clients, env validation

Next.js 16 App Router with strict TypeScript, Tailwind 4 theme tokens from
docs/UI.md, Vitest, and the three Supabase clients. Env vars are validated
with zod in next.config.ts so dev and build fail fast with a readable error.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-03 12:27:11 +02:00
co-authored by Claude Opus 5.5
commit c89a82295b
29 changed files with 9107 additions and 0 deletions
+13
View File
@@ -0,0 +1,13 @@
# Supabase
NEXT_PUBLIC_SUPABASE_URL=https://YOUR-PROJECT.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key
SUPABASE_SERVICE_ROLE_KEY=your-service-role-key # server only. never NEXT_PUBLIC_
# Comma-separated emails allowed to use /admin
ADMIN_EMAILS=you@example.com
# Branding
NEXT_PUBLIC_APP_NAME=Game Night Live
# Used to build share links (no trailing slash)
NEXT_PUBLIC_SITE_URL=http://localhost:3000
+45
View File
@@ -0,0 +1,45 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
!.env.example
# IDE
.idea/
+42
View File
@@ -0,0 +1,42 @@
# CLAUDE.md — Game Night Live
You are building a live scoring web app for a church youth group's game nights.
Read `docs/` before writing code. Docs are the source of truth; if code and docs disagree, ask.
## Stack (do not swap without asking)
- Next.js (App Router, latest stable) + TypeScript (strict)
- Tailwind CSS
- Supabase: Postgres, Auth (admin only, email magic link), Realtime (Broadcast)
- zod for all input validation
- Vitest for unit tests (scoring logic MUST be tested)
- npm as package manager
- Deploy target: Vercel
Verify current Next.js and Supabase APIs against their official docs before using them;
don't rely on memory for SDK method names (esp. `@supabase/ssr` and Realtime broadcast from the server).
## Non-negotiable rules
1. **The service role key is server-only.** Never import it in a client component. Never prefix it `NEXT_PUBLIC_`.
2. **Anon users get zero table access.** RLS is on for every table. Scoreboard and judge pages
read/write ONLY through Next.js route handlers that validate the token and use the service-role client.
3. **Hiding the scoreboard is enforced on the server.** When hidden, the scoreboard API must not
return scores at all. Never hide with CSS only — someone will open devtools.
4. **Realtime messages carry no data.** Broadcast a `{ type: "refresh" }` ping; clients refetch
from the API. This keeps hidden/sanitised data rules in one place.
5. **One scoring engine.** All score math lives in `src/lib/scoring/` as pure functions with tests.
Admin view, scoreboard API and judge view all call it. No score math in components or SQL views.
6. Tokens are unguessable (≥ 128 bits, hex). Validate shape with zod before hitting the DB.
7. Mobile-first for judge pages (they're on phones). Scoreboard is 16:9 projector-first.
8. Small commits per task. After each phase, stop and summarise what to test manually.
## Code conventions
- `src/app` routes, `src/lib` logic, `src/components` UI, `src/lib/supabase/{server,admin,browser}.ts` clients.
- Server actions for admin mutations; route handlers (`src/app/api/...`) for public/judge.
- Every mutation that affects scores/visibility calls `notify()` from `src/lib/realtime.ts`
which pings the relevant channels (see ARCHITECTURE.md).
- Use DB types generated by `supabase gen types typescript`.
- Explain *why* in PR-style summaries, not just what changed.
## Commands
- `npm run dev` / `npm run build` / `npm run test` / `npm run lint`
- Migrations live in `supabase/migrations/`. Apply via Supabase CLI or paste into SQL editor.
+40
View File
@@ -0,0 +1,40 @@
# Game Night Live — build pack
A spec pack for Claude Code. It builds a live scoring app for youth game nights:
an admin control room, a projector scoreboard, and per-game judge links.
## What's in here
| File | What it's for |
|---|---|
| `CLAUDE.md` | Rules Claude Code reads automatically every session |
| `docs/KICKOFF_PROMPT.md` | The first message you paste into Claude Code |
| `docs/PRD.md` | Features, roles, user flows |
| `docs/ARCHITECTURE.md` | Stack, routes, realtime, security model |
| `docs/DATA_MODEL.md` | Tables explained |
| `docs/SCORING.md` | Exact scoring math (judges, averages, placement points, bonuses) |
| `docs/UI.md` | Screens + design direction |
| `docs/BUILD_PLAN.md` | Phased tasks with acceptance criteria |
| `supabase/migrations/0001_init.sql` | Starting schema + RLS |
| `.env.example` | Env vars you'll need |
## Setup (≈15 min, before you open Claude Code)
1. **Supabase** — create a free project at supabase.com.
- Settings → API: copy the Project URL, anon key, and service_role key.
- Authentication → Providers: make sure Email is on (magic link is fine).
2. **Vercel** — free account, connect GitHub (for deploy at the end).
3. **Node** — install current LTS. Check with `node -v`.
4. Unzip this folder somewhere, `git init`, open it in **WebStorm**.
5. Copy `.env.example` → `.env.local` and paste in your Supabase values + your admin email.
## Running Claude Code in WebStorm
- Install the **Claude Code** plugin from JetBrains Marketplace (or just run `claude` in WebStorm's terminal from the project root).
- Open `docs/KICKOFF_PROMPT.md`, paste its contents into Claude Code.
- Let it go phase by phase. After each phase: run it, click around, commit, then say "next phase".
## Tips
- Keep `docs/` as the source of truth. If you change your mind on a feature, edit the doc first, then tell Claude Code "I updated PRD.md section X, implement it".
- Test the scoreboard on the actual projector PC + one judge on a phone before game night. Wi-Fi at church is the #1 thing that'll bite you.
+79
View File
@@ -0,0 +1,79 @@
# Architecture
## Stack
Next.js (App Router) on Vercel · Supabase (Postgres + Auth + Realtime Broadcast) · Tailwind · zod · Vitest.
## Routes
| Route | Who | Notes |
|---|---|---|
| `/login` | admin | Supabase magic link |
| `/admin` | admin | Events list |
| `/admin/events/[eventId]` | admin | Setup: teams, games, criteria, judges, share links |
| `/admin/events/[eventId]/live` | admin | Control room |
| `/s/[scoreboardToken]` | public | Scoreboard |
| `/j/[judgeToken]` | judge | Personal judge page |
| `/jo/[openToken]` | judge | Open link → name form |
| `GET /api/public/scoreboard/[token]` | public | Sanitised scoreboard payload |
| `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}` |
Admin mutations = server actions. Middleware protects `/admin/*`: must be logged in AND email in `ADMIN_EMAILS`.
## Supabase clients (`src/lib/supabase/`)
- `browser.ts` — anon key, used for Realtime subscribe + admin auth session.
- `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.
- 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 (e.g. 20 req/s per token, in-memory is fine for v1).
## 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
- `admin:{eventId}` — admin control room listens (judge submissions land here)
`src/lib/realtime.ts` exposes `notify({ eventId, gameId? })` which looks up the scoreboard token and sends to the right channels from the server.
Use Supabase's server-side broadcast (REST endpoint / `httpSend` — check current docs).
Debounce refetches client-side (~150ms) so a burst of taps causes one fetch.
Clients also poll every 10s as a safety net, and refetch on reconnect / tab focus.
## Scoring engine (`src/lib/scoring/`)
Pure TS, no DB access. Input = plain data (teams, games, criteria, scores, adjustments). Output = per-game results + standings. See SCORING.md. Fully unit tested.
## Scoreboard payload shape
```ts
type ScoreboardPayload = {
eventName: string;
visibility: 'visible' | 'frozen' | 'hidden';
currentGame: { id: string; title: string; index: number; total: number } | null;
reveal: { active: boolean; revealedCount: number } | null;
teams: Array<{
id: string; name: string; color: string;
// omitted entirely when hidden:
rank?: number; total?: number;
perGame?: Array<{ gameId: string; points: number }>;
}>;
updatedAt: string;
};
```
During reveal mode, teams not yet revealed are sent without rank/total.
## Folder layout
```
src/
app/ (routes above)
components/ (ui/, scoreboard/, judge/, admin/)
lib/
scoring/ (index.ts, *.test.ts)
supabase/ (browser.ts, server.ts, admin.ts)
realtime.ts
tokens.ts (zod schemas)
queries/ (data loaders shared by admin + API)
supabase/migrations/
```
+63
View File
@@ -0,0 +1,63 @@
# Build plan
Work one phase at a time. At the end of each phase: run lint + tests, commit, and give the
human a short "how to test this manually" checklist. Don't start the next phase until told.
## Phase 0 — Scaffold
- Next.js + TS strict + Tailwind + ESLint + Vitest. `src/` layout per ARCHITECTURE.md.
- Fonts: Big Shoulders Display + Atkinson Hyperlegible via `next/font/google`. Tokens from UI.md as CSS variables + Tailwind theme.
- Supabase clients (`browser`, `server`, `admin` with `server-only`).
- `.env.local` loading; fail fast with a clear error if vars are missing.
**Done when:** `npm run dev` shows a placeholder home; `npm run test` runs a dummy test.
## Phase 1 — Database + types
- Apply `supabase/migrations/0001_init.sql`. Generate TS types into `src/lib/database.types.ts`.
- `src/lib/tokens.ts` zod schemas (32-char hex).
**Done when:** tables visible in Supabase; anon key cannot `select` from any table (write a quick script/test proving it).
## Phase 2 — Scoring engine (TDD)
- Implement SCORING.md in `src/lib/scoring/` as pure functions.
- All listed test cases + edge cases.
**Done when:** tests green, 100% branch coverage on the scoring module.
## Phase 3 — Admin auth + setup screens
- `/login` magic link; middleware guards `/admin/*` with `ADMIN_EMAILS`.
- Events list + create/duplicate/delete.
- Event setup: teams CRUD (colour picker, reorder), games builder with criteria + presets, notes.
**Done when:** I can build a "Worship Game Night" with 4 teams and a Karaoke game with 5 criteria (1–5) and a Guess the Lyric game with 1 number criterion.
## Phase 4 — Scoreboard + realtime
- `src/lib/realtime.ts` `notify()` (server broadcast) + `useRefreshChannel(channel, refetch)` hook (debounce, reconnect, 10s poll, refetch on focus).
- `GET /api/public/scoreboard/[token]` building the payload via the scoring engine.
- `/s/[token]` page per UI.md: FLIP rank animation, count-up totals, reduced motion, fullscreen, cursor hide, connection dot.
**Done when:** changing anything in the DB via admin (or SQL + a manual notify) updates an open scoreboard tab in < 1s.
## Phase 5 — Control room (direct scoring)
- `/admin/events/[id]/live`: game list, prev/next, set current (→ live), mark done.
- Scoring grid with block + number inputs, autosave, optimistic UI.
- Bonus/penalty panel with quick buttons, custom, reason, undo.
- Keyboard shortcuts.
**Done when:** I run a full direct-scored game and the scoreboard tracks every change.
## Phase 6 — Visibility + reveal
- Visible / Frozen / Hidden toggle. Freeze stores snapshot server-side. Hidden strips scores in the API.
- Reveal mode: start, reveal next (bottom-up), finish → visible.
**Done when:** in Hidden mode, the network tab on the scoreboard contains NO totals. Reveal works end-to-end.
## Phase 7 — Judges
- Nominate judges, copy link, QR codes (use a small QR lib), revoke.
- Open link toggle + `/jo/[token]` name flow + localStorage.
- `/j/[token]` mobile page: team cards, spotlight pinned, block rows, autosave status, read-only states.
- `PUT /api/judge/[token]/scores` with all checks from ARCHITECTURE.md + rate limit. Clearing = DELETE or `value: null`.
- Control room in judged mode: averages, judge progress matrix, per-judge breakdown, override cells, spotlight select, lock toggle.
**Done when:** 2 phones judging Karaoke at once, admin sees progress live, scoreboard updates with averages, override works, lock makes judges read-only.
## Phase 8 — Polish + deploy
- Share panel (all links + QR in one place, "copy all judge links for this game" as text for WhatsApp).
- Empty states + error copy per UI.md.
- Accessibility pass (focus, contrast, tap targets).
- Deploy to Vercel; set env vars; set Supabase auth redirect URLs to the Vercel domain.
**Done when:** full dry run on the deployed URL: laptop admin + projector scoreboard + 2 phone judges.
## Stretch (only if asked)
Participants within teams · co-host admins · countdown timer on scoreboard · sound effects on reveal · CSV export of results.
+19
View File
@@ -0,0 +1,19 @@
# Data model
Full SQL in `supabase/migrations/0001_init.sql`.
| Table | Purpose | Key columns |
|---|---|---|
| `events` | A game night | `owner_id`, `scoreboard_token`, `scoreboard_visibility`, `frozen_snapshot`, `current_game_id`, `reveal_active`, `reveal_count` |
| `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` |
| `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` |
## 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.
- `frozen_snapshot` holds the `ScoreboardPayload` captured at freeze time.
+18
View File
@@ -0,0 +1,18 @@
Paste everything below the line into Claude Code.
---
We're building "Game Night Live", a live scoring app for my church youth group's game nights.
Before writing any code:
1. Read CLAUDE.md and every file in docs/ plus supabase/migrations/0001_init.sql.
2. Give me a short summary of what you understood, list any gaps/contradictions you found,
and ask me any questions you need answered (max 5).
3. Confirm the current versions/APIs you'll use for Next.js, @supabase/ssr and Supabase Realtime
server-side broadcast by checking the official docs.
Then start Phase 0 from docs/BUILD_PLAN.md. Stop at the end of each phase, show me
what to test manually, and wait for me to say "next phase".
My .env.local is already filled in. I'm using WebStorm and npm.
Explain the *why* behind key decisions as you go — keep it brief.
+101
View File
@@ -0,0 +1,101 @@
# PRD — Game Night Live
## 1. Goal
Run a youth game night (e.g. "Worship Game Night") from one admin tab while a projector tab shows a
live scoreboard, and judges score from their phones via links. Versatile enough for any game format.
## 2. Roles
| Role | Access | How they get in |
|---|---|---|
| **Admin** | Everything | 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) |
## 3. Core concepts
- **Event** — one game night. Has teams, games, one scoreboard link, visibility state.
- **Team** — name + colour. Belongs to an event.
- **Game** — one activity. Ordered. Has a scoring mode:
- `judged` — judges submit scores via links; admin can see progress & override.
- `direct` — admin enters scores directly.
- **Criterion ("block")** — one thing being scored in a game. A game has 1..N criteria.
- e.g. Karaoke: Pitch, Energy, Lyrics accuracy, Stage presence, Crowd reaction (each 1–5).
- e.g. Guess the Lyric: one criterion "Correct answers" (number input, 0–20).
- Input type: `blocks` (tap a number in a row of buttons) or `number` (free numeric input / +/- stepper).
- Has min, max, step, weight, and a description (shown to whoever scores it).
- **Points mode** per game (how a game's result becomes leaderboard points):
- `raw` — game points = criteria total × multiplier.
- `placement` — teams ranked by criteria total, then awarded placement points (default 10/7/5/3).
- **Adjustment** — bonus or penalty points with a reason, optionally tied to a game.
- **Notes** — `admin_notes` (only admin sees) and `judge_notes` (judges see) per game.
## 4. Features
### 4.1 Admin — Setup (`/admin/events/[id]`)
- Create/edit/delete events; duplicate an event (copies teams, games, criteria — not scores).
- Teams: add, rename, recolour, reorder, delete.
- Games builder: add, reorder (drag or up/down), duplicate, delete.
- Title, description, scoring mode, points mode, multiplier, placement points.
- 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).
- 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.
### 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.
- Game switcher: Prev / Next buttons + list of all games with status (upcoming / live / done).
Setting a game as current marks it `live` and shows it on the scoreboard.
- Current game panel:
- Scoring grid: rows = teams, columns = criteria, using the same block/number inputs.
- Direct mode: admin types/taps here. Autosaves.
- Judged mode: shows per-team average per criterion, a judge-progress matrix
(✓ per judge per team), and per-judge breakdown on expand. Admin can override a cell
(override wins over judge average; clearly marked; can be cleared).
- Spotlight: pick "now performing" team — judges see it highlighted.
- Lock judging toggle (judges become read-only).
- Mark game done.
- Admin notes visible.
- Bonus/penalty: per team quick buttons (+1, +5, +10, −5) and custom amount + reason.
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, `?` help.
### 4.3 Scoreboard (`/s/[token]`)
- Projector-first, 16:9, readable from the back of a hall.
- Shows: event name, current game title, team standings (rank, name, colour, total points),
optionally per-game points columns for completed games.
- Updates live (< 1s) when admin changes anything. Rank changes animate.
- **Visible** — live totals.
- **Frozen** — shows totals as they were the moment it was frozen, with a subtle "scores frozen" note.
Server stores the snapshot; live scores are not sent.
- **Hidden** — no scores sent at all. Shows team names (no order implied — alphabetical) and the current game, with a "scores are hidden" state.
- **Reveal mode** (admin-triggered): scoreboard reveals final standings last place → first,
one team per admin click ("Reveal next"), then sets visibility to Visible.
- Auto-reconnects; also polls every 10s as a fallback. Shows a small offline dot if disconnected.
- Fullscreen button; hides cursor after inactivity.
### 4.4 Judge (`/j/[token]`)
- Mobile-first. Shows game title, judge notes, and their own name.
- One card per team (spotlighted team pinned on top + highlighted).
- Each card: one block row per criterion (e.g. 1 2 3 4 5 big tap targets) with the criterion
label + description; number criteria get a stepper.
- Autosaves on every tap; shows saved ✓ / saving / error per card.
- Judges see only their own scores — never other judges' or totals.
- If judging locked or game done: read-only with a message. If revoked: "This link is no longer active."
- Open link (`/jo/[token]`): enter name → creates judge → redirects to personal link and stores it
in localStorage so a refresh keeps them in.
## 5. Non-functional
- Live latency target < 1s on normal Wi-Fi.
- Works with ~10 teams, ~20 games, ~10 judges per game without lag.
- No data loss on refresh; all state server-side.
- Accessible: visible focus, sufficient contrast, tap targets ≥ 44px.
## 6. Out of scope (v1)
- Individual contestants within teams (stretch: `participants` per team).
- Multiple admins / co-hosts (stretch).
- Timers/buzzers (stretch).
+42
View File
@@ -0,0 +1,42 @@
# Scoring rules
All implemented in `src/lib/scoring/` as pure functions. Tests must cover every rule below.
## 1. Cell value (team × criterion)
- **Direct game:** the admin score row. Missing → not scored.
- **Judged game:**
- If an admin override row exists → use it.
- Else average of non-revoked judges' values for that cell. Missing judges are ignored (not zero).
- No judge values → not scored.
## 2. Team raw score for a game
`raw = Σ (cellValue × criterion.weight)` over scored criteria.
A team with zero scored cells is **unscored** for that game (not 0).
## 3. Game points
- **raw mode:** `points = round2(raw × multiplier)`.
- **placement mode:** rank scored teams by raw desc. Award `placement_points[rank-1] ?? 0`.
- Ties: tied teams share the **average** of the places they span, rounded to 2dp.
e.g. two teams tied for 1st with [10,7,5] → each gets 8.5; next team gets 5.
- Then × multiplier.
- Unscored teams get 0 and are not ranked.
- Only games with status `live` or `done` count toward totals. `upcoming` games contribute nothing
(prevents half-set-up games leaking).
## 4. Event total
`total = Σ game points + Σ adjustments.points`
## 5. Standings
Sort by total desc. Ties share rank (standard competition: 1,1,3). Tie display order: team `sort_order`.
## Test cases to include (minimum)
1. Karaoke: 5 criteria × 1–5, 3 judges, one judge missing a team → average uses 2 judges.
2. Override beats judge average.
3. Revoked judge excluded.
4. Weights applied.
5. Placement with a 2-way tie at 1st and 3 teams.
6. Placement with more teams than placement_points → extra teams get 0.
7. Multiplier on both modes.
8. Upcoming game ignored.
9. Adjustments (+ and −) added; adjustment tied to an upcoming game still counts (it's explicit).
10. Standings tie → shared rank.
+79
View File
@@ -0,0 +1,79 @@
# UI & design direction
## Concept: "Sports-hall scoreboard at a worship night"
Chunky, legible-from-the-back numbers; team colours do the shouting; everything else stays calm.
Spend the boldness in ONE place: the scoreboard's rank-change moment. Admin + judge UIs are quiet and functional.
## Tokens
| Name | Hex | Use |
|---|---|---|
| Stage | `#1B1640` | Scoreboard background (deep indigo, not black) |
| Floodlight | `#FFD23F` | Leader highlight, focus rings on scoreboard |
| Chalk | `#F5F7FA` | Admin/judge background |
| Ink | `#14213D` | Primary text on light |
| Signal | `#2EC4B6` | Saved/connected state |
| Alert | `#E63946` | Errors, penalties, hidden state |
Team colours are user-chosen; pick text colour per team by contrast (white or Ink).
Type:
- **Big Shoulders Display** (Google Fonts) — scoreboard numbers, team names, game titles. Tabular numbers.
- **Atkinson Hyperlegible** — all body/UI text (readable on phones + projector).
Sentence case everywhere. No all-caps labels.
## Screens
### Scoreboard (16:9)
```
┌──────────────────────────────────────────────────────────────┐
│ Worship Game Night Game 3 of 8: Karaoke │
│ │
│ 1 ████ Lions 142 │
│ 2 ████ Eagles 128 │
│ 3 ████ Sharks 120 │
│ 4 ████ Wolves 97 │
│ │
│ ● live ⛶ │
└──────────────────────────────────────────────────────────────┘
```
- Each row: rank, colour bar (full-height block in team colour), name, total. Row height scales with team count to fill the screen.
- Leader row gets a Floodlight outline.
- On update: rows slide to new positions (FLIP animation, ~600ms), changed totals count up. Respect `prefers-reduced-motion` (instant swap).
- Hidden: team colour blocks in alphabetical order, totals replaced by a single centred message "Scores are hidden — final reveal soon".
- Reveal mode: rows appear bottom-up, one per admin click, with a short drum-roll pause effect (visual only).
### Admin control room (desktop/laptop)
```
┌ Games ─────────┐┌ Karaoke · live ───────────────────────┐┌ Scoreboard ─────┐
│ ✓ Name that... ││ [Prev] [Next] Spotlight: [Lions ▾] ││ (●Visible)Frozen│
│ ✓ Guess lyric ││ ││ Hidden │
│ ▶ Karaoke ││ Team Pitch Energy Lyrics Stage Crowd││ [Open] [Reveal] │
│ ○ Charades ││ Lions 4.3 5.0 3.7 4.0 4.7 ││ │
│ ○ Bible quiz ││ Eagles … │├ Bonus ──────────┤
│ ││ Judges: Eben ✓✓✓✓ Melissa ✓✓✗✗ ││ Lions +1 +5 +10 │
│ ││ Notes (only you): … ││ … │
└────────────────┘└───────────────────────────────────────┘└─────────────────┘
```
Collapses to stacked panels on tablet.
### Judge (phone)
```
┌─────────────────────────┐
│ Karaoke │
│ Judging as Melissa │
│ ▸ Judge notes │
├─────────────────────────┤
│ ★ Now: Lions │
│ Pitch │
│ [1][2][3][4][5] │
│ Energy │
│ [1][2][3][4][5] │
│ … Saved ✓ │
├─────────────────────────┤
│ Eagles … │
└─────────────────────────┘
```
Selected block filled in team colour. Tap the selected block again to clear.
## 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."
+18
View File
@@ -0,0 +1,18 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;
+11
View File
@@ -0,0 +1,11 @@
import type { NextConfig } from "next";
import { parseEnv, serverEnvSchema } from "./src/lib/env";
// Fail fast: `next dev` and `next build` refuse to start with a broken .env.local,
// instead of failing later on the first request that needs Supabase.
parseEnv(serverEnvSchema, process.env);
const nextConfig: NextConfig = {};
export default nextConfig;
+8000
View File
File diff suppressed because it is too large Load Diff
+34
View File
@@ -0,0 +1,34 @@
{
"name": "game-night-live",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@supabase/ssr": "^0.12.7",
"@supabase/supabase-js": "^2.117.2",
"next": "16.3.8",
"react": "19.2.8",
"react-dom": "19.2.8",
"server-only": "^0.0.1",
"zod": "^4.6.5"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^22",
"@types/react": "^19",
"@types/react-dom": "^19",
"@vitest/coverage-v8": "^5.0.3",
"eslint": "^9",
"eslint-config-next": "16.3.8",
"tailwindcss": "^4",
"typescript": "^5",
"vitest": "^5.0.3"
}
}
+7
View File
@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
+33
View File
@@ -0,0 +1,33 @@
@import "tailwindcss";
/* Design tokens from docs/UI.md. */
:root {
--stage: #1b1640;
--floodlight: #ffd23f;
--chalk: #f5f7fa;
--ink: #14213d;
--signal: #2ec4b6;
--alert: #e63946;
}
/* Expose the tokens as Tailwind utilities: bg-stage, text-ink, font-display, ... */
@theme inline {
--color-stage: var(--stage);
--color-floodlight: var(--floodlight);
--color-chalk: var(--chalk);
--color-ink: var(--ink);
--color-signal: var(--signal);
--color-alert: var(--alert);
--font-display: var(--font-big-shoulders), ui-sans-serif, system-ui, sans-serif;
--font-sans: var(--font-atkinson), ui-sans-serif, system-ui, sans-serif;
}
body {
background: var(--chalk);
color: var(--ink);
}
:focus-visible {
outline: 3px solid var(--ink);
outline-offset: 2px;
}
+33
View File
@@ -0,0 +1,33 @@
import type { Metadata } from "next";
import { Atkinson_Hyperlegible, Big_Shoulders } from "next/font/google";
import { publicEnv } from "@/lib/env.public";
import "./globals.css";
// Google Fonts renamed "Big Shoulders Display" to "Big Shoulders"; same typeface.
const bigShoulders = Big_Shoulders({
subsets: ["latin"],
variable: "--font-big-shoulders",
display: "swap",
});
const atkinson = Atkinson_Hyperlegible({
subsets: ["latin"],
weight: ["400", "700"],
variable: "--font-atkinson",
display: "swap",
});
export const metadata: Metadata = {
title: publicEnv.NEXT_PUBLIC_APP_NAME,
description: "Live scoring for game nights: control room, projector scoreboard and judge links.",
};
export default function RootLayout({ children }: LayoutProps<"/">) {
return (
<html lang="en" className={`${bigShoulders.variable} ${atkinson.variable}`}>
<body className="min-h-dvh font-sans antialiased">{children}</body>
</html>
);
}
+32
View File
@@ -0,0 +1,32 @@
import { publicEnv } from "@/lib/env.public";
const tokens = [
{ name: "Stage", className: "bg-stage" },
{ name: "Floodlight", className: "bg-floodlight" },
{ name: "Chalk", className: "bg-chalk" },
{ name: "Ink", className: "bg-ink" },
{ name: "Signal", className: "bg-signal" },
{ name: "Alert", className: "bg-alert" },
];
// Placeholder home for Phase 0: proves fonts, tokens and env loading work.
export default function Home() {
return (
<main className="mx-auto flex min-h-dvh max-w-2xl flex-col justify-center gap-8 p-6">
<div>
<h1 className="font-display text-6xl font-bold tabular-nums">
{publicEnv.NEXT_PUBLIC_APP_NAME}
</h1>
<p className="mt-2 text-lg">Scaffold is running. Admin, scoreboard and judge pages come next.</p>
</div>
<ul className="grid grid-cols-3 gap-3 sm:grid-cols-6">
{tokens.map((token) => (
<li key={token.name} className="text-sm">
<div className={`h-12 rounded border border-ink/20 ${token.className}`} />
{token.name}
</li>
))}
</ul>
</main>
);
}
+9
View File
@@ -0,0 +1,9 @@
import { parseEnv, publicEnvSchema } from "./env";
// Each var is referenced literally so Next.js can inline it into the client bundle.
export const publicEnv = parseEnv(publicEnvSchema, {
NEXT_PUBLIC_SUPABASE_URL: process.env.NEXT_PUBLIC_SUPABASE_URL,
NEXT_PUBLIC_SUPABASE_ANON_KEY: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY,
NEXT_PUBLIC_APP_NAME: process.env.NEXT_PUBLIC_APP_NAME,
NEXT_PUBLIC_SITE_URL: process.env.NEXT_PUBLIC_SITE_URL,
});
+5
View File
@@ -0,0 +1,5 @@
import "server-only";
import { parseEnv, serverEnvSchema } from "./env";
export const serverEnv = parseEnv(serverEnvSchema, process.env);
+31
View File
@@ -0,0 +1,31 @@
import { describe, expect, it } from "vitest";
import { parseEnv, serverEnvSchema } from "./env";
const valid = {
NEXT_PUBLIC_SUPABASE_URL: "https://example.supabase.co",
NEXT_PUBLIC_SUPABASE_ANON_KEY: "anon",
NEXT_PUBLIC_SITE_URL: "http://localhost:3000",
SUPABASE_SERVICE_ROLE_KEY: "service",
ADMIN_EMAILS: "Host@Example.com, second@example.com",
};
describe("parseEnv", () => {
it("parses a valid env and normalises admin emails", () => {
const env = parseEnv(serverEnvSchema, valid);
expect(env.ADMIN_EMAILS).toEqual(["host@example.com", "second@example.com"]);
expect(env.NEXT_PUBLIC_APP_NAME).toBe("Game Night Live");
});
it("names every missing or blank variable in one error", () => {
const broken = { ...valid, SUPABASE_SERVICE_ROLE_KEY: undefined, ADMIN_EMAILS: " " };
expect(() => parseEnv(serverEnvSchema, broken)).toThrowError(
/SUPABASE_SERVICE_ROLE_KEY[\s\S]*ADMIN_EMAILS[\s\S]*\.env\.local/,
);
});
it("rejects a site URL with a trailing slash", () => {
const broken = { ...valid, NEXT_PUBLIC_SITE_URL: "http://localhost:3000/" };
expect(() => parseEnv(serverEnvSchema, broken)).toThrowError(/NEXT_PUBLIC_SITE_URL/);
});
});
+53
View File
@@ -0,0 +1,53 @@
import { z } from "zod";
// Schemas only — no values are read here, so this file is safe to import from
// next.config.ts, tests, client and server code alike.
export const publicEnvSchema = z.object({
NEXT_PUBLIC_SUPABASE_URL: z.url(),
NEXT_PUBLIC_SUPABASE_ANON_KEY: z.string().min(1),
NEXT_PUBLIC_APP_NAME: z.string().min(1).default("Game Night Live"),
NEXT_PUBLIC_SITE_URL: z
.url()
.refine((url) => !url.endsWith("/"), "must not end with a slash"),
});
export const serverEnvSchema = publicEnvSchema.extend({
SUPABASE_SERVICE_ROLE_KEY: z.string().min(1),
ADMIN_EMAILS: z
.string()
.transform((value) =>
value
.split(",")
.map((email) => email.trim().toLowerCase())
.filter(Boolean),
)
.pipe(z.array(z.email()).min(1, "needs at least one email")),
});
export type PublicEnv = z.infer<typeof publicEnvSchema>;
export type ServerEnv = z.infer<typeof serverEnvSchema>;
type EnvSource = Record<string, string | undefined>;
/** Parses env vars, throwing one readable error that lists every problem. */
export function parseEnv<T extends z.ZodType>(schema: T, source: EnvSource): z.infer<T> {
// Treat blank values ("FOO=") the same as missing ones.
const cleaned = Object.fromEntries(
Object.entries(source).filter(([, value]) => value !== undefined && value.trim() !== ""),
);
const result = schema.safeParse(cleaned);
if (!result.success) {
const problems = result.error.issues
.map((issue) => {
const name = issue.path.join(".") || "(env)";
return ` - ${name}: ${name in cleaned ? issue.message : "is missing"}`;
})
.join("\n");
throw new Error(
`Invalid or missing environment variables:\n${problems}\n` +
"Copy .env.example to .env.local and fill in the values.",
);
}
return result.data;
}
+15
View File
@@ -0,0 +1,15 @@
import "server-only";
import { createClient } from "@supabase/supabase-js";
import { serverEnv } from "@/lib/env.server";
/**
* Service-role client. Bypasses RLS, so only call it from route handlers
* AFTER the scoreboard/judge token has been validated, and scope every query by that token.
*/
export function createAdminClient() {
return createClient(serverEnv.NEXT_PUBLIC_SUPABASE_URL, serverEnv.SUPABASE_SERVICE_ROLE_KEY, {
auth: { persistSession: false, autoRefreshToken: false },
});
}
+11
View File
@@ -0,0 +1,11 @@
import { createBrowserClient } from "@supabase/ssr";
import { publicEnv } from "@/lib/env.public";
/** Anon-key client for the browser: Realtime subscriptions + the admin auth session. */
export function createClient() {
return createBrowserClient(
publicEnv.NEXT_PUBLIC_SUPABASE_URL,
publicEnv.NEXT_PUBLIC_SUPABASE_ANON_KEY,
);
}
+33
View File
@@ -0,0 +1,33 @@
import "server-only";
import { createServerClient } from "@supabase/ssr";
import { cookies } from "next/headers";
import { publicEnv } from "@/lib/env.public";
/** Cookie-based client for admin server components/actions. RLS applies as the signed-in admin. */
export async function createClient() {
const cookieStore = await cookies();
return createServerClient(
publicEnv.NEXT_PUBLIC_SUPABASE_URL,
publicEnv.NEXT_PUBLIC_SUPABASE_ANON_KEY,
{
cookies: {
getAll() {
return cookieStore.getAll();
},
setAll(cookiesToSet) {
try {
cookiesToSet.forEach(({ name, value, options }) =>
cookieStore.set(name, value, options),
);
} catch {
// Server Components can't write cookies. Safe to ignore: the proxy
// refreshes the session cookie on every request.
}
},
},
},
);
}
+190
View File
@@ -0,0 +1,190 @@
-- Game Night Live — initial schema
-- Requires Postgres 15+ (Supabase default) for NULLS NOT DISTINCT.
create extension if not exists pgcrypto;
-- ---------- enums ----------
create type scoring_mode as enum ('judged', 'direct');
create type points_mode as enum ('raw', 'placement');
create type game_status as enum ('upcoming', 'live', 'done');
create type scoreboard_visibility as enum ('visible', 'frozen', 'hidden');
create type criterion_input as enum ('blocks', 'number');
create type judge_source as enum ('nominated', 'open_link');
-- token helper: 32 hex chars = 128 bits
create or replace function new_token() returns text
language sql volatile as $$ select encode(gen_random_bytes(16), 'hex') $$;
-- ---------- tables ----------
create table events (
id uuid primary key default gen_random_uuid(),
owner_id uuid not null references auth.users(id) on delete cascade,
name text not null check (length(name) between 1 and 120),
event_date date,
scoreboard_token text not null unique default new_token(),
scoreboard_visibility scoreboard_visibility not null default 'visible',
frozen_snapshot jsonb,
current_game_id uuid,
reveal_active boolean not null default false,
reveal_count int not null default 0,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create table teams (
id uuid primary key default gen_random_uuid(),
event_id uuid not null references events(id) on delete cascade,
name text not null check (length(name) between 1 and 60),
color text not null default '#3A86FF' check (color ~ '^#[0-9A-Fa-f]{6}$'),
sort_order int not null default 0,
created_at timestamptz not null default now()
);
create index on teams(event_id);
create table games (
id uuid primary key default gen_random_uuid(),
event_id uuid not null references events(id) on delete cascade,
title text not null check (length(title) between 1 and 120),
description text,
sort_order int not null default 0,
scoring_mode scoring_mode not null default 'direct',
points_mode points_mode not null default 'raw',
multiplier numeric(6,2) not null default 1 check (multiplier >= 0),
placement_points int[] not null default '{10,7,5,3}',
status game_status not null default 'upcoming',
admin_notes text,
judge_notes text,
open_token text not null unique default new_token(),
open_link_enabled boolean not null default false,
judging_locked boolean not null default false,
spotlight_team_id uuid references teams(id) on delete set null,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create index on games(event_id, sort_order);
alter table events
add constraint events_current_game_fk
foreign key (current_game_id) references games(id) on delete set null;
create table criteria (
id uuid primary key default gen_random_uuid(),
game_id uuid not null references games(id) on delete cascade,
label text not null check (length(label) between 1 and 60),
description text,
input_type criterion_input not null default 'blocks',
min_value numeric(8,2) not null default 1,
max_value numeric(8,2) not null default 5,
step numeric(8,2) not null default 1 check (step > 0),
weight numeric(6,2) not null default 1 check (weight >= 0),
sort_order int not null default 0,
check (max_value > min_value),
-- blocks render one button per step; keep it tappable
check (input_type <> 'blocks' or (max_value - min_value) / step <= 10)
);
create index on criteria(game_id, sort_order);
create table judges (
id uuid primary key default gen_random_uuid(),
game_id uuid not null references games(id) on delete cascade,
name text not null check (length(name) between 1 and 60),
token text not null unique default new_token(),
source judge_source not null default 'nominated',
revoked boolean not null default false,
created_at timestamptz not null default now()
);
create index on judges(game_id);
create table scores (
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,
criterion_id uuid not null references criteria(id) on delete cascade,
judge_id uuid references judges(id) on delete cascade, -- NULL = admin score / override
value numeric(8,2) not null,
updated_at timestamptz not null default now(),
constraint scores_one_per_cell unique nulls not distinct (team_id, criterion_id, judge_id)
);
create index on scores(game_id);
create table adjustments (
id uuid primary key default gen_random_uuid(),
event_id uuid not null references events(id) on delete cascade,
team_id uuid not null references teams(id) on delete cascade,
game_id uuid references games(id) on delete set null,
points numeric(8,2) not null check (points <> 0),
reason text,
created_at timestamptz not null default now()
);
create index on adjustments(event_id);
-- ---------- integrity triggers ----------
-- score must be in range, on step, and criterion must belong to the score's game
create or replace function validate_score() returns trigger
language plpgsql as $$
declare c criteria%rowtype;
begin
select * into c from criteria where id = new.criterion_id;
if c.game_id <> new.game_id then
raise exception 'criterion does not belong to game';
end if;
if new.value < c.min_value or new.value > c.max_value then
raise exception 'value % out of range [%, %]', new.value, c.min_value, c.max_value;
end if;
if mod(new.value - c.min_value, c.step) <> 0 then
raise exception 'value % not on step %', new.value, c.step;
end if;
if new.judge_id is not null and
(select game_id from judges where id = new.judge_id) <> new.game_id then
raise exception 'judge does not belong to game';
end if;
new.updated_at := now();
return new;
end $$;
create trigger scores_validate
before insert or update on scores
for each row execute function validate_score();
create or replace function touch_updated_at() returns trigger
language plpgsql as $$ begin new.updated_at := now(); return new; end $$;
create trigger events_touch before update on events for each row execute function touch_updated_at();
create trigger games_touch before update on games for each row execute function touch_updated_at();
-- ---------- RLS ----------
-- Only the authenticated owner can touch anything. Anon has NO policies.
-- Public + judge access goes through server route handlers using the service role.
create or replace function is_event_owner(eid uuid) returns boolean
language sql stable security definer set search_path = public as $$
select exists (select 1 from events where id = eid and owner_id = auth.uid())
$$;
create or replace function game_event(gid uuid) returns uuid
language sql stable security definer set search_path = public as $$
select event_id from games where id = gid
$$;
alter table events enable row level security;
alter table teams enable row level security;
alter table games enable row level security;
alter table criteria enable row level security;
alter table judges enable row level security;
alter table scores enable row level security;
alter table adjustments enable row level security;
create policy owner_all on events for all to authenticated
using (owner_id = auth.uid()) with check (owner_id = auth.uid());
create policy owner_all on teams for all to authenticated
using (is_event_owner(event_id)) with check (is_event_owner(event_id));
create policy owner_all on games for all to authenticated
using (is_event_owner(event_id)) with check (is_event_owner(event_id));
create policy owner_all on adjustments for all to authenticated
using (is_event_owner(event_id)) with check (is_event_owner(event_id));
create policy owner_all on criteria for all to authenticated
using (is_event_owner(game_event(game_id))) with check (is_event_owner(game_event(game_id)));
create policy owner_all on judges for all to authenticated
using (is_event_owner(game_event(game_id))) with check (is_event_owner(game_event(game_id)));
create policy owner_all on scores for all to authenticated
using (is_event_owner(game_event(game_id))) with check (is_event_owner(game_event(game_id)));
+34
View File
@@ -0,0 +1,34 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
}
+17
View File
@@ -0,0 +1,17 @@
import { fileURLToPath } from "node:url";
import { defineConfig } from "vitest/config";
export default defineConfig({
resolve: {
alias: { "@": fileURLToPath(new URL("./src", import.meta.url)) },
},
test: {
environment: "node",
include: ["src/**/*.test.ts"],
coverage: {
provider: "v8",
include: ["src/lib/scoring/**"],
},
},
});