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:
@@ -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
@@ -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/
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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/
|
||||||
|
```
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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
@@ -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).
|
||||||
@@ -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
@@ -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."
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
Generated
+8000
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
"@tailwindcss/postcss": {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import "server-only";
|
||||||
|
|
||||||
|
import { parseEnv, serverEnvSchema } from "./env";
|
||||||
|
|
||||||
|
export const serverEnv = parseEnv(serverEnvSchema, process.env);
|
||||||
@@ -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/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 },
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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)));
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -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/**"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user