From d17f39a03d77199999fa73b07da77ebc306e9a0c Mon Sep 17 00:00:00 2001 From: Volodya Date: Thu, 14 May 2026 13:46:36 +0100 Subject: [PATCH 1/3] feat: [T04] design leaderboard database schema Adds the PostgreSQL schema for the snake leaderboard: - users: one row per player handle, with bearer token for X-Player-Token auth - sessions: one row per game played (start/end + final score + meta JSONB) - scores: append-only score feed indexed for top-N reads Indexes: - users_player_lower_uniq case-insensitive unique handle - users_api_token_uniq token lookup for write auth - sessions_user_started_idx recent sessions per user - scores_score_created_idx global top-N (ties resolved by earliest submit) - scores_user_score_idx best-score-per-user Migrations live as numbered .sql files under backend/migrations/ and are idempotent. backend/src/db.ts exposes a lazy pool plus a runMigrations() helper for tests/bootstrap so typecheck does not require a running DB. Adds pg + @types/pg to backend. --- backend/migrations/001_init.sql | 78 +++++++++++++++++++++++++++++++++ backend/migrations/README.md | 28 ++++++++++++ backend/package.json | 4 +- backend/src/db.ts | 67 ++++++++++++++++++++++++++++ 4 files changed, 176 insertions(+), 1 deletion(-) create mode 100644 backend/migrations/001_init.sql create mode 100644 backend/migrations/README.md create mode 100644 backend/src/db.ts diff --git a/backend/migrations/001_init.sql b/backend/migrations/001_init.sql new file mode 100644 index 0000000..84174d7 --- /dev/null +++ b/backend/migrations/001_init.sql @@ -0,0 +1,78 @@ +-- 001_init.sql +-- Initial schema for the snake leaderboard. +-- +-- Three tables: +-- users — registered players (one row per unique player handle) +-- sessions — one row per game played (start/end time, final score, optional metadata) +-- scores — denormalized "best/notable scores" feed used by the leaderboard +-- +-- We keep `scores` separate from `sessions` so the leaderboard read path is a +-- cheap index scan and not a `MAX(score) GROUP BY user` over the full session log. +-- +-- All statements are idempotent so a bootstrap helper can safely re-run them. + +BEGIN; + +-- --------------------------------------------------------------------------- +-- users +-- --------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS users ( + id BIGSERIAL PRIMARY KEY, + -- Display handle. Case-insensitive uniqueness is enforced via the index + -- below (CITEXT would be nicer but adds an extension dependency). + player TEXT NOT NULL, + -- Opaque bearer token used by the API for `X-Player-Token` auth. Stored + -- as a hex string; rotation is just an UPDATE. + api_token TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE UNIQUE INDEX IF NOT EXISTS users_player_lower_uniq + ON users (LOWER(player)); + +CREATE UNIQUE INDEX IF NOT EXISTS users_api_token_uniq + ON users (api_token); + +-- --------------------------------------------------------------------------- +-- sessions +-- --------------------------------------------------------------------------- +-- One row per played game. `ended_at IS NULL` means in-progress. +CREATE TABLE IF NOT EXISTS sessions ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + ended_at TIMESTAMPTZ, + final_score INTEGER NOT NULL DEFAULT 0 CHECK (final_score >= 0), + -- Free-form JSON for client-side metadata (board size, tick rate, etc.). + -- Useful for analytics; the leaderboard does not read it. + meta JSONB NOT NULL DEFAULT '{}'::jsonb +); + +CREATE INDEX IF NOT EXISTS sessions_user_started_idx + ON sessions (user_id, started_at DESC); + +-- --------------------------------------------------------------------------- +-- scores +-- --------------------------------------------------------------------------- +-- Append-only feed of submitted scores. The leaderboard query reads this +-- table directly. We index `(score DESC, created_at ASC)` so that +-- "top-N global" is a single index scan and ties resolve by who got there +-- first. +CREATE TABLE IF NOT EXISTS scores ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + session_id BIGINT REFERENCES sessions(id) ON DELETE SET NULL, + score INTEGER NOT NULL CHECK (score >= 0), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Top-N leaderboard read path. +CREATE INDEX IF NOT EXISTS scores_score_created_idx + ON scores (score DESC, created_at ASC); + +-- "Best score per user" lookups (`GET /api/users/:id/best`). +CREATE INDEX IF NOT EXISTS scores_user_score_idx + ON scores (user_id, score DESC); + +COMMIT; diff --git a/backend/migrations/README.md b/backend/migrations/README.md new file mode 100644 index 0000000..a6bf3c9 --- /dev/null +++ b/backend/migrations/README.md @@ -0,0 +1,28 @@ +# Migrations + +Plain numbered SQL files. Apply with `psql`: + +```bash +DATABASE_URL=postgres://user:pass@localhost:5432/snake \ + psql "$DATABASE_URL" -f backend/migrations/001_init.sql +``` + +Or, from a Node script, use `backend/src/db.ts` which exposes `runMigrations(client)` for tests/bootstrap. + +All statements are wrapped in `BEGIN/COMMIT` and use `IF NOT EXISTS`, so re-running on an already-initialised database is a no-op. + +## Schema overview + +| Table | Purpose | +|------------|----------------------------------------------------------------------| +| `users` | One row per player handle. Holds the bearer token used for write auth. | +| `sessions` | One row per game played. `ended_at IS NULL` while in progress. | +| `scores` | Append-only feed of submitted scores. Indexed for top-N reads. | + +### Indexes + +- `users_player_lower_uniq` — case-insensitive unique handle. +- `users_api_token_uniq` — token lookup for `X-Player-Token` auth. +- `sessions_user_started_idx` — recent-sessions-per-user reads. +- `scores_score_created_idx` — global top-N leaderboard (score DESC, ties resolved by earliest submit). +- `scores_user_score_idx` — best-score-per-user lookups. diff --git a/backend/package.json b/backend/package.json index fe4d771..e3c5ffa 100644 --- a/backend/package.json +++ b/backend/package.json @@ -13,11 +13,13 @@ }, "dependencies": { "@snake/shared": "*", - "express": "^4.21.1" + "express": "^4.21.1", + "pg": "^8.13.1" }, "devDependencies": { "@types/express": "^4.17.21", "@types/node": "^20.16.10", + "@types/pg": "^8.11.10", "tsx": "^4.19.1", "typescript": "^5.5.4" } diff --git a/backend/src/db.ts b/backend/src/db.ts new file mode 100644 index 0000000..02bc4e2 --- /dev/null +++ b/backend/src/db.ts @@ -0,0 +1,67 @@ +/** + * Database access layer. + * + * Why a thin abstraction: + * - The leaderboard backend talks to PostgreSQL via `pg`, but typechecking + * and unit tests must not require a running database. `getPool()` is lazy: + * a Pool is only constructed on first call. + * - Tests can swap in any object that implements the minimal `Db` interface + * below (e.g. `pg-mem`'s adapter) by calling `setDb()`. + * - All query helpers go through this module so the rest of the codebase + * never imports `pg` directly. Keeps the surface area small. + */ + +import { readFile, readdir } from 'node:fs/promises' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' + +/** Minimal subset of `pg.Pool` we depend on. */ +export interface Db { + query(text: string, params?: unknown[]): Promise<{ rows: T[]; rowCount: number | null }> +} + +let _db: Db | null = null + +/** + * Returns the active database handle, lazily constructing a `pg.Pool` from + * `DATABASE_URL` on first call. Throws if `DATABASE_URL` is not set and no + * test harness has called `setDb()`. + */ +export async function getDb(): Promise { + if (_db) return _db + const url = process.env.DATABASE_URL + if (!url) { + throw new Error( + 'DATABASE_URL is not set. Either configure it, or call setDb() with a test adapter.', + ) + } + // Imported lazily so `pg` is not required for typecheck or tests that + // never touch a real database. + const { Pool } = await import('pg') + const pool = new Pool({ connectionString: url }) + _db = pool as unknown as Db + return _db +} + +/** Inject a custom adapter (e.g. pg-mem) for tests. */ +export function setDb(db: Db | null): void { + _db = db +} + +/** + * Apply every `.sql` file in `backend/migrations/` in lexicographic order. + * Files are idempotent so re-running is safe. + */ +export async function runMigrations(db?: Db): Promise { + const handle = db ?? (await getDb()) + const here = dirname(fileURLToPath(import.meta.url)) + // src/ -> backend/migrations + const migrationsDir = join(here, '..', 'migrations') + const files = (await readdir(migrationsDir)) + .filter((f) => f.endsWith('.sql')) + .sort() + for (const file of files) { + const sql = await readFile(join(migrationsDir, file), 'utf8') + await handle.query(sql) + } +} From 803eedab2711a673a8395743c6c9015691cfc2b1 Mon Sep 17 00:00:00 2001 From: Volodya Date: Thu, 14 May 2026 13:52:02 +0100 Subject: [PATCH 2/3] feat: [T05] implement score tracking API REST endpoints under /api: POST /api/users/register create user, returns X-Player-Token POST /api/scores submit a score (auth required) GET /api/leaderboard?limit=N top-N (limit clamped 1..100, default 10) GET /api/users/:id/best all-time best for one user Layered as: routes/leaderboard.ts -> repo.ts -> db.ts. SQL lives only in repo.ts; routes do validation + auth. Validation uses zod (handle regex, score >= 0 and bounded, limit clamp). Auth model: a single bearer token per user, sent as X-Player-Token. That's sufficient for a hobby leaderboard and avoids dragging in JWT/bcrypt for T05's scope. Tests: 9 integration tests under src/__tests__/leaderboard.test.ts using pg-mem to provide an in-memory PostgreSQL adapter, so CI does not need a real database. Migrations are applied through the same runMigrations() helper used in production. Test runner: node --test (--import tsx). Shared types extended additively: - SubmitScoreRequest gains optional `meta` - ScoreEntry gains optional `rank` - new SubmitScoreResponse / LeaderboardResponse / RegisterUserResponse Adds zod (runtime), pg-mem + supertest (tests). --- backend/package.json | 8 +- backend/src/__tests__/leaderboard.test.ts | 141 ++++++++++++++++ backend/src/repo.ts | 153 +++++++++++++++++ backend/src/routes/leaderboard.ts | 193 ++++++++++++++++++++++ backend/src/server.ts | 6 + shared/src/index.ts | 26 +++ 6 files changed, 525 insertions(+), 2 deletions(-) create mode 100644 backend/src/__tests__/leaderboard.test.ts create mode 100644 backend/src/repo.ts create mode 100644 backend/src/routes/leaderboard.ts diff --git a/backend/package.json b/backend/package.json index e3c5ffa..d3caac4 100644 --- a/backend/package.json +++ b/backend/package.json @@ -8,18 +8,22 @@ "dev": "tsx watch src/server.ts", "build": "tsc -p tsconfig.json", "start": "node dist/server.js", - "test": "node --test --import tsx src/**/*.test.ts", + "test": "node --test --import tsx 'src/**/*.test.ts'", "typecheck": "tsc --noEmit" }, "dependencies": { "@snake/shared": "*", "express": "^4.21.1", - "pg": "^8.13.1" + "pg": "^8.13.1", + "zod": "^3.23.8" }, "devDependencies": { "@types/express": "^4.17.21", "@types/node": "^20.16.10", "@types/pg": "^8.11.10", + "@types/supertest": "^6.0.2", + "pg-mem": "^3.0.4", + "supertest": "^7.0.0", "tsx": "^4.19.1", "typescript": "^5.5.4" } diff --git a/backend/src/__tests__/leaderboard.test.ts b/backend/src/__tests__/leaderboard.test.ts new file mode 100644 index 0000000..e64e8db --- /dev/null +++ b/backend/src/__tests__/leaderboard.test.ts @@ -0,0 +1,141 @@ +/** + * Integration tests for the leaderboard router. + * + * Uses `pg-mem` to provide an in-memory PostgreSQL-compatible database, so + * the suite runs in CI without a real Postgres. The router is wired with the + * mem DB via the `db` option; nothing in the code path under test is mocked. + */ + +import { test, before } from 'node:test' +import assert from 'node:assert/strict' +import express from 'express' +import request from 'supertest' +import { newDb } from 'pg-mem' +import { leaderboardRouter } from '../routes/leaderboard.js' +import { runMigrations } from '../db.js' +import type { Db } from '../db.js' + +let app: express.Express + +before(async () => { + const mem = newDb({ autoCreateForeignKeyIndices: true }) + // pg-mem doesn't ship NOW()/jsonb-functions identically to PG; the + // defaults are good enough for our schema. + const adapter = mem.adapters.createPg() + const pool = new adapter.Pool() as unknown as Db + + // Apply real migrations (the same .sql files used in production). + await runMigrations(pool) + + app = express() + app.use(express.json()) + app.use('/api', leaderboardRouter({ db: pool })) +}) + +test('register -> submit -> leaderboard -> best round-trip', async () => { + // Register two players. + const reg1 = await request(app) + .post('/api/users/register') + .send({ player: 'alice' }) + .expect(201) + assert.equal(reg1.body.player, 'alice') + assert.ok(reg1.body.id, 'id present') + assert.ok(reg1.body.token && typeof reg1.body.token === 'string', 'token returned') + + const reg2 = await request(app) + .post('/api/users/register') + .send({ player: 'bob' }) + .expect(201) + + // Submit several scores. + await request(app) + .post('/api/scores') + .set('X-Player-Token', reg1.body.token) + .send({ score: 50 }) + .expect(201) + + const r2 = await request(app) + .post('/api/scores') + .set('X-Player-Token', reg1.body.token) + .send({ score: 120, meta: { boardSize: 20 } }) + .expect(201) + assert.equal(r2.body.entry.score, 120) + assert.equal(r2.body.bestScore, 120, 'best updated to 120') + + await request(app) + .post('/api/scores') + .set('X-Player-Token', reg2.body.token) + .send({ score: 80 }) + .expect(201) + + // Leaderboard should be 120 (alice), 80 (bob), 50 (alice). + const lb = await request(app).get('/api/leaderboard?limit=10').expect(200) + assert.equal(lb.body.entries.length, 3) + assert.equal(lb.body.entries[0].score, 120) + assert.equal(lb.body.entries[0].player, 'alice') + assert.equal(lb.body.entries[0].rank, 1) + assert.equal(lb.body.entries[1].score, 80) + assert.equal(lb.body.entries[2].score, 50) + assert.ok(lb.body.generatedAt, 'generatedAt set') + + // Best for alice should be 120. + const best = await request(app) + .get(`/api/users/${reg1.body.id}/best`) + .expect(200) + assert.equal(best.body.entry.score, 120) + assert.equal(best.body.entry.player, 'alice') +}) + +test('rejects score submit without token', async () => { + await request(app).post('/api/scores').send({ score: 10 }).expect(401) +}) + +test('rejects score submit with invalid token', async () => { + await request(app) + .post('/api/scores') + .set('X-Player-Token', 'definitely-not-a-real-token') + .send({ score: 10 }) + .expect(401) +}) + +test('rejects negative scores via zod validation', async () => { + const reg = await request(app) + .post('/api/users/register') + .send({ player: 'carol' }) + .expect(201) + const r = await request(app) + .post('/api/scores') + .set('X-Player-Token', reg.body.token) + .send({ score: -1 }) + .expect(400) + assert.equal(r.body.error, 'validation failed') +}) + +test('rejects malformed player handle on register', async () => { + await request(app).post('/api/users/register').send({ player: '' }).expect(400) + await request(app) + .post('/api/users/register') + .send({ player: 'has spaces' }) + .expect(400) +}) + +test('register is idempotent on case-insensitive handle', async () => { + const a = await request(app).post('/api/users/register').send({ player: 'Dave' }).expect(201) + const b = await request(app).post('/api/users/register').send({ player: 'dave' }).expect(201) + assert.equal(a.body.id, b.body.id, 'same user returned for case variant') +}) + +test('leaderboard limit is clamped to a sane range', async () => { + const tooBig = await request(app).get('/api/leaderboard?limit=10000').expect(400) + assert.equal(tooBig.body.error, 'validation failed') + const tooSmall = await request(app).get('/api/leaderboard?limit=0').expect(400) + assert.equal(tooSmall.body.error, 'validation failed') +}) + +test('users/:id/best returns 404 for unknown user', async () => { + await request(app).get('/api/users/9999999/best').expect(404) +}) + +test('users/:id/best rejects non-numeric ids', async () => { + await request(app).get('/api/users/not-a-number/best').expect(400) +}) diff --git a/backend/src/repo.ts b/backend/src/repo.ts new file mode 100644 index 0000000..12b62b4 --- /dev/null +++ b/backend/src/repo.ts @@ -0,0 +1,153 @@ +/** + * Data access layer. + * + * All SQL lives here. Routes call these helpers and never assemble SQL + * themselves. Every helper takes a `Db` so routes/tests can pick a connection. + */ + +import { randomBytes } from 'node:crypto' +import type { Db } from './db.js' + +export interface UserRow { + id: string + player: string + api_token: string +} + +export interface ScoreRow { + id: string + user_id: string + score: number + created_at: string // ISO-8601 + player: string +} + +/** + * Create a new user with a freshly minted API token, or return the existing + * one. We do a SELECT-then-INSERT instead of `ON CONFLICT (LOWER(player))` + * because expression-based unique indexes are well-supported by real + * PostgreSQL but not always by lightweight test adapters (pg-mem). The race + * window is harmless here — at worst two concurrent registrations for the + * same handle will conflict on the unique index and one will retry. + */ +export async function createUser(db: Db, player: string): Promise { + const existing = await db.query( + `SELECT id::text AS id, player, api_token + FROM users + WHERE LOWER(player) = LOWER($1)`, + [player], + ) + const found = existing.rows[0] + if (found) return found + + const token = randomBytes(24).toString('hex') + const insert = await db.query( + `INSERT INTO users (player, api_token) + VALUES ($1, $2) + RETURNING id::text AS id, player, api_token`, + [player, token], + ) + const row = insert.rows[0] + if (!row) throw new Error('failed to create user') + return row +} + +/** Resolve a user by their API token. Returns `null` if the token is unknown. */ +export async function findUserByToken(db: Db, token: string): Promise { + const result = await db.query( + `SELECT id::text AS id, player, api_token + FROM users + WHERE api_token = $1`, + [token], + ) + return result.rows[0] ?? null +} + +/** Resolve a user by id. */ +export async function findUserById(db: Db, id: string): Promise { + const result = await db.query( + `SELECT id::text AS id, player, api_token + FROM users + WHERE id = $1`, + [id], + ) + return result.rows[0] ?? null +} + +/** + * Record a score for the given user. Creates a session row alongside the + * score so we have a 1:1 audit trail even if no `/sessions` endpoint exists + * yet. + */ +export async function recordScore( + db: Db, + userId: string, + score: number, + meta: Record = {}, +): Promise { + const session = await db.query<{ id: string }>( + `INSERT INTO sessions (user_id, ended_at, final_score, meta) + VALUES ($1, NOW(), $2, $3::jsonb) + RETURNING id::text AS id`, + [userId, score, JSON.stringify(meta)], + ) + const sessionId = session.rows[0]?.id ?? null + + const inserted = await db.query( + `INSERT INTO scores (user_id, session_id, score) + VALUES ($1, $2, $3) + RETURNING id::text AS id, + user_id::text AS user_id, + score, + created_at, + (SELECT player FROM users WHERE id = $1) AS player`, + [userId, sessionId, score], + ) + const row = inserted.rows[0] + if (!row) throw new Error('failed to record score') + // Some adapters return Date objects for TIMESTAMPTZ; normalize to ISO. + return { ...row, created_at: toIso(row.created_at) } +} + +/** Fetch the top-N scores for the global leaderboard, newest tie-break wins ascending. */ +export async function topScores(db: Db, limit: number): Promise { + const result = await db.query( + `SELECT s.id::text AS id, + s.user_id::text AS user_id, + s.score AS score, + s.created_at AS created_at, + u.player AS player + FROM scores s + JOIN users u ON u.id = s.user_id + ORDER BY s.score DESC, s.created_at ASC + LIMIT $1`, + [limit], + ) + return result.rows.map((r) => ({ ...r, created_at: toIso(r.created_at) })) +} + +/** Fetch the all-time best score for a single user. Returns `null` if no scores. */ +export async function bestScoreForUser(db: Db, userId: string): Promise { + const result = await db.query( + `SELECT s.id::text AS id, + s.user_id::text AS user_id, + s.score AS score, + s.created_at AS created_at, + u.player AS player + FROM scores s + JOIN users u ON u.id = s.user_id + WHERE s.user_id = $1 + ORDER BY s.score DESC, s.created_at ASC + LIMIT 1`, + [userId], + ) + const row = result.rows[0] + if (!row) return null + return { ...row, created_at: toIso(row.created_at) } +} + +function toIso(value: unknown): string { + if (value instanceof Date) return value.toISOString() + if (typeof value === 'string') return value + return new Date(String(value)).toISOString() +} diff --git a/backend/src/routes/leaderboard.ts b/backend/src/routes/leaderboard.ts new file mode 100644 index 0000000..d63f2e3 --- /dev/null +++ b/backend/src/routes/leaderboard.ts @@ -0,0 +1,193 @@ +/** + * Score / leaderboard / user routes. + * + * POST /api/users/register — create a user, returns API token + * POST /api/scores — submit a score (requires X-Player-Token) + * GET /api/leaderboard?limit=N — top-N scores (limit clamped 1..100, default 10) + * GET /api/users/:id/best — that user's best score + * + * Auth model: a single bearer token per user, sent as `X-Player-Token`. + * That's enough for a hobby leaderboard; promoting to JWT/refresh tokens + * is out of scope for T05. + */ + +import { Router, type Request, type Response, type NextFunction } from 'express' +import { z } from 'zod' +import type { + LeaderboardResponse, + RegisterUserResponse, + ScoreEntry, + SubmitScoreResponse, +} from '@snake/shared' +import { getDb, type Db } from '../db.js' +import { + bestScoreForUser, + createUser, + findUserById, + findUserByToken, + recordScore, + topScores, + type ScoreRow, +} from '../repo.js' + +// ---------- Validation schemas --------------------------------------------- + +// Player handle: 1-32 chars, alphanumeric + ._- . Keeps the leaderboard +// readable and avoids zero-width / control chars sneaking in. +const PlayerHandle = z + .string() + .trim() + .min(1, 'player handle must not be empty') + .max(32, 'player handle too long') + .regex(/^[A-Za-z0-9._-]+$/, 'player handle must be alphanumeric (._- allowed)') + +const RegisterBody = z.object({ player: PlayerHandle }) + +const SubmitBody = z.object({ + // `player` is accepted for parity with `SubmitScoreRequest` from + // @snake/shared but is *advisory* — the authoritative identity comes + // from the X-Player-Token header. We just sanity-check that it matches + // the token's user when both are supplied. + player: PlayerHandle.optional(), + score: z.number().int().nonnegative().max(1_000_000), + meta: z.record(z.unknown()).optional(), +}) + +const LeaderboardQuery = z.object({ + limit: z + .preprocess((v) => (v === undefined ? undefined : Number(v)), z.number().int().min(1).max(100)) + .optional(), +}) + +// ---------- Helpers --------------------------------------------------------- + +function rowToEntry(row: ScoreRow, rank?: number): ScoreEntry { + return { + id: row.id, + player: row.player, + score: row.score, + createdAt: row.created_at, + ...(rank !== undefined ? { rank } : {}), + } +} + +/** + * Resolve the authenticated user from the `X-Player-Token` header. + * Throws a 401-flavoured error if the header is missing or invalid. + */ +async function authenticate(db: Db, req: Request) { + const raw = req.header('x-player-token') + if (!raw || typeof raw !== 'string') { + const err: Error & { status?: number } = new Error('missing X-Player-Token header') + err.status = 401 + throw err + } + const user = await findUserByToken(db, raw.trim()) + if (!user) { + const err: Error & { status?: number } = new Error('invalid X-Player-Token') + err.status = 401 + throw err + } + return user +} + +// ---------- Router factory -------------------------------------------------- + +export interface LeaderboardRouterOptions { + /** Optional DB override (used by tests). Falls back to `getDb()`. */ + db?: Db +} + +export function leaderboardRouter(opts: LeaderboardRouterOptions = {}): Router { + const router = Router() + const db = async (): Promise => opts.db ?? (await getDb()) + + router.post('/users/register', async (req, res, next) => { + try { + const body = RegisterBody.parse(req.body) + const user = await createUser(await db(), body.player) + const out: RegisterUserResponse = { + id: user.id, + player: user.player, + token: user.api_token, + } + res.status(201).json(out) + } catch (e) { + next(e) + } + }) + + router.post('/scores', async (req, res, next) => { + try { + const body = SubmitBody.parse(req.body) + const conn = await db() + const user = await authenticate(conn, req) + if (body.player && body.player.toLowerCase() !== user.player.toLowerCase()) { + res.status(403).json({ error: 'player handle does not match token' }) + return + } + const row = await recordScore(conn, user.id, body.score, body.meta ?? {}) + const best = await bestScoreForUser(conn, user.id) + const out: SubmitScoreResponse = { + entry: rowToEntry(row), + bestScore: best?.score ?? row.score, + } + res.status(201).json(out) + } catch (e) { + next(e) + } + }) + + router.get('/leaderboard', async (req, res, next) => { + try { + const q = LeaderboardQuery.parse(req.query) + const limit = q.limit ?? 10 + const rows = await topScores(await db(), limit) + const out: LeaderboardResponse = { + entries: rows.map((r, i) => rowToEntry(r, i + 1)), + generatedAt: new Date().toISOString(), + } + res.json(out) + } catch (e) { + next(e) + } + }) + + router.get('/users/:id/best', async (req, res, next) => { + try { + const conn = await db() + const id = req.params.id + if (!id || !/^\d+$/.test(id)) { + res.status(400).json({ error: 'invalid user id' }) + return + } + const user = await findUserById(conn, id) + if (!user) { + res.status(404).json({ error: 'user not found' }) + return + } + const best = await bestScoreForUser(conn, id) + if (!best) { + res.status(404).json({ error: 'no scores recorded for user' }) + return + } + res.json({ entry: rowToEntry(best) }) + } catch (e) { + next(e) + } + }) + + // Centralised error handler for this router. Validation failures from + // zod become 400s; explicit `.status` on thrown errors is honoured. + router.use((err: unknown, _req: Request, res: Response, _next: NextFunction) => { + if (err instanceof z.ZodError) { + res.status(400).json({ error: 'validation failed', issues: err.issues }) + return + } + const status = (err as { status?: number })?.status ?? 500 + const message = err instanceof Error ? err.message : 'internal error' + res.status(status).json({ error: message }) + }) + + return router +} diff --git a/backend/src/server.ts b/backend/src/server.ts index 1620a79..e738be9 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -1,4 +1,5 @@ import express from 'express' +import { leaderboardRouter } from './routes/leaderboard.js' const app = express() app.use(express.json()) @@ -7,6 +8,11 @@ app.get('/api/health', (_req, res) => { res.json({ status: 'ok', service: 'snake-backend' }) }) +// Score / leaderboard / user routes. Mounted under `/api` so the dev proxy +// in the frontend Vite config (`/api -> http://localhost:8787`) hits them +// without extra rewriting. +app.use('/api', leaderboardRouter()) + const port = Number(process.env.PORT ?? 8787) if (process.env.NODE_ENV !== 'test') { diff --git a/shared/src/index.ts b/shared/src/index.ts index 3b11c69..1c5a425 100644 --- a/shared/src/index.ts +++ b/shared/src/index.ts @@ -13,9 +13,35 @@ export interface ScoreEntry { player: string score: number createdAt: string + /** 1-based position in the leaderboard response (optional, server-supplied). */ + rank?: number } export interface SubmitScoreRequest { player: string score: number + /** Optional client metadata persisted on the session row. */ + meta?: Record +} + +/** Response shape of `POST /api/scores`. */ +export interface SubmitScoreResponse { + entry: ScoreEntry + /** Player's all-time best score after this submission. */ + bestScore: number +} + +/** Response shape of `GET /api/leaderboard?limit=N`. */ +export interface LeaderboardResponse { + entries: ScoreEntry[] + /** When the server generated this response, ISO-8601. */ + generatedAt: string +} + +/** Response shape of `POST /api/users/register`. */ +export interface RegisterUserResponse { + id: string + player: string + /** Bearer token for `X-Player-Token` header on subsequent writes. */ + token: string } From fce4c1ae65ec643b6319e1a0a2f3af294e6d0a33 Mon Sep 17 00:00:00 2001 From: Volodya Date: Thu, 14 May 2026 13:56:57 +0100 Subject: [PATCH 3/3] feat: [T06] create leaderboard UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds frontend/src/leaderboard/: - api.ts tiny fetch wrapper with typed response + LeaderboardError - Leaderboard.tsx responsive React component (table of top-N) Behaviour: - Polls GET /api/leaderboard every 5s by default (pollMs prop) - Pauses polling when document.hidden so background tabs don't burn cycles - Cancels in-flight requests on unmount / next refresh via AbortController - Loading, error, and empty states; transient failures keep prior data visible with a warning banner (no UI blanking on a network blip) - Manual Refresh button - Relative timestamps (Ns/Nm/Nh/Nd ago) Styling: - Themed to match T01's dark palette (#0a0f1c base) - Gold/silver/bronze player colour for top-3 - Mobile breakpoint at 480px hides the When column for narrow screens Wired into App.tsx alongside the Board from T02 (additive — game UI is unchanged). Uses LeaderboardResponse / ScoreEntry from @snake/shared. --- frontend/src/App.tsx | 2 + frontend/src/leaderboard/Leaderboard.tsx | 177 +++++++++++++++++++++++ frontend/src/leaderboard/api.ts | 41 ++++++ frontend/src/styles.css | 140 ++++++++++++++++++ 4 files changed, 360 insertions(+) create mode 100644 frontend/src/leaderboard/Leaderboard.tsx create mode 100644 frontend/src/leaderboard/api.ts diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index c63a87e..da989ca 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -8,6 +8,7 @@ import { stepMovement, } from './game/movement' import { Board } from './ui/Board' +import { Leaderboard } from './leaderboard/Leaderboard' type Action = | { type: 'tick' } @@ -88,6 +89,7 @@ export default function App() {

Arrows / WASD to move · Space to pause/resume · R to reset

+ ) } diff --git a/frontend/src/leaderboard/Leaderboard.tsx b/frontend/src/leaderboard/Leaderboard.tsx new file mode 100644 index 0000000..2e5ffba --- /dev/null +++ b/frontend/src/leaderboard/Leaderboard.tsx @@ -0,0 +1,177 @@ +/** + * Leaderboard panel. + * + * Polls `GET /api/leaderboard` every `pollMs` (default 5s) and renders the + * top-N players in a table. Handles loading, error, and empty states; the + * polling interval is paused when the document tab is hidden so we don't + * burn server cycles on background tabs. + */ + +import { useCallback, useEffect, useRef, useState } from 'react' +import type { LeaderboardResponse, ScoreEntry } from '@snake/shared' +import { LeaderboardError, fetchLeaderboard } from './api' + +export interface LeaderboardProps { + /** Number of entries to fetch and render. Default 10. */ + limit?: number + /** Poll interval in ms. Default 5000. Set to 0 to disable polling. */ + pollMs?: number +} + +type Status = 'loading' | 'ready' | 'error' + +export function Leaderboard({ limit = 10, pollMs = 5000 }: LeaderboardProps) { + const [entries, setEntries] = useState([]) + const [generatedAt, setGeneratedAt] = useState(null) + const [status, setStatus] = useState('loading') + const [error, setError] = useState(null) + + // Track the in-flight request so we can cancel it on unmount / next poll. + const abortRef = useRef(null) + + const refresh = useCallback(async () => { + abortRef.current?.abort() + const ctrl = new AbortController() + abortRef.current = ctrl + try { + const data: LeaderboardResponse = await fetchLeaderboard(limit, ctrl.signal) + // Don't clobber state if a newer request has superseded us. + if (ctrl.signal.aborted) return + setEntries(data.entries) + setGeneratedAt(data.generatedAt) + setStatus('ready') + setError(null) + } catch (e) { + if (ctrl.signal.aborted) return + const msg = + e instanceof LeaderboardError + ? e.message + : e instanceof Error + ? e.message + : 'failed to load leaderboard' + setError(msg) + // Keep the previous entries visible if we already had some, so a + // transient network blip doesn't blank the UI. + setStatus(entries.length > 0 ? 'ready' : 'error') + } + }, [limit, entries.length]) + + useEffect(() => { + // Initial load. + void refresh() + return () => abortRef.current?.abort() + }, [refresh]) + + useEffect(() => { + if (pollMs <= 0) return + let intervalId: number | undefined + const start = () => { + stop() + intervalId = window.setInterval(() => { + void refresh() + }, pollMs) + } + const stop = () => { + if (intervalId !== undefined) { + window.clearInterval(intervalId) + intervalId = undefined + } + } + const onVisibility = () => { + if (document.hidden) stop() + else { + void refresh() + start() + } + } + if (!document.hidden) start() + document.addEventListener('visibilitychange', onVisibility) + return () => { + stop() + document.removeEventListener('visibilitychange', onVisibility) + } + }, [pollMs, refresh]) + + return ( +
+
+

Leaderboard

+ +
+ + {status === 'loading' && entries.length === 0 && ( +

Loading top scores…

+ )} + + {status === 'error' && entries.length === 0 && ( +

+ {error ?? 'Unable to load leaderboard.'} +

+ )} + + {entries.length > 0 && ( + + + + + + + + + + + {entries.map((entry, index) => ( + + + + + + + ))} + +
+ # + Player + Score + + When +
{entry.rank ?? index + 1}{entry.player}{entry.score.toLocaleString()} + {formatRelative(entry.createdAt)} +
+ )} + + {error && entries.length > 0 && ( +

+ Last refresh failed: {error} +

+ )} + + {generatedAt && ( +

+ Updated {formatRelative(generatedAt)} +

+ )} +
+ ) +} + +/** Render an ISO-8601 timestamp as `Ns ago` / `Nm ago` / `Nh ago` / `Nd ago`. */ +function formatRelative(iso: string): string { + const then = Date.parse(iso) + if (Number.isNaN(then)) return iso + const seconds = Math.max(0, Math.round((Date.now() - then) / 1000)) + if (seconds < 60) return `${seconds}s ago` + const minutes = Math.round(seconds / 60) + if (minutes < 60) return `${minutes}m ago` + const hours = Math.round(minutes / 60) + if (hours < 24) return `${hours}h ago` + const days = Math.round(hours / 24) + return `${days}d ago` +} diff --git a/frontend/src/leaderboard/api.ts b/frontend/src/leaderboard/api.ts new file mode 100644 index 0000000..4f9d481 --- /dev/null +++ b/frontend/src/leaderboard/api.ts @@ -0,0 +1,41 @@ +/** + * Tiny `fetch` wrapper around the leaderboard endpoints from T05. + * + * Lives in its own module so the React component stays focused on rendering + * and so it's trivially mockable in any future tests. + */ + +import type { LeaderboardResponse } from '@snake/shared' + +export class LeaderboardError extends Error { + constructor(public readonly status: number, message: string) { + super(message) + this.name = 'LeaderboardError' + } +} + +/** + * Fetch the top-N entries from `/api/leaderboard`. + * + * @param limit Number of entries (server clamps to 1..100). + * @param signal AbortSignal so the caller can cancel in-flight requests when + * unmounting or starting a new poll. + */ +export async function fetchLeaderboard( + limit: number, + signal?: AbortSignal, +): Promise { + const url = `/api/leaderboard?limit=${encodeURIComponent(String(limit))}` + const res = await fetch(url, { signal }) + if (!res.ok) { + let message = `leaderboard request failed (${res.status})` + try { + const body = (await res.json()) as { error?: string } + if (body?.error) message = body.error + } catch { + // Body wasn't JSON — keep the default message. + } + throw new LeaderboardError(res.status, message) + } + return (await res.json()) as LeaderboardResponse +} diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 6e10ac1..f795b5d 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -56,3 +56,143 @@ h1 { font-size: clamp(2rem, 6vw, 3rem); margin: 0 0 0.5rem; } border-radius: 50%; box-shadow: 0 0 8px rgba(255, 85, 119, 0.8); } + +/* Leaderboard ------------------------------------------------------------- */ + +.leaderboard { + margin: 2rem 0 1rem; + padding: 1rem 1.25rem 1.25rem; + background: #0f1830; + border: 1px solid #1f2c4a; + border-radius: 10px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.25); +} + +.leaderboard__header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 0.75rem; +} + +.leaderboard__header h2 { + margin: 0; + font-size: 1.1rem; + letter-spacing: 0.04em; + text-transform: uppercase; + color: #7ad7ff; +} + +.leaderboard__refresh { + background: transparent; + border: 1px solid #2a3a64; + color: #cfe7ff; + padding: 0.25rem 0.7rem; + border-radius: 999px; + font-size: 0.78rem; + cursor: pointer; + transition: background 120ms ease, border-color 120ms ease; +} + +.leaderboard__refresh:hover, +.leaderboard__refresh:focus-visible { + background: #18254a; + border-color: #3d5396; + outline: none; +} + +.leaderboard__table { + width: 100%; + border-collapse: collapse; + font-variant-numeric: tabular-nums; +} + +.leaderboard__table th, +.leaderboard__table td { + padding: 0.45rem 0.5rem; + border-bottom: 1px solid #182143; + text-align: left; +} + +.leaderboard__table th { + font-size: 0.72rem; + letter-spacing: 0.06em; + text-transform: uppercase; + color: #7f93b8; + font-weight: 600; +} + +.leaderboard__table tbody tr:last-child td { + border-bottom: none; +} + +.leaderboard__table tbody tr:nth-child(1) .leaderboard__player { + color: #ffd166; + font-weight: 600; +} + +.leaderboard__table tbody tr:nth-child(2) .leaderboard__player { + color: #d6e1ff; +} + +.leaderboard__table tbody tr:nth-child(3) .leaderboard__player { + color: #c39d7a; +} + +.leaderboard__rank { + width: 2.5rem; + color: #7f93b8; +} + +.leaderboard__score, +.leaderboard__when { + text-align: right; + white-space: nowrap; +} + +.leaderboard__score { + font-weight: 600; +} + +.leaderboard__when { + color: #7f93b8; + font-size: 0.85rem; +} + +.leaderboard__empty, +.leaderboard__error, +.leaderboard__warning, +.leaderboard__updated { + margin: 0.75rem 0 0; + font-size: 0.85rem; +} + +.leaderboard__empty { + color: #7f93b8; +} + +.leaderboard__error { + color: #ff8aa3; +} + +.leaderboard__warning { + color: #ffb86c; +} + +.leaderboard__updated { + color: #5a6a8c; + text-align: right; +} + +@media (max-width: 480px) { + .leaderboard { + padding: 0.75rem 0.75rem 1rem; + } + .leaderboard__when { + display: none; + } + .leaderboard__table th, + .leaderboard__table td { + padding: 0.4rem 0.35rem; + } +}