From 6b52e1e9721b7a3ed82e286d471594d845cfaa36 Mon Sep 17 00:00:00 2001 From: Humza Butt Date: Fri, 3 Jul 2026 12:49:45 +0100 Subject: [PATCH 01/23] added new api variables --- .env.example | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.env.example b/.env.example index 7cd3233..9bf772f 100644 --- a/.env.example +++ b/.env.example @@ -21,5 +21,7 @@ CLOUDFLARE_API_TOKEN="" # --- Local dev (SPA + API) --- VITE_API_BASE="http://localhost:8787" +VITE_API_BASE_STAGING="https://oche-api-staging.humza-butt.space" +VITE_API_BASE_PROD="https://oche-api.humza-butt.space" APP_ORIGIN="http://localhost:5173" MEDIA_SIGNING_SECRET="dev-media-signing-secret-change-in-prod" From e266df3edcab40a258e450fd66a3fd3c7e3f49b6 Mon Sep 17 00:00:00 2001 From: Humza Butt Date: Fri, 3 Jul 2026 12:49:56 +0100 Subject: [PATCH 02/23] new --- .oche-keys.example.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.oche-keys.example.json b/.oche-keys.example.json index e02abfc..5f28270 100644 --- a/.oche-keys.example.json +++ b/.oche-keys.example.json @@ -1 +1 @@ - + \ No newline at end of file From f4ce1f3ae01ed3222c8edcb0cf2b347e278ee618 Mon Sep 17 00:00:00 2001 From: Humza Butt Date: Fri, 3 Jul 2026 12:50:15 +0100 Subject: [PATCH 03/23] Enhance build and deployment scripts - Added new build commands for staging and production environments in `package.json`. - Updated deployment scripts to include the new build commands for both staging and production deployments. --- package.json | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 4180983..79a5773 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,8 @@ "preview": "npm run preview -w apps/web", "build": "npm run build --workspaces --if-present", "build:web": "npm run build -w apps/web", + "build:web:staging": "node scripts/build-web.mjs staging", + "build:web:prod": "node scripts/build-web.mjs production", "build:api": "npm run build -w apps/api", "build:shared": "npm run build -w packages/shared", "typecheck": "tsc -b --pretty", @@ -58,8 +60,8 @@ "db:seed:staging": "node scripts/with-env.mjs DATABASE_URL_STAGING npm run db:seed -w packages/db", "db:reset": "npm run db:reset -w packages/db", "db:rls:check": "node scripts/rls-check.mjs", - "deploy:staging": "node scripts/run-steps.mjs db:migrate:staging deploy:api:staging deploy:web:staging", - "deploy:prod": "node scripts/run-steps.mjs db:migrate:prod deploy:api:prod deploy:web:prod", + "deploy:staging": "node scripts/run-steps.mjs db:migrate:staging deploy:api:staging build:web:staging deploy:web:staging", + "deploy:prod": "node scripts/run-steps.mjs db:migrate:prod deploy:api:prod build:web:prod deploy:web:prod", "deploy:all": "node scripts/run-steps.mjs deploy:staging deploy:prod", "deploy:api:staging": "npm run deploy -w apps/api -- --env staging", "deploy:api:prod": "npm run deploy -w apps/api -- --env production", From 4ab5e912b22bbf4c3513eb52d3dabcb64d93a3e0 Mon Sep 17 00:00:00 2001 From: Humza Butt Date: Fri, 3 Jul 2026 12:50:24 +0100 Subject: [PATCH 04/23] Update deployment workflows for staging and production - Added new environment variables for API base URLs in both `deploy-production.yml` and `deploy-staging.yml`. - Updated build commands to use specific build scripts for web applications in both workflows. --- .github/workflows/deploy-production.yml | 3 ++- .github/workflows/deploy-staging.yml | 5 +++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/.github/workflows/deploy-production.yml b/.github/workflows/deploy-production.yml index 23ae4c6..9ae5e1a 100644 --- a/.github/workflows/deploy-production.yml +++ b/.github/workflows/deploy-production.yml @@ -6,6 +6,7 @@ env: CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} DATABASE_URL_PROD: ${{ secrets.DATABASE_URL_PROD }} + VITE_API_BASE_PROD: https://oche-api.humza-butt.space jobs: deploy: runs-on: ubuntu-latest @@ -17,4 +18,4 @@ jobs: - run: npm ci - run: npm run db:migrate:prod - run: npm run deploy -w apps/api -- --env production - - run: npm run build -w apps/web && npm run deploy:prod -w apps/web + - run: npm run build:web:prod && npm run deploy:prod -w apps/web diff --git a/.github/workflows/deploy-staging.yml b/.github/workflows/deploy-staging.yml index dd4e3a9..7aa50d5 100644 --- a/.github/workflows/deploy-staging.yml +++ b/.github/workflows/deploy-staging.yml @@ -6,6 +6,7 @@ env: CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} DATABASE_URL_STAGING: ${{ secrets.DATABASE_URL_STAGING }} + VITE_API_BASE_STAGING: https://oche-api-staging.humza-butt.space jobs: deploy: runs-on: ubuntu-latest @@ -19,5 +20,5 @@ jobs: run: npm run db:migrate:staging - name: Deploy API (Worker, env=staging) run: npm run deploy -w apps/api -- --env staging - - name: Deploy web (Pages, staging) - run: npm run build -w apps/web && npm run deploy:staging -w apps/web + - name: Build & deploy web (Pages, staging) + run: npm run build:web:staging && npm run deploy:staging -w apps/web From 386b17e5b344ae9349f848a273b6295b74b51d41 Mon Sep 17 00:00:00 2001 From: Humza Butt Date: Fri, 3 Jul 2026 12:51:21 +0100 Subject: [PATCH 05/23] =?UTF-8?q?changed=20c:=20=E2=86=92=20extends?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/api/src/principal.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/api/src/principal.ts b/apps/api/src/principal.ts index e741925..95bdc9a 100644 --- a/apps/api/src/principal.ts +++ b/apps/api/src/principal.ts @@ -28,7 +28,7 @@ function extraOwners(env: Env): Record { } } -export function resolvePrincipal(c: Context<{ Bindings: Env }>): string | null { +export function resolvePrincipal(c: Context): string | null { const key = c.req.header('x-oche-owner') ?? c.req.query('key'); if (!key) return null; const owners = { ...DEMO_OWNERS, ...extraOwners(c.env) }; From ed3f6020da19a56ad5aba6f0e03e07bb84913b2a Mon Sep 17 00:00:00 2001 From: Humza Butt Date: Fri, 3 Jul 2026 12:51:31 +0100 Subject: [PATCH 06/23] Update README.md for setup instructions and performance optimizations - Revised quick start commands to streamline setup process with `npm run setup` and `npm run setup:dev-vars:sync`. - Enhanced performance section to clarify virtualization of history lists and improved image handling with responsive `srcset` for player avatars. --- README.md | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 9771e39..5602d28 100644 --- a/README.md +++ b/README.md @@ -16,13 +16,12 @@ Vite + React 19 SPA (TanStack Query, Tailwind v4 + shadcn) · Hono on Cloudflare ## Quick start ```bash -npm install -cp .env.example .env # add Neon URLs (main + staging branches) -npm run db:generate && npm run db:migrate -psql "$DATABASE_URL" -f packages/db/migrations/zzzz_force_rls.sql # FORCE RLS + grants -npm run db:seed # two owners, for RLS isolation tests -npm run dev # web :5173 + api :8787 -npm run db:rls:check # confirm RLS is enabled + forced everywhere +npm run setup # install + .env / .dev.vars templates +# Fill DATABASE_URL in .env, then: +npm run setup:dev-vars:sync +npm run db:prepare # migrate + force-rls + seed +npm run dev # web :5173 + api :8787 +npm run db:rls:check # confirm RLS is enabled + forced everywhere ``` ## API @@ -41,11 +40,11 @@ All bodies are Zod-validated (strict); errors return `{ error, issues? }`. Every ## Frontend performance optimisation -Route-level code splitting (lazy routes); **TanStack Query** for caching, request dedupe, and **optimistic** score updates with rollback; match-history virtualization for long lists; responsive images (AVIF/WebP via `srcset`/`sizes`, `width`/`height` set to avoid layout shift, `loading="lazy"`, `decoding="async"`); video uses `preload="metadata"` only; `preconnect` to the media origin; a bundle-size budget enforced in CI (`npm run size`). +Route-level code splitting (lazy routes); **TanStack Query** for caching, request dedupe, and **optimistic** score updates with rollback; **virtualize** the history list when it grows past ~50 rows (see [PERFORMANCE.md](docs/PERFORMANCE.md)); player avatars use **`srcset`** where the CDN supports it; fixed `width`/`height` to avoid layout shift; `loading="lazy"` + `decoding="async"`; video uses `preload="metadata"` only; a bundle-size budget enforced in CI (`npm run size`). ## Efficient handling of images & video -Media lives in **R2** with immutable hashed keys and long cache lifetimes, served over Cloudflare's CDN with **zero egress fees**. Photos are delivered as responsive AVIF/WebP. Video is served with **HTTP byte-range** requests so seeking works without downloading the whole file, behind a **poster frame** and metadata-only preload; where an HLS rendition exists the player adapts and uses the **Media Capabilities API** to pick the best source, falling back to MP4. Posters + one HLS rendition are produced with FFmpeg (offline for the free tier; the production pipeline is an R2-event → Queue → Container job — see [docs/MEDIA.md](docs/MEDIA.md)). Private assets use signed, short-lived URLs. +Media lives in **R2** with immutable hashed keys and long cache lifetimes, served over Cloudflare's CDN with **zero egress fees**. Player photos use responsive **`srcset`** (ui-avatars / R2); video is served with **HTTP byte-range** requests so seeking works without downloading the whole file, behind a **poster frame** and metadata-only preload; optional **HLS** when `hls_url` is set (offline FFmpeg script). Private R2 assets use signed, short-lived URLs. ## What I'd do differently at scale From 55e59b2b516cf20f97fe6134ff311d80a5c55329 Mon Sep 17 00:00:00 2001 From: Humza Butt Date: Fri, 3 Jul 2026 12:51:47 +0100 Subject: [PATCH 07/23] reorganised the display of the submission --- SUBMISSION.md | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/SUBMISSION.md b/SUBMISSION.md index 8829bdb..0d9a330 100644 --- a/SUBMISSION.md +++ b/SUBMISSION.md @@ -24,11 +24,11 @@ RLS on every table (proven by `npm run test:rls`) · SQLite Durable Object webso **Start:** [docs/README.md](docs/README.md) · [docs/ROADMAP.md](docs/ROADMAP.md) · [docs/HOMEWORK-SPEC.md](docs/HOMEWORK-SPEC.md) -| Doc | Purpose | -|-----|---------| -| [docs/ROADMAP.md](docs/ROADMAP.md) | Status snapshot, Tiers 1–7, sprint plan, implementation order | -| [docs/HOMEWORK-SPEC.md](docs/HOMEWORK-SPEC.md) | Original 501 brief + spec mapping | -| [docs/checklists/01-homework-rubric.md](docs/checklists/01-homework-rubric.md) | Rubric tick-list | -| [docs/checklists/02-pre-submission.md](docs/checklists/02-pre-submission.md) | Pre-submit verification | -| [docs/checklists/03-known-gaps.md](docs/checklists/03-known-gaps.md) | Gaps to fix or explain | -| [docs/checklists/04-enhancements.md](docs/checklists/04-enhancements.md) | Enhancement checkboxes | +| Doc | Purpose | +| ------------------------------------------------------------------------------ | ------------------------------------------------------------- | +| [docs/ROADMAP.md](docs/ROADMAP.md) | Status snapshot, Tiers 1–7, sprint plan, implementation order | +| [docs/HOMEWORK-SPEC.md](docs/HOMEWORK-SPEC.md) | Original 501 brief + spec mapping | +| [docs/checklists/01-homework-rubric.md](docs/checklists/01-homework-rubric.md) | Rubric tick-list | +| [docs/checklists/02-pre-submission.md](docs/checklists/02-pre-submission.md) | Pre-submit verification | +| [docs/checklists/03-known-gaps.md](docs/checklists/03-known-gaps.md) | Gaps to fix or explain | +| [docs/checklists/04-enhancements.md](docs/checklists/04-enhancements.md) | Enhancement checkboxes | From 342ed2bc6160de118e49423f611d6c234aa6c5b1 Mon Sep 17 00:00:00 2001 From: Humza Butt Date: Fri, 3 Jul 2026 12:53:05 +0100 Subject: [PATCH 08/23] Add new session creation feature and enhance UI components - Introduced a new route for creating sessions at `/sessions/new`, including a form for session title and player names. - Updated the Overview and SessionDetail components to display the session ID with a copy button for easy access. - Enhanced PlayerAvatar component to support responsive image loading with `srcset`. - Added a new SessionId component for displaying and copying session IDs. - Updated API to include a method for creating sessions, ensuring full functionality from the UI to the backend. --- apps/web/src/App.tsx | 1 + apps/web/src/components/PlayerAvatar.tsx | 5 + apps/web/src/components/SessionId.tsx | 36 +++++ apps/web/src/lib/api.ts | 4 +- apps/web/src/lib/media-src.ts | 10 ++ apps/web/src/main.tsx | 11 ++ apps/web/src/routes/CreateSession.tsx | 123 +++++++++++++++ apps/web/src/routes/Overview.tsx | 13 +- apps/web/src/routes/SessionDetail.tsx | 8 +- docs/HOMEWORK-SPEC.md | 92 +++++------ docs/PERFORMANCE.md | 6 +- docs/README.md | 36 ++--- docs/ROADMAP.md | 186 +++++++++++------------ docs/checklists/01-homework-rubric.md | 124 +++++++-------- docs/checklists/02-pre-submission.md | 134 ++++++++-------- docs/checklists/03-known-gaps.md | 89 ++++++----- docs/checklists/04-enhancements.md | 108 ++++++------- docs/checklists/README.md | 12 +- packages/db/src/seed.ts | 7 +- scripts/build-web.mjs | 33 ++++ tests/e2e/dashboard.spec.ts | 14 +- 21 files changed, 648 insertions(+), 404 deletions(-) create mode 100644 apps/web/src/components/SessionId.tsx create mode 100644 apps/web/src/lib/media-src.ts create mode 100644 apps/web/src/routes/CreateSession.tsx create mode 100644 scripts/build-web.mjs diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index a0290b6..0b01db1 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -5,6 +5,7 @@ import { cn } from '@/lib/utils'; const tabs = [ { to: '/', label: 'Live sessions', end: true }, { to: '/history', label: 'Match history', end: false }, + { to: '/sessions/new', label: 'New session', end: false }, ]; export default function App() { diff --git a/apps/web/src/components/PlayerAvatar.tsx b/apps/web/src/components/PlayerAvatar.tsx index 935db55..570b7ce 100644 --- a/apps/web/src/components/PlayerAvatar.tsx +++ b/apps/web/src/components/PlayerAvatar.tsx @@ -1,3 +1,5 @@ +import { avatarSrcSet } from '@/lib/media-src'; + /** Player photo with responsive sizing, lazy load, and initials fallback. */ export function PlayerAvatar({ name, photoUrl }: { name: string; photoUrl?: string | null }) { const initials = name @@ -8,9 +10,12 @@ export function PlayerAvatar({ name, photoUrl }: { name: string; photoUrl?: stri .toUpperCase(); if (photoUrl) { + const srcSet = avatarSrcSet(photoUrl); return ( setCopied(false), 2000); + } catch { + /* clipboard unavailable */ + } + } + + return ( +

+ + Session ID:{' '} + + {short} + + + +

+ ); +} diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index 6505fa4..34fbd1c 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -1,4 +1,4 @@ -import type { Session, SessionSummary } from '@oche/shared'; +import type { CreateSessionInput, Session, SessionSummary } from '@oche/shared'; const BASE = import.meta.env.VITE_API_BASE ?? 'http://localhost:8787'; @@ -17,6 +17,8 @@ async function req(path: string, init?: RequestInit): Promise { export const api = { listSessions: () => req<{ data: SessionSummary[]; nextCursor: string | null }>('/sessions'), getSession: (id: string) => req(`/sessions/${id}`), + createSession: (body: CreateSessionInput) => + req('/sessions', { method: 'POST', body: JSON.stringify(body) }), patchScores: (id: string, scores: Array<{ playerId: string; delta?: number; set?: number }>) => req<{ ok: true }>(`/sessions/${id}`, { method: 'PATCH', body: JSON.stringify({ scores }) }), wsUrl: (id: string) => `${BASE.replace(/^http/, 'ws')}/sessions/${id}/live?key=demo-key-a`, diff --git a/apps/web/src/lib/media-src.ts b/apps/web/src/lib/media-src.ts new file mode 100644 index 0000000..3e19102 --- /dev/null +++ b/apps/web/src/lib/media-src.ts @@ -0,0 +1,10 @@ +/** Build srcset for ui-avatars URLs; returns undefined for other origins. */ +export function avatarSrcSet(url: string): string | undefined { + if (!url.includes('ui-avatars.com')) return undefined; + const u = new URL(url); + u.searchParams.set('size', '40'); + const small = u.toString(); + u.searchParams.set('size', '80'); + const large = u.toString(); + return `${small} 40w, ${large} 80w`; +} diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index a451717..23549b7 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -9,6 +9,9 @@ import { queryClient } from './lib/query'; const Overview = lazy(() => import('./routes/Overview').then((m) => ({ default: m.Overview }))); const History = lazy(() => import('./routes/History').then((m) => ({ default: m.History }))); +const CreateSession = lazy(() => + import('./routes/CreateSession').then((m) => ({ default: m.CreateSession })), +); const SessionDetail = lazy(() => import('./routes/SessionDetail').then((m) => ({ default: m.SessionDetail })), ); @@ -38,6 +41,14 @@ const router = createBrowserRouter([ ), }, + { + path: 'sessions/new', + element: ( + + + + ), + }, { path: 'history/:id', element: ( diff --git a/apps/web/src/routes/CreateSession.tsx b/apps/web/src/routes/CreateSession.tsx new file mode 100644 index 0000000..5f361e0 --- /dev/null +++ b/apps/web/src/routes/CreateSession.tsx @@ -0,0 +1,123 @@ +import type { CreateSessionInput } from '@oche/shared'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { useState } from 'react'; +import { Link, useNavigate } from 'react-router-dom'; +import { Button } from '@/components/ui/button'; +import { api } from '@/lib/api'; + +/** Create a session + players (POST /sessions). */ +export function CreateSession() { + const navigate = useNavigate(); + const qc = useQueryClient(); + const [title, setTitle] = useState(''); + const [names, setNames] = useState(['', '']); + const [error, setError] = useState(null); + + const create = useMutation({ + mutationFn: (body: CreateSessionInput) => api.createSession(body), + onSuccess: (session) => { + qc.invalidateQueries({ queryKey: ['sessions'] }); + navigate('/', { replace: true }); + qc.setQueryData(['session', session.id], session); + }, + onError: (e: Error) => setError(e.message), + }); + + function setName(i: number, value: string) { + setNames((prev) => prev.map((n, idx) => (idx === i ? value : n))); + } + + function addPlayer() { + if (names.length < 12) setNames((prev) => [...prev, '']); + } + + function removePlayer(i: number) { + if (names.length <= 1) return; + setNames((prev) => prev.filter((_, idx) => idx !== i)); + } + + function submit(e: React.FormEvent) { + e.preventDefault(); + setError(null); + const players = names.map((name) => name.trim()).filter(Boolean); + if (!title.trim()) { + setError('Title is required.'); + return; + } + if (players.length === 0) { + setError('Add at least one player.'); + return; + } + create.mutate({ + title: title.trim(), + status: 'active', + players: players.map((name, position) => ({ name, score: 0, position })), + }); + } + + return ( +
+

New session

+

+ Creates a session via POST /sessions. +

+ +
+ + +
+ Players + {names.map((name, i) => ( +
+ setName(i, e.target.value)} + maxLength={80} + className="min-w-0 flex-1 rounded-md border border-[var(--color-line)] bg-[var(--color-surface)] px-3 py-2 text-sm" + placeholder={`Player ${i + 1}`} + aria-label={`Player ${i + 1} name`} + /> + {names.length > 1 ? ( + + ) : null} +
+ ))} + {names.length < 12 ? ( + + ) : null} +
+ + {error ? ( +

+ {error} +

+ ) : null} + +
+ + +
+
+
+ ); +} diff --git a/apps/web/src/routes/Overview.tsx b/apps/web/src/routes/Overview.tsx index d2e1abb..a145a47 100644 --- a/apps/web/src/routes/Overview.tsx +++ b/apps/web/src/routes/Overview.tsx @@ -2,8 +2,11 @@ import type { Session } from '@oche/shared'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useEffect, useState } from 'react'; import { PlayerAvatar } from '@/components/PlayerAvatar'; +import { SessionId } from '@/components/SessionId'; import { useLiveSession } from '@/hooks/useLiveSession'; import { api } from '@/lib/api'; +import { Link } from 'react-router-dom'; +import { Button } from '@/components/ui/button'; /** Primary screen: the most recent active session with editable, live scores. */ export function Overview() { @@ -14,7 +17,14 @@ export function Overview() { if (error) return

Couldn’t load sessions. Check the API is running.

; if (!firstActive) - return

No sessions yet. Create one to get started.

; + return ( +
+

No sessions yet.

+ +
+ ); return ; } @@ -68,6 +78,7 @@ function SessionPanel({ id }: { id: string }) {

{session.title}

+

{connected ? 'Live' : 'Polling every 5s (WebSocket reconnecting…)'}

diff --git a/apps/web/src/routes/SessionDetail.tsx b/apps/web/src/routes/SessionDetail.tsx index 921ff71..ccac4d2 100644 --- a/apps/web/src/routes/SessionDetail.tsx +++ b/apps/web/src/routes/SessionDetail.tsx @@ -1,5 +1,7 @@ import { useQuery } from '@tanstack/react-query'; import { useParams } from 'react-router-dom'; +import { SessionId } from '@/components/SessionId'; +import { PlayerAvatar } from '@/components/PlayerAvatar'; import { api } from '@/lib/api'; /** Session detail: scoreboard + game video (poster, metadata preload, byte-range via CDN). */ @@ -21,6 +23,7 @@ export function SessionDetail() {

{session.title}

+

{session.status}

@@ -47,7 +50,10 @@ export function SessionDetail() { key={p.id} className="flex items-center justify-between rounded-lg border border-[var(--color-line)] bg-[var(--color-surface)] px-4 py-3" > - {p.name} + + + {p.name} + {p.score} ))} diff --git a/docs/HOMEWORK-SPEC.md b/docs/HOMEWORK-SPEC.md index e749342..73b5eef 100644 --- a/docs/HOMEWORK-SPEC.md +++ b/docs/HOMEWORK-SPEC.md @@ -23,27 +23,27 @@ Reference copy of what 501 sent for the **Cloud Developer** role and the **techn ### Session overview -- Session ID -- Players (names + scores) -- Player photo -- Status (active / completed) -- Clean, responsive UI -- **Update player scores** +- Session ID +- Players (names + scores) +- Player photo +- Status (active / completed) +- Clean, responsive UI +- **Update player scores** - **Simulate real-time updates** (polling or websockets) ### Match history -- Show a list of previous sessions -- Clicking a session loads its data +- Show a list of previous sessions +- Clicking a session loads its data - **Plays video of game** ### API Create a simple API with endpoints such as: -- `GET /sessions` -- `GET /sessions/:id` -- `POST /sessions` +- `GET /sessions` +- `GET /sessions/:id` +- `POST /sessions` - `PATCH /sessions/:id` Store data in **a database**. @@ -52,11 +52,11 @@ Store data in **a database**. Provide a short explanation covering: -1. Frontend performance optimisation -2. Efficient handling of images/video -3. What you would do differently at scale -4. What security concerns exist in your solution -5. How you would address them in production +1. Frontend performance optimisation +2. Efficient handling of images/video +3. What you would do differently at scale +4. What security concerns exist in your solution +5. How you would address them in production > Be prepared to **walk through, explain and extend** your answers in a follow-up interview. @@ -64,45 +64,45 @@ Provide a short explanation covering: ## How Oche maps to the spec -| Requirement | Oche implementation | Status | -|-------------|----------------------|--------| -| React dashboard | `apps/web` — Vite, React 19, TanStack Query | Done | -| Session ID | Used internally; **not shown in UI yet** | Gap | -| Players + scores | `Overview.tsx` | Done | -| Player photo | `PlayerAvatar`; **seed has no photos** | Partial | -| Status | active/completed badge | Done | -| Responsive UI | Tailwind, mobile-friendly layout | Done | -| Update scores | PATCH + optimistic UI | Done | -| Real-time | WebSocket (Durable Object) + 5s polling fallback | Done | -| Match history list | `History.tsx` | Done | -| Click → detail | `/history/:id` | Done | -| Play video | `SessionDetail.tsx` + seed MP4 URLs | Done | -| GET/POST/PATCH API | `apps/api` Hono routes | Done | -| Database | Neon Postgres + Drizzle + RLS | Done | -| README sections | [README.md](../README.md) | Done (some claims partial — see gaps) | +| Requirement | Oche implementation | Status | +| ------------------ | ------------------------------------------------ | ------------------------------------- | +| React dashboard | `apps/web` — Vite, React 19, TanStack Query | Done | +| Session ID | Used internally; **not shown in UI yet** | Gap | +| Players + scores | `Overview.tsx` | Done | +| Player photo | `PlayerAvatar`; **seed has no photos** | Partial | +| Status | active/completed badge | Done | +| Responsive UI | Tailwind, mobile-friendly layout | Done | +| Update scores | PATCH + optimistic UI | Done | +| Real-time | WebSocket (Durable Object) + 5s polling fallback | Done | +| Match history list | `History.tsx` | Done | +| Click → detail | `/history/:id` | Done | +| Play video | `SessionDetail.tsx` + seed MP4 URLs | Done | +| GET/POST/PATCH API | `apps/api` Hono routes | Done | +| Database | Neon Postgres + Drizzle + RLS | Done | +| README sections | [README.md](../README.md) | Done (some claims partial — see gaps) | ### Beyond the spec (defensible extras) -| Extra | Why it helps the interview | -|-------|---------------------------| +| Extra | Why it helps the interview | +| ------------------------------- | ----------------------------- | | RLS on every table + `test:rls` | Security-minded, multi-tenant | -| WebSocket Durable Object | Real-time at scale story | -| Staging + production | Cloud / env isolation | -| R2 media + signed URLs | “Know your formats” | -| OpenAPI, Zod strict, CI | Production API hygiene | -| Playwright + axe e2e | Quality signal | +| WebSocket Durable Object | Real-time at scale story | +| Staging + production | Cloud / env isolation | +| R2 media + signed URLs | “Know your formats” | +| OpenAPI, Zod strict, CI | Production API hygiene | +| Playwright + axe e2e | Quality signal | --- ## Graded README → where to read -| Topic | Primary doc | -|-------|-------------| -| Frontend performance | [README.md](../README.md) · [PERFORMANCE.md](./PERFORMANCE.md) | -| Images & video | [README.md](../README.md) · [MEDIA.md](./MEDIA.md) | -| At scale | [README.md](../README.md) · [PERFORMANCE.md](./PERFORMANCE.md) | -| Security | [README.md](../README.md) · [SECURITY.md](../SECURITY.md) · [RLS.md](./RLS.md) | -| Production mitigations | [SECURITY.md](../SECURITY.md) · [DEPLOYMENT.md](./DEPLOYMENT.md) | +| Topic | Primary doc | +| ---------------------- | ------------------------------------------------------------------------------ | +| Frontend performance | [README.md](../README.md) · [PERFORMANCE.md](./PERFORMANCE.md) | +| Images & video | [README.md](../README.md) · [MEDIA.md](./MEDIA.md) | +| At scale | [README.md](../README.md) · [PERFORMANCE.md](./PERFORMANCE.md) | +| Security | [README.md](../README.md) · [SECURITY.md](../SECURITY.md) · [RLS.md](./RLS.md) | +| Production mitigations | [SECURITY.md](../SECURITY.md) · [DEPLOYMENT.md](./DEPLOYMENT.md) | --- diff --git a/docs/PERFORMANCE.md b/docs/PERFORMANCE.md index e28e771..d2b10ae 100644 --- a/docs/PERFORMANCE.md +++ b/docs/PERFORMANCE.md @@ -3,9 +3,9 @@ ## Frontend - Lazy routes (code splitting); TanStack Query caching + dedupe + optimistic score writes. -- Virtualize the history list past ~50 rows. -- Images: AVIF/WebP `srcset`/`sizes`, fixed dimensions (no CLS), lazy + async decode. -- Video: `preload="metadata"`, poster, byte-range; `preconnect` to media origin. +- Virtualize the history list past ~50 rows (Tier 2 — not yet implemented). +- Player avatars: `srcset` for ui-avatars URLs; fixed dimensions (no CLS); lazy + async decode. +- Video: `preload="metadata"`, poster, byte-range via API/R2 or CDN URLs. - CI enforces a bundle budget (`npm run size`). ## Backend / data diff --git a/docs/README.md b/docs/README.md index 60101e4..486ca8a 100644 --- a/docs/README.md +++ b/docs/README.md @@ -4,14 +4,14 @@ Central index for the **501 Cloud Developer** take-home. Start here before imple ## Before you begin (read in order) -| Step | Document | Purpose | -|------|----------|---------| -| 1 | [HOMEWORK-SPEC.md](./HOMEWORK-SPEC.md) | Original company brief + technical task requirements | -| 2 | [ROADMAP.md](./ROADMAP.md) | Current status, gaps summary, all enhancement tiers, sprint plan | -| 3 | [checklists/01-homework-rubric.md](./checklists/01-homework-rubric.md) | Tick off each rubric item | -| 4 | [checklists/02-pre-submission.md](./checklists/02-pre-submission.md) | Manual verification + deploy | -| 5 | [checklists/03-known-gaps.md](./checklists/03-known-gaps.md) | Fix or explain before interview | -| 6 | [checklists/04-enhancements.md](./checklists/04-enhancements.md) | Full enhancement backlog (checkboxes) | +| Step | Document | Purpose | +| ---- | ---------------------------------------------------------------------- | ---------------------------------------------------------------- | +| 1 | [HOMEWORK-SPEC.md](./HOMEWORK-SPEC.md) | Original company brief + technical task requirements | +| 2 | [ROADMAP.md](./ROADMAP.md) | Current status, gaps summary, all enhancement tiers, sprint plan | +| 3 | [checklists/01-homework-rubric.md](./checklists/01-homework-rubric.md) | Tick off each rubric item | +| 4 | [checklists/02-pre-submission.md](./checklists/02-pre-submission.md) | Manual verification + deploy | +| 5 | [checklists/03-known-gaps.md](./checklists/03-known-gaps.md) | Fix or explain before interview | +| 6 | [checklists/04-enhancements.md](./checklists/04-enhancements.md) | Full enhancement backlog (checkboxes) | **Submission one-pager:** [SUBMISSION.md](../SUBMISSION.md) **Interview prep:** [INTERVIEW.md](./INTERVIEW.md) @@ -20,16 +20,16 @@ Central index for the **501 Cloud Developer** take-home. Start here before imple ## Architecture & design -| Doc | Topic | -|-----|-------| -| [ARCHITECTURE.md](../ARCHITECTURE.md) | System overview | -| [SECURITY.md](../SECURITY.md) | Threat model + controls | -| [RLS.md](./RLS.md) | Row-level security | -| [PERFORMANCE.md](./PERFORMANCE.md) | Frontend + backend perf notes | -| [MEDIA.md](./MEDIA.md) | Images, video, R2, transcoding | -| [ENVIRONMENTS.md](./ENVIRONMENTS.md) | Staging vs production | -| [DEPLOYMENT.md](./DEPLOYMENT.md) | One-time Cloudflare setup | -| [RUNBOOK.md](./RUNBOOK.md) | Ops troubleshooting | +| Doc | Topic | +| ------------------------------------- | ------------------------------ | +| [ARCHITECTURE.md](../ARCHITECTURE.md) | System overview | +| [SECURITY.md](../SECURITY.md) | Threat model + controls | +| [RLS.md](./RLS.md) | Row-level security | +| [PERFORMANCE.md](./PERFORMANCE.md) | Frontend + backend perf notes | +| [MEDIA.md](./MEDIA.md) | Images, video, R2, transcoding | +| [ENVIRONMENTS.md](./ENVIRONMENTS.md) | Staging vs production | +| [DEPLOYMENT.md](./DEPLOYMENT.md) | One-time Cloudflare setup | +| [RUNBOOK.md](./RUNBOOK.md) | Ops troubleshooting | ## ADRs (`docs/adr/`) diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 7910796..3b18346 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1,6 +1,6 @@ # Project roadmap — Oche take-home -**Last updated:** planning phase before Tier 1 implementation. +**Last updated:** Tier 1 complete — verify staging deploy next. **Goal:** Close homework gaps, verify staging/prod, then polish for interview. Use checkbox files in [checklists/](./checklists/) to track progress. This doc is the **narrative + full tier reference**. @@ -11,28 +11,26 @@ Use checkbox files in [checklists/](./checklists/) to track progress. This doc i ### What is done (core homework) -| Area | Status | -|------|--------| -| React SPA — live scores, history, video detail | ✅ | -| REST API — 4 required endpoints + WS + media | ✅ | -| Neon Postgres — schema, migrations, seed, RLS | ✅ | -| Real-time — Durable Object WebSocket + polling fallback | ✅ | -| README graded sections | ✅ written | -| Tests — unit, e2e, RLS, bundle budget | ✅ | -| Cloudflare stack — Workers, Pages, R2, Hyperdrive (configured) | ✅ | -| Docs — architecture, security, ADRs, diagrams | ✅ | +| Area | Status | +| -------------------------------------------------------------- | ---------- | +| React SPA — live scores, history, video detail | ✅ | +| REST API — 4 required endpoints + WS + media | ✅ | +| Neon Postgres — schema, migrations, seed, RLS | ✅ | +| Real-time — Durable Object WebSocket + polling fallback | ✅ | +| README graded sections | ✅ written | +| Tests — unit, e2e, RLS, bundle budget | ✅ | +| Cloudflare stack — Workers, Pages, R2, Hyperdrive (configured) | ✅ | +| Docs — architecture, security, ADRs, diagrams | ✅ | ### What needs attention before submit -| Priority | Item | Doc | -|----------|------|-----| -| 🔴 | Staging/prod deploy verified with correct `VITE_API_BASE` | [02-pre-submission](./checklists/02-pre-submission.md) | -| 🔴 | Session ID visible in UI | Tier 1 | -| 🔴 | Player photos in demo data | Tier 1 | -| 🔴 | README claims vs code (virtualization, srcset) — fix or edit | [03-known-gaps](./checklists/03-known-gaps.md) | -| 🟡 | `build:web` in root deploy scripts | Tier 1 | -| 🟡 | `db:force-rls` in deploy pipeline | [02-pre-submission](./checklists/02-pre-submission.md) | -| 🟡 | Rehearse 90s interview tour | [INTERVIEW.md](./INTERVIEW.md) | +| Priority | Item | Doc | +| -------- | ------------------------------------------------------------ | ------------------------------------------------------ | +| 🔴 | Staging/prod deploy verified with correct `VITE_API_BASE` | [02-pre-submission](./checklists/02-pre-submission.md) | +| 🟡 | README virtualization claim — implement or soften wording | [03-known-gaps](./checklists/03-known-gaps.md) | +| 🟡 | `db:force-rls` in deploy pipeline | [02-pre-submission](./checklists/02-pre-submission.md) | +| 🟡 | Re-seed locally (`npm run db:seed`) for player photos | — | +| 🟡 | Rehearse 90s interview tour | [INTERVIEW.md](./INTERVIEW.md) | --- @@ -43,93 +41,93 @@ Detailed checkboxes: [checklists/04-enhancements.md](./checklists/04-enhancement --- -### Tier 1 — High impact, homework-aligned (**start here**) +### Tier 1 — High impact, homework-aligned (**complete**) -| # | Enhancement | Effort | Notes | -|---|-------------|--------|-------| -| 1.1 | Show session ID + copy button | S | Closes spec gap; Overview or SessionDetail | -| 1.2 | Seed player photos | S | ui-avatars, R2, or static URLs in seed | -| 1.3 | Create session form (POST from UI) | M | Title + players; proves API end-to-end | -| 1.4 | `VITE_API_BASE` per env in deploy | S | Pages build must point at staging/prod API | -| 1.5 | `build:web` in `deploy:staging` / `deploy:prod` | S | Avoid stale/empty `dist/` on Pages | -| 1.6 | README media claims — implement or soften | S–M | srcset, virtualization wording | +| # | Enhancement | Effort | Notes | +| --- | ----------------------------------------------- | ------ | --------------------------- | +| 1.1 | Show session ID + copy button | S | ✅ `SessionId` component | +| 1.2 | Seed player photos | S | ✅ ui-avatars in seed | +| 1.3 | Create session form (POST from UI) | M | ✅ `/sessions/new` | +| 1.4 | `VITE_API_BASE` per env in deploy | S | ✅ `build-web.mjs` + CI env | +| 1.5 | `build:web` in `deploy:staging` / `deploy:prod` | S | ✅ in deploy scripts | +| 1.6 | README media claims — implement or soften | S–M | ✅ srcset + README aligned | --- ### Tier 2 — UX & dashboard polish -| # | Enhancement | Effort | Notes | -|---|-------------|--------|-------| -| 2.1 | Session picker / tabs | M | Multiple active sessions per owner | -| 2.2 | “End session” → `status: completed` | S | PATCH + WS broadcast | -| 2.3 | Score history timeline | M | Render `scoreEvents` on SessionDetail | -| 2.4 | History pagination / infinite scroll | M | Wire API `nextCursor` | -| 2.5 | History list virtualization | M | `@tanstack/react-virtual` | -| 2.6 | Empty / error states with actions | S | Create session, retry API | -| 2.7 | Loading skeletons | S | Replace plain “Loading…” | -| 2.8 | Dark mode toggle | M | CSS variables already in place | +| # | Enhancement | Effort | Notes | +| --- | ------------------------------------ | ------ | ------------------------------------- | +| 2.1 | Session picker / tabs | M | Multiple active sessions per owner | +| 2.2 | “End session” → `status: completed` | S | PATCH + WS broadcast | +| 2.3 | Score history timeline | M | Render `scoreEvents` on SessionDetail | +| 2.4 | History pagination / infinite scroll | M | Wire API `nextCursor` | +| 2.5 | History list virtualization | M | `@tanstack/react-virtual` | +| 2.6 | Empty / error states with actions | S | Create session, retry API | +| 2.7 | Loading skeletons | S | Replace plain “Loading…” | +| 2.8 | Dark mode toggle | M | CSS variables already in place | --- ### Tier 3 — Media (501 “know your formats” signal) -| # | Enhancement | Effort | Notes | -|---|-------------|--------|-------| -| 3.1 | Upload video/photo in UI | M | `POST /media/upload` + attach to session | -| 3.2 | Responsive images (`srcset`/`sizes`) | S | PlayerAvatar + history posters | -| 3.3 | Offline transcode on seed clip | M | `scripts/transcode-local.mjs` → R2 | -| 3.4 | Preconnect to API/media in `index.html` | S | Env-specific at build time | -| 3.5 | Media Capabilities API for video | M | Best source selection | -| 3.6 | Upload progress + cancel | M | Large video UX | +| # | Enhancement | Effort | Notes | +| --- | --------------------------------------- | ------ | ---------------------------------------- | +| 3.1 | Upload video/photo in UI | M | `POST /media/upload` + attach to session | +| 3.2 | Responsive images (`srcset`/`sizes`) | S | PlayerAvatar + history posters | +| 3.3 | Offline transcode on seed clip | M | `scripts/transcode-local.mjs` → R2 | +| 3.4 | Preconnect to API/media in `index.html` | S | Env-specific at build time | +| 3.5 | Media Capabilities API for video | M | Best source selection | +| 3.6 | Upload progress + cancel | M | Large video UX | --- ### Tier 4 — Auth & multi-tenant (live “extend it” answers) -| # | Enhancement | Effort | Notes | -|---|-------------|--------|-------| -| 4.1 | JWT auth (Neon Auth / Clerk / CF Access) | L | Replace `x-oche-owner` header | -| 4.2 | RLS via `auth.user_id()` | L | Drop GUC; see [RLS.md](./RLS.md) | -| 4.3 | Owner switcher in dev | S | Toggle demo-key-a / demo-key-b in UI | -| 4.4 | API key management UI | L | Hashed keys in DB for venues | +| # | Enhancement | Effort | Notes | +| --- | ---------------------------------------- | ------ | ------------------------------------ | +| 4.1 | JWT auth (Neon Auth / Clerk / CF Access) | L | Replace `x-oche-owner` header | +| 4.2 | RLS via `auth.user_id()` | L | Drop GUC; see [RLS.md](./RLS.md) | +| 4.3 | Owner switcher in dev | S | Toggle demo-key-a / demo-key-b in UI | +| 4.4 | API key management UI | L | Hashed keys in DB for venues | --- ### Tier 5 — Scale & reliability -| # | Enhancement | Effort | Notes | -|---|-------------|--------|-------| -| 5.1 | DO-backed rate limiting | M | Replace in-memory token bucket | -| 5.2 | KV cache for session list | M | TTL + invalidate on PATCH | -| 5.3 | Structured logging + trace IDs | M | JSON logs, cf-ray correlation | -| 5.4 | Hyperdrive + read replicas | L | Neon replica routing story | -| 5.5 | Queue + Container transcoding | L | Paid tier; design in [MEDIA.md](./MEDIA.md) | -| 5.6 | Deep health check | S | `/health?deep=1` with DB ping | +| # | Enhancement | Effort | Notes | +| --- | ------------------------------ | ------ | ------------------------------------------- | +| 5.1 | DO-backed rate limiting | M | Replace in-memory token bucket | +| 5.2 | KV cache for session list | M | TTL + invalidate on PATCH | +| 5.3 | Structured logging + trace IDs | M | JSON logs, cf-ray correlation | +| 5.4 | Hyperdrive + read replicas | L | Neon replica routing story | +| 5.5 | Queue + Container transcoding | L | Paid tier; design in [MEDIA.md](./MEDIA.md) | +| 5.6 | Deep health check | S | `/health?deep=1` with DB ping | --- ### Tier 6 — Quality & developer experience -| # | Enhancement | Effort | Notes | -|---|-------------|--------|-------| -| 6.1 | SPA component tests (RTL) | M | Score bump, history navigation | -| 6.2 | Contract tests vs OpenAPI | M | `tests/contracts` | -| 6.3 | Visual regression (`@visual`) | M | Playwright screenshots | -| 6.4 | `npm run deploy:staging:full` | S | setup → build → migrate → rls → seed → deploy | -| 6.5 | Update README quick start | S | `npm run setup`, `db:prepare`, Windows | -| 6.6 | Wrangler 4 upgrade | M | DevDeps + re-test deploy | +| # | Enhancement | Effort | Notes | +| --- | ----------------------------- | ------ | --------------------------------------------- | +| 6.1 | SPA component tests (RTL) | M | Score bump, history navigation | +| 6.2 | Contract tests vs OpenAPI | M | `tests/contracts` | +| 6.3 | Visual regression (`@visual`) | M | Playwright screenshots | +| 6.4 | `npm run deploy:staging:full` | S | setup → build → migrate → rls → seed → deploy | +| 6.5 | Update README quick start | S | `npm run setup`, `db:prepare`, Windows | +| 6.6 | Wrangler 4 upgrade | M | DevDeps + re-test deploy | --- ### Tier 7 — Nice extras (time permitting) -| # | Enhancement | Notes | -|---|-------------|-------| -| 7.1 | PWA / offline read-only cache | Service worker for history | -| 7.2 | Export session PDF / scorecard | Venue-facing feature | -| 7.3 | i18n (EN first) | Global product fit | -| 7.4 | Privacy-preserving analytics | Plausible / CF Web Analytics | -| 7.5 | OpenAPI Swagger / Redoc at `/docs` | Static docs on Pages | +| # | Enhancement | Notes | +| --- | ---------------------------------- | ---------------------------- | +| 7.1 | PWA / offline read-only cache | Service worker for history | +| 7.2 | Export session PDF / scorecard | Venue-facing feature | +| 7.3 | i18n (EN first) | Global product fit | +| 7.4 | Privacy-preserving analytics | Plausible / CF Web Analytics | +| 7.5 | OpenAPI Swagger / Redoc at `/docs` | Static docs on Pages | --- @@ -165,32 +163,32 @@ Detailed checkboxes: [checklists/04-enhancements.md](./checklists/04-enhancement Full triage: [checklists/03-known-gaps.md](./checklists/03-known-gaps.md). -| Category | Examples | -|----------|----------| -| **UI vs spec** | No session ID in UI; no photos in seed; no create-session form | +| Category | Examples | +| ---------------- | ---------------------------------------------------------------------------------------- | +| **UI vs spec** | No session ID in UI; no photos in seed; no create-session form | | **Docs vs code** | Virtualization, srcset, Media Capabilities, media preconnect claimed but not fully built | -| **Auth** | `demo-key-a` hardcoded in SPA — explain as take-home stand-in | -| **Deploy** | Missing `build:web` in root deploy; `VITE_API_BASE` must be set at build | -| **CI** | e2e in CI may need `DATABASE_URL` secret | -| **DX** | README quick start still mentions `cp` / `psql` instead of `npm run setup` | +| **Auth** | `demo-key-a` hardcoded in SPA — explain as take-home stand-in | +| **Deploy** | Missing `build:web` in root deploy; `VITE_API_BASE` must be set at build | +| **CI** | e2e in CI may need `DATABASE_URL` secret | +| **DX** | README quick start still mentions `cp` / `psql` instead of `npm run setup` | --- ## Implementation order (when we begin) -1. Tier 1.4 + 1.5 — deploy pipeline fixes (staging actually works) -2. Tier 1.1 + 1.2 — session ID + photos (spec demo) -3. Tier 1.6 — align README or implement srcset -4. Tier 1.3 — create session form (if time) -5. Sprint B items as capacity allows +1. Tier 1.4 + 1.5 — deploy pipeline fixes (staging actually works) +2. Tier 1.1 + 1.2 — session ID + photos (spec demo) +3. Tier 1.6 — align README or implement srcset +4. Tier 1.3 — create session form (if time) +5. Sprint B items as capacity allows --- ## Related documents -| Doc | Role | -|-----|------| -| [HOMEWORK-SPEC.md](./HOMEWORK-SPEC.md) | Original brief | -| [checklists/01-homework-rubric.md](./checklists/01-homework-rubric.md) | Rubric tick-list | -| [checklists/04-enhancements.md](./checklists/04-enhancements.md) | Checkbox backlog | -| [SUBMISSION.md](../SUBMISSION.md) | One-page submit summary | +| Doc | Role | +| ---------------------------------------------------------------------- | ----------------------- | +| [HOMEWORK-SPEC.md](./HOMEWORK-SPEC.md) | Original brief | +| [checklists/01-homework-rubric.md](./checklists/01-homework-rubric.md) | Rubric tick-list | +| [checklists/04-enhancements.md](./checklists/04-enhancements.md) | Checkbox backlog | +| [SUBMISSION.md](../SUBMISSION.md) | One-page submit summary | diff --git a/docs/checklists/01-homework-rubric.md b/docs/checklists/01-homework-rubric.md index 4e6b774..9509f7e 100644 --- a/docs/checklists/01-homework-rubric.md +++ b/docs/checklists/01-homework-rubric.md @@ -1,6 +1,6 @@ # 01 — Homework rubric checklist -Source: *Interview Homework Test - Cloud Developer.txt* + *Cloud Developer.docx* (501 Entertainment role). +Source: _Interview Homework Test - Cloud Developer.txt_ + _Cloud Developer.docx_ (501 Entertainment role). Legend: `[x]` = implemented in repo (verify manually before interview). `[ ]` = not done or needs verification. @@ -10,38 +10,38 @@ Legend: `[x]` = implemented in repo (verify manually before interview). `[ ]` = ### A1. Session overview (live screen) -| | Item | Where / how to verify | -|---|------|------------------------| -| [x] | React SPA | `apps/web` — Vite + React 19 | -| [ ] | **Session ID visible** in UI | Spec asks for Session ID; overview shows **title** but not UUID — add to Overview/SessionDetail or note in demo | -| [x] | Players: names + scores | `apps/web/src/routes/Overview.tsx` | -| [ ] | **Player photo** shown | `PlayerAvatar` supports `photoUrl`; **seed has no photos** → initials fallback only unless you upload/add URLs | -| [x] | Status (active / completed) | Badge on Overview; detail on SessionDetail | -| [x] | Clean, responsive UI | Tailwind, `max-w-5xl`, mobile nav — resize browser | -| [x] | Update player scores | +/- buttons → `PATCH /sessions/:id` + optimistic UI | -| [x] | Real-time updates | WebSocket via Durable Object + **5s polling fallback** when disconnected | +| | Item | Where / how to verify | +| --- | ---------------------------- | --------------------------------------------------------------------------------------------------------------- | +| [x] | React SPA | `apps/web` — Vite + React 19 | +| [x] | **Session ID visible** in UI | `SessionId` on Overview + SessionDetail — truncated UUID + copy button | +| [x] | Players: names + scores | `apps/web/src/routes/Overview.tsx` | +| [x] | **Player photo** shown | Seed sets `photoUrl` via ui-avatars; `PlayerAvatar` renders with srcset | +| [x] | Status (active / completed) | Badge on Overview; detail on SessionDetail | +| [x] | Clean, responsive UI | Tailwind, `max-w-5xl`, mobile nav — resize browser | +| [x] | Update player scores | +/- buttons → `PATCH /sessions/:id` + optimistic UI | +| [x] | Real-time updates | WebSocket via Durable Object + **5s polling fallback** when disconnected | ### A2. Match history -| | Item | Where / how to verify | -|---|------|------------------------| -| [x] | List of previous sessions | `apps/web/src/routes/History.tsx` | -| [x] | Click session → load detail | Route `/history/:id` → `SessionDetail.tsx` | -| [x] | Plays video of game | `
- +
+ + +
diff --git a/apps/web/src/components/QueryFeedback.tsx b/apps/web/src/components/QueryFeedback.tsx new file mode 100644 index 0000000..ccea0d1 --- /dev/null +++ b/apps/web/src/components/QueryFeedback.tsx @@ -0,0 +1,39 @@ +import type { ReactNode } from 'react'; +import { Button } from '@/components/ui/button'; + +export function QueryError({ + message, + onRetry, + retrying, + action, +}: { + message: string; + onRetry: () => void; + retrying?: boolean; + action?: ReactNode; +}) { + return ( +
+

{message}

+
+ + {action} +
+
+ ); +} + +export function QueryEmpty({ title, hint, action }: { title: string; hint?: string; action?: ReactNode }) { + return ( +
+

{title}

+ {hint ?

{hint}

: null} + {action ?
{action}
: null} +
+ ); +} diff --git a/apps/web/src/components/ScoreTimeline.tsx b/apps/web/src/components/ScoreTimeline.tsx new file mode 100644 index 0000000..6984ce7 --- /dev/null +++ b/apps/web/src/components/ScoreTimeline.tsx @@ -0,0 +1,49 @@ +import type { Player, ScoreEvent } from '@oche/shared'; + +function formatWhen(iso: string) { + return new Date(iso).toLocaleString(undefined, { + day: 'numeric', + month: 'short', + hour: '2-digit', + minute: '2-digit', + }); +} + +export function ScoreTimeline({ events, players }: { events: ScoreEvent[]; players: Player[] }) { + const names = new Map(players.map((p) => [p.id, p.name])); + if (!events.length) return null; + + return ( +
+

+ Score history +

+
    + {events.map((e) => { + const name = names.get(e.playerId) ?? 'Player'; + const deltaLabel = e.delta > 0 ? `+${e.delta}` : String(e.delta); + return ( +
  1. +
    + + {name}{' '} + = 0 ? 'text-[var(--color-oche)]' : 'text-[var(--color-amber)]'}> + {deltaLabel} + {' '} + {' '} + {e.newScore} + + +
    +
  2. + ); + })} +
+
+ ); +} diff --git a/apps/web/src/components/SessionPicker.tsx b/apps/web/src/components/SessionPicker.tsx new file mode 100644 index 0000000..35184c5 --- /dev/null +++ b/apps/web/src/components/SessionPicker.tsx @@ -0,0 +1,37 @@ +import type { SessionSummary } from '@oche/shared'; +import { cn } from '@/lib/utils'; + +export function SessionPicker({ + sessions, + selectedId, + onSelect, +}: { + sessions: SessionSummary[]; + selectedId: string; + onSelect: (id: string) => void; +}) { + return ( +
+ {sessions.map((s) => { + const selected = s.id === selectedId; + return ( + + ); + })} +
+ ); +} diff --git a/apps/web/src/components/ThemeToggle.tsx b/apps/web/src/components/ThemeToggle.tsx new file mode 100644 index 0000000..4578ded --- /dev/null +++ b/apps/web/src/components/ThemeToggle.tsx @@ -0,0 +1,19 @@ +import { Button } from '@/components/ui/button'; +import { useTheme } from '@/hooks/useTheme'; + +export function ThemeToggle() { + const { theme, toggle } = useTheme(); + + return ( + + ); +} diff --git a/apps/web/src/components/ui/skeleton.tsx b/apps/web/src/components/ui/skeleton.tsx new file mode 100644 index 0000000..3f9495f --- /dev/null +++ b/apps/web/src/components/ui/skeleton.tsx @@ -0,0 +1,80 @@ +import { cn } from '@/lib/utils'; + +export function Skeleton({ className }: { className?: string }) { + return
; +} + +export function SessionOverviewSkeleton() { + return ( +
+
+
+ + + +
+ +
+
    + {[0, 1, 2].map((i) => ( +
  • + + + + + +
  • + ))} +
+
+ ); +} + +export function HistoryListSkeleton() { + return ( +
    + {[0, 1, 2, 3].map((i) => ( +
  • + + + + + +
  • + ))} +
+ ); +} + +export function SessionDetailSkeleton() { + return ( +
+
+ + + +
+ +
    + {[0, 1].map((i) => ( +
  • + + + + + +
  • + ))} +
+
+ ); +} diff --git a/apps/web/src/hooks/useTheme.ts b/apps/web/src/hooks/useTheme.ts new file mode 100644 index 0000000..89e4640 --- /dev/null +++ b/apps/web/src/hooks/useTheme.ts @@ -0,0 +1,28 @@ +import { useEffect, useState } from 'react'; + +export type Theme = 'dark' | 'light'; + +function readTheme(): Theme { + if (typeof window === 'undefined') return 'dark'; + const stored = localStorage.getItem('oche-theme'); + return stored === 'light' ? 'light' : 'dark'; +} + +function applyTheme(theme: Theme) { + document.documentElement.dataset.theme = theme; + localStorage.setItem('oche-theme', theme); +} + +export function useTheme() { + const [theme, setThemeState] = useState(readTheme); + + useEffect(() => { + applyTheme(theme); + }, [theme]); + + return { + theme, + toggle: () => setThemeState((t) => (t === 'dark' ? 'light' : 'dark')), + setTheme: setThemeState, + }; +} diff --git a/apps/web/src/index.css b/apps/web/src/index.css index f8d281e..c516bfb 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -19,6 +19,16 @@ html { color-scheme: dark; } + html[data-theme='light'] { + color-scheme: light; + --color-canvas: #f5f3ec; + --color-surface: #ffffff; + --color-line: #e0ddd4; + --color-chalk: #0e1116; + --color-muted: #5c6570; + --color-oche: #7da812; + --color-amber: #c87d0a; + } body { margin: 0; background: var(--color-canvas); diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index 34fbd1c..6f067c5 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -1,4 +1,4 @@ -import type { CreateSessionInput, Session, SessionSummary } from '@oche/shared'; +import type { CreateSessionInput, PatchSessionInput, Session, SessionSummary } from '@oche/shared'; const BASE = import.meta.env.VITE_API_BASE ?? 'http://localhost:8787'; @@ -14,12 +14,22 @@ async function req(path: string, init?: RequestInit): Promise { return res.json() as Promise; } +export type ListSessionsOptions = { cursor?: string; limit?: number }; + export const api = { - listSessions: () => req<{ data: SessionSummary[]; nextCursor: string | null }>('/sessions'), + listSessions: (opts?: ListSessionsOptions) => { + const params = new URLSearchParams(); + if (opts?.cursor) params.set('cursor', opts.cursor); + if (opts?.limit) params.set('limit', String(opts.limit)); + const qs = params.toString(); + return req<{ data: SessionSummary[]; nextCursor: string | null }>(`/sessions${qs ? `?${qs}` : ''}`); + }, getSession: (id: string) => req(`/sessions/${id}`), createSession: (body: CreateSessionInput) => req('/sessions', { method: 'POST', body: JSON.stringify(body) }), + patchSession: (id: string, body: PatchSessionInput) => + req<{ ok: true }>(`/sessions/${id}`, { method: 'PATCH', body: JSON.stringify(body) }), patchScores: (id: string, scores: Array<{ playerId: string; delta?: number; set?: number }>) => - req<{ ok: true }>(`/sessions/${id}`, { method: 'PATCH', body: JSON.stringify({ scores }) }), + api.patchSession(id, { scores }), wsUrl: (id: string) => `${BASE.replace(/^http/, 'ws')}/sessions/${id}/live?key=demo-key-a`, }; diff --git a/apps/web/src/routes/History.tsx b/apps/web/src/routes/History.tsx index 45071c7..22762f9 100644 --- a/apps/web/src/routes/History.tsx +++ b/apps/web/src/routes/History.tsx @@ -1,7 +1,14 @@ -import { useQuery } from '@tanstack/react-query'; +import { useInfiniteQuery } from '@tanstack/react-query'; +import { useVirtualizer } from '@tanstack/react-virtual'; +import { useEffect, useMemo, useRef } from 'react'; import { Link } from 'react-router-dom'; +import { QueryEmpty, QueryError } from '@/components/QueryFeedback'; +import { Button } from '@/components/ui/button'; +import { HistoryListSkeleton } from '@/components/ui/skeleton'; import { api } from '@/lib/api'; +const ROW_HEIGHT = 72; + function formatWhen(iso: string) { return new Date(iso).toLocaleDateString(undefined, { day: 'numeric', @@ -10,49 +17,119 @@ function formatWhen(iso: string) { }); } -/** Match history: paginated list; each row links to detail + video. */ +/** Match history: virtualised list with cursor pagination. */ export function History() { - const { data, isLoading, error } = useQuery({ queryKey: ['sessions'], queryFn: api.listSessions }); + const parentRef = useRef(null); + + const { data, isLoading, error, fetchNextPage, hasNextPage, isFetchingNextPage, refetch, isRefetching } = + useInfiniteQuery({ + queryKey: ['sessions', 'history'], + queryFn: ({ pageParam }) => api.listSessions({ cursor: pageParam ?? undefined, limit: 20 }), + initialPageParam: null as string | null, + getNextPageParam: (last) => last.nextCursor, + }); + + const rows = useMemo(() => data?.pages.flatMap((p) => p.data) ?? [], [data?.pages]); - if (isLoading) return

Loading history…

; - if (error) return

Couldn’t load history.

; - if (!data?.data.length) return

No completed sessions yet.

; + const virtualCount = hasNextPage ? rows.length + 1 : rows.length; + + const virtualizer = useVirtualizer({ + count: virtualCount, + getScrollElement: () => parentRef.current, + estimateSize: () => ROW_HEIGHT, + overscan: 6, + }); + + const virtualItems = virtualizer.getVirtualItems(); + + useEffect(() => { + const last = virtualItems.at(-1); + if (!last) return; + if (last.index >= rows.length - 1 && hasNextPage && !isFetchingNextPage) { + void fetchNextPage(); + } + }, [virtualItems, rows.length, hasNextPage, isFetchingNextPage, fetchNextPage]); + + if (isLoading) return ; + if (error) { + return ( + void refetch()} + retrying={isRefetching} + /> + ); + } + if (!rows.length) { + return ( + + View live sessions + + } + /> + ); + } return ( -
    - {data.data.map((s) => ( -
  • - - {s.videoPoster ? ( - - ) : ( - - No video - - )} - - {s.title} - - {formatWhen(s.createdAt)} · {s.playerCount} players · {s.status} - - - -
  • - ))} -
+
+
    + {virtualItems.map((item) => { + const isLoader = item.index >= rows.length; + const s = rows[item.index]; + + return ( +
  • + {isLoader ? ( +
    + {isFetchingNextPage ? 'Loading more…' : 'Scroll for more'} +
    + ) : ( + + {s!.videoPoster ? ( + + ) : ( + + No video + + )} + + {s!.title} + + {formatWhen(s!.createdAt)} · {s!.playerCount} players · {s!.status} + + + + )} +
  • + ); + })} +
+
); } diff --git a/apps/web/src/routes/Overview.tsx b/apps/web/src/routes/Overview.tsx index a145a47..97c85cf 100644 --- a/apps/web/src/routes/Overview.tsx +++ b/apps/web/src/routes/Overview.tsx @@ -1,38 +1,77 @@ import type { Session } from '@oche/shared'; import { useQuery, useQueryClient } from '@tanstack/react-query'; -import { useEffect, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; +import { Link } from 'react-router-dom'; import { PlayerAvatar } from '@/components/PlayerAvatar'; +import { QueryEmpty, QueryError } from '@/components/QueryFeedback'; import { SessionId } from '@/components/SessionId'; +import { SessionPicker } from '@/components/SessionPicker'; +import { Button } from '@/components/ui/button'; +import { SessionOverviewSkeleton } from '@/components/ui/skeleton'; import { useLiveSession } from '@/hooks/useLiveSession'; import { api } from '@/lib/api'; -import { Link } from 'react-router-dom'; -import { Button } from '@/components/ui/button'; +import { cn } from '@/lib/utils'; -/** Primary screen: the most recent active session with editable, live scores. */ +/** Primary screen: active sessions with picker, editable live scores, and end-session. */ export function Overview() { - const { data, isLoading, error } = useQuery({ queryKey: ['sessions'], queryFn: api.listSessions }); - const firstActive = data?.data.find((s) => s.status === 'active') ?? data?.data[0]; + const { data, isLoading, error, refetch, isRefetching } = useQuery({ + queryKey: ['sessions'], + queryFn: () => api.listSessions(), + }); + + const activeSessions = useMemo(() => data?.data.filter((s) => s.status === 'active') ?? [], [data?.data]); - if (isLoading) return

Loading sessions…

; - if (error) - return

Couldn’t load sessions. Check the API is running.

; - if (!firstActive) + const [selectedId, setSelectedId] = useState(null); + + useEffect(() => { + if (!activeSessions.length) { + setSelectedId(null); + return; + } + if (!selectedId || !activeSessions.some((s) => s.id === selectedId)) { + setSelectedId(activeSessions[0]!.id); + } + }, [activeSessions, selectedId]); + + if (isLoading) return ; + if (error) { return ( -
-

No sessions yet.

- -
+ void refetch()} + retrying={isRefetching} + /> ); + } + if (!activeSessions.length) { + return ( + + Create a session + + } + /> + ); + } - return ; + return ( + <> + {activeSessions.length > 1 ? ( + + ) : null} + + + ); } function SessionPanel({ id }: { id: string }) { const qc = useQueryClient(); const [pulse, setPulse] = useState(null); const [saveError, setSaveError] = useState(null); + const [ending, setEnding] = useState(false); const { connected } = useLiveSession(id, (m) => { if (m.type === 'score') { @@ -42,10 +81,17 @@ function SessionPanel({ id }: { id: string }) { if (m.type === 'status') { qc.invalidateQueries({ queryKey: ['session', id] }); qc.invalidateQueries({ queryKey: ['sessions'] }); + qc.invalidateQueries({ queryKey: ['sessions', 'history'] }); } }); - const { data: session } = useQuery({ + const { + data: session, + isLoading, + error, + refetch, + isRefetching, + } = useQuery({ queryKey: ['session', id], queryFn: () => api.getSession(id), refetchInterval: connected ? false : 5_000, @@ -71,11 +117,34 @@ function SessionPanel({ id }: { id: string }) { } } - if (!session) return null; + async function endSession() { + setSaveError(null); + setEnding(true); + try { + await api.patchSession(id, { status: 'completed' }); + await qc.invalidateQueries({ queryKey: ['sessions'] }); + await qc.invalidateQueries({ queryKey: ['sessions', 'history'] }); + } catch { + setSaveError('Could not end session — try again.'); + } finally { + setEnding(false); + } + } + + if (isLoading) return ; + if (error || !session) { + return ( + void refetch()} + retrying={isRefetching} + /> + ); + } return (
-
+

{session.title}

@@ -83,9 +152,16 @@ function SessionPanel({ id }: { id: string }) { {connected ? 'Live' : 'Polling every 5s (WebSocket reconnecting…)'}

- - {session.status} - +
+ + {session.status} + + {session.status === 'active' ? ( + + ) : null} +
{saveError ? ( @@ -134,9 +210,9 @@ function cnPulse(active: boolean) { typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches ? '' : 'transition-colors duration-300'; - return [ + return cn( 'flex items-center justify-between rounded-lg border border-[var(--color-line)] bg-[var(--color-surface)] px-4 py-3', motion, - active ? 'border-[var(--color-oche)]' : '', - ].join(' '); + active && 'border-[var(--color-oche)]', + ); } diff --git a/apps/web/src/routes/SessionDetail.tsx b/apps/web/src/routes/SessionDetail.tsx index ccac4d2..13af61c 100644 --- a/apps/web/src/routes/SessionDetail.tsx +++ b/apps/web/src/routes/SessionDetail.tsx @@ -1,23 +1,43 @@ import { useQuery } from '@tanstack/react-query'; -import { useParams } from 'react-router-dom'; -import { SessionId } from '@/components/SessionId'; +import { Link, useParams } from 'react-router-dom'; import { PlayerAvatar } from '@/components/PlayerAvatar'; +import { QueryError } from '@/components/QueryFeedback'; +import { ScoreTimeline } from '@/components/ScoreTimeline'; +import { SessionId } from '@/components/SessionId'; +import { Button } from '@/components/ui/button'; +import { SessionDetailSkeleton } from '@/components/ui/skeleton'; import { api } from '@/lib/api'; -/** Session detail: scoreboard + game video (poster, metadata preload, byte-range via CDN). */ +/** Session detail: scoreboard, score timeline, and game video. */ export function SessionDetail() { const { id = '' } = useParams(); const { data: session, isLoading, error, + refetch, + isRefetching, } = useQuery({ queryKey: ['session', id], queryFn: () => api.getSession(id), + enabled: Boolean(id), }); - if (isLoading) return

Loading…

; - if (error || !session) return

Session not found.

; + if (isLoading) return ; + if (error || !session) { + return ( + void refetch()} + retrying={isRefetching} + action={ + + } + /> + ); + } return (
@@ -58,6 +78,8 @@ export function SessionDetail() { ))} + +
); } diff --git a/docs/HOMEWORK-SPEC.md b/docs/HOMEWORK-SPEC.md index 73b5eef..f692da6 100644 --- a/docs/HOMEWORK-SPEC.md +++ b/docs/HOMEWORK-SPEC.md @@ -67,9 +67,9 @@ Provide a short explanation covering: | Requirement | Oche implementation | Status | | ------------------ | ------------------------------------------------ | ------------------------------------- | | React dashboard | `apps/web` — Vite, React 19, TanStack Query | Done | -| Session ID | Used internally; **not shown in UI yet** | Gap | +| Session ID | `SessionId` component on Overview + Detail | Done | | Players + scores | `Overview.tsx` | Done | -| Player photo | `PlayerAvatar`; **seed has no photos** | Partial | +| Player photo | `PlayerAvatar` + ui-avatars in seed | Done | | Status | active/completed badge | Done | | Responsive UI | Tailwind, mobile-friendly layout | Done | | Update scores | PATCH + optimistic UI | Done | diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 3b18346..9bb7ede 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1,6 +1,6 @@ # Project roadmap — Oche take-home -**Last updated:** Tier 1 complete — verify staging deploy next. +**Last updated:** Tier 2 complete — verify staging deploy next. **Goal:** Close homework gaps, verify staging/prod, then polish for interview. Use checkbox files in [checklists/](./checklists/) to track progress. This doc is the **narrative + full tier reference**. @@ -24,13 +24,13 @@ Use checkbox files in [checklists/](./checklists/) to track progress. This doc i ### What needs attention before submit -| Priority | Item | Doc | -| -------- | ------------------------------------------------------------ | ------------------------------------------------------ | -| 🔴 | Staging/prod deploy verified with correct `VITE_API_BASE` | [02-pre-submission](./checklists/02-pre-submission.md) | -| 🟡 | README virtualization claim — implement or soften wording | [03-known-gaps](./checklists/03-known-gaps.md) | -| 🟡 | `db:force-rls` in deploy pipeline | [02-pre-submission](./checklists/02-pre-submission.md) | -| 🟡 | Re-seed locally (`npm run db:seed`) for player photos | — | -| 🟡 | Rehearse 90s interview tour | [INTERVIEW.md](./INTERVIEW.md) | +| Priority | Item | Doc | +| -------- | --------------------------------------------------------- | ------------------------------------------------------ | +| 🔴 | Staging/prod deploy verified with correct `VITE_API_BASE` | [02-pre-submission](./checklists/02-pre-submission.md) | +| 🟡 | README virtualization claim — implement or soften wording | Done (Tier 2) | +| 🟡 | `db:force-rls` in deploy pipeline | [02-pre-submission](./checklists/02-pre-submission.md) | +| 🟡 | Re-seed locally (`npm run db:seed`) for player photos | — | +| 🟡 | Rehearse 90s interview tour | [INTERVIEW.md](./INTERVIEW.md) | --- @@ -54,18 +54,18 @@ Detailed checkboxes: [checklists/04-enhancements.md](./checklists/04-enhancement --- -### Tier 2 — UX & dashboard polish - -| # | Enhancement | Effort | Notes | -| --- | ------------------------------------ | ------ | ------------------------------------- | -| 2.1 | Session picker / tabs | M | Multiple active sessions per owner | -| 2.2 | “End session” → `status: completed` | S | PATCH + WS broadcast | -| 2.3 | Score history timeline | M | Render `scoreEvents` on SessionDetail | -| 2.4 | History pagination / infinite scroll | M | Wire API `nextCursor` | -| 2.5 | History list virtualization | M | `@tanstack/react-virtual` | -| 2.6 | Empty / error states with actions | S | Create session, retry API | -| 2.7 | Loading skeletons | S | Replace plain “Loading…” | -| 2.8 | Dark mode toggle | M | CSS variables already in place | +### Tier 2 — UX & dashboard polish (**complete**) + +| # | Enhancement | Effort | Notes | +| --- | ------------------------------------ | ------ | ------------------------------------ | +| 2.1 | Session picker / tabs | M | ✅ `SessionPicker` on Overview | +| 2.2 | “End session” → `status: completed` | S | ✅ PATCH + WS broadcast | +| 2.3 | Score history timeline | M | ✅ `ScoreTimeline` on SessionDetail | +| 2.4 | History pagination / infinite scroll | M | ✅ `useInfiniteQuery` + `nextCursor` | +| 2.5 | History list virtualization | M | ✅ `@tanstack/react-virtual` | +| 2.6 | Empty / error states with actions | S | ✅ `QueryFeedback` | +| 2.7 | Loading skeletons | S | ✅ `ui/skeleton.tsx` | +| 2.8 | Dark mode toggle | M | ✅ `useTheme` + light palette | --- diff --git a/docs/checklists/01-homework-rubric.md b/docs/checklists/01-homework-rubric.md index 9509f7e..445dcbb 100644 --- a/docs/checklists/01-homework-rubric.md +++ b/docs/checklists/01-homework-rubric.md @@ -10,16 +10,16 @@ Legend: `[x]` = implemented in repo (verify manually before interview). `[ ]` = ### A1. Session overview (live screen) -| | Item | Where / how to verify | -| --- | ---------------------------- | --------------------------------------------------------------------------------------------------------------- | -| [x] | React SPA | `apps/web` — Vite + React 19 | -| [x] | **Session ID visible** in UI | `SessionId` on Overview + SessionDetail — truncated UUID + copy button | -| [x] | Players: names + scores | `apps/web/src/routes/Overview.tsx` | -| [x] | **Player photo** shown | Seed sets `photoUrl` via ui-avatars; `PlayerAvatar` renders with srcset | -| [x] | Status (active / completed) | Badge on Overview; detail on SessionDetail | -| [x] | Clean, responsive UI | Tailwind, `max-w-5xl`, mobile nav — resize browser | -| [x] | Update player scores | +/- buttons → `PATCH /sessions/:id` + optimistic UI | -| [x] | Real-time updates | WebSocket via Durable Object + **5s polling fallback** when disconnected | +| | Item | Where / how to verify | +| --- | ---------------------------- | ------------------------------------------------------------------------ | +| [x] | React SPA | `apps/web` — Vite + React 19 | +| [x] | **Session ID visible** in UI | `SessionId` on Overview + SessionDetail — truncated UUID + copy button | +| [x] | Players: names + scores | `apps/web/src/routes/Overview.tsx` | +| [x] | **Player photo** shown | Seed sets `photoUrl` via ui-avatars; `PlayerAvatar` renders with srcset | +| [x] | Status (active / completed) | Badge on Overview; detail on SessionDetail | +| [x] | Clean, responsive UI | Tailwind, `max-w-5xl`, mobile nav — resize browser | +| [x] | Update player scores | +/- buttons → `PATCH /sessions/:id` + optimistic UI | +| [x] | Real-time updates | WebSocket via Durable Object + **5s polling fallback** when disconnected | ### A2. Match history diff --git a/docs/checklists/03-known-gaps.md b/docs/checklists/03-known-gaps.md index 1a10c14..406157a 100644 --- a/docs/checklists/03-known-gaps.md +++ b/docs/checklists/03-known-gaps.md @@ -8,13 +8,13 @@ Priority: 🔴 fix/explain before demo · 🟡 should fix if time · 🟢 defer ## UI vs homework spec -| Pri | Issue | Detail | Action | -| --- | --------------------------- | --------------------------------------------- | ---------------------------------------------------------------------------------------------------- | -| ✅ | ~~Session ID not shown~~ | Fixed: `SessionId` on Overview + SessionDetail | — | -| ✅ | ~~Player photos empty~~ | Fixed: ui-avatars URLs in seed | Re-run `npm run db:seed` if DB predates change | -| ✅ | ~~No “create session” UI~~ | Fixed: `/sessions/new` form | — | -| 🟡 | No session picker | Overview always uses first active session | **Fix:** dropdown if multiple actives | -| 🟡 | Can’t mark completed in UI | PATCH supports `status` | **Fix:** “End session” button | +| Pri | Issue | Detail | Action | +| --- | -------------------------- | ---------------------------------------------- | ---------------------------------------------- | +| ✅ | ~~Session ID not shown~~ | Fixed: `SessionId` on Overview + SessionDetail | — | +| ✅ | ~~Player photos empty~~ | Fixed: ui-avatars URLs in seed | Re-run `npm run db:seed` if DB predates change | +| ✅ | ~~No “create session” UI~~ | Fixed: `/sessions/new` form | — | +| 🟡 | No session picker | Fixed: tabs when multiple actives | — | +| 🟡 | Can’t mark completed in UI | Fixed: “End session” on Overview | — | --- @@ -22,14 +22,14 @@ Priority: 🔴 fix/explain before demo · 🟡 should fix if time · 🟢 defer Claims in README, PERFORMANCE, MEDIA that are **design or partial** — don’t overstate in interview. -| Pri | Claim | Reality | Action | -| --- | ---------------------------------- | ---------------------------------------------- | ------------------------------------------------------------------------------------- | -| 🔴 | History list “virtualization” | Not implemented (`History.tsx` maps all rows) | **Fix** with `@tanstack/react-virtual` · **Or edit** README to say “planned at scale” | -| ✅ | ~~Photos: srcset~~ | Fixed: `media-src.ts` + `PlayerAvatar` srcset | — | -| 🟡 | `preconnect` to media origin | Only Google Fonts in `index.html` | **Fix:** preconnect to API/R2 domain in prod build | -| 🟡 | Media Capabilities API for video | `SessionDetail` uses static `` | **Fix** or **edit** MEDIA.md | -| 🟡 | HLS renditions | Schema + `` support; seed has MP4 only | **Explain:** offline `transcode-local.mjs` path | -| 🟢 | Queue + Container transcoding | Documented as paid-tier design | OK as “at scale” | +| Pri | Claim | Reality | Action | +| --- | --------------------------------- | ---------------------------------------------- | -------------------------------------------------- | +| ✅ | ~~History list “virtualization”~~ | Fixed: `@tanstack/react-virtual` on History | — | +| ✅ | ~~Photos: srcset~~ | Fixed: `media-src.ts` + `PlayerAvatar` srcset | — | +| 🟡 | `preconnect` to media origin | Only Google Fonts in `index.html` | **Fix:** preconnect to API/R2 domain in prod build | +| 🟡 | Media Capabilities API for video | `SessionDetail` uses static `` | **Fix** or **edit** MEDIA.md | +| 🟡 | HLS renditions | Schema + `` support; seed has MP4 only | **Explain:** offline `transcode-local.mjs` path | +| 🟢 | Queue + Container transcoding | Documented as paid-tier design | OK as “at scale” | --- diff --git a/docs/checklists/04-enhancements.md b/docs/checklists/04-enhancements.md index e234541..dcf4294 100644 --- a/docs/checklists/04-enhancements.md +++ b/docs/checklists/04-enhancements.md @@ -21,16 +21,16 @@ Legend: **Impact** (interviewer signal) · **Effort** (S/M/L) ## Tier 2 — UX & dashboard polish -| | Enhancement | Impact | Effort | Notes | -| --- | ------------------------------------ | --------------------- | ------ | ---------------------------------- | -| [ ] | Session picker / tabs | Multi-active venues | M | When several `active` sessions | -| [ ] | “End session” → `status: completed` | Complete lifecycle | S | PATCH + WS broadcast | -| [ ] | Score history timeline | Richer detail view | M | Use `scoreEvents` on SessionDetail | -| [ ] | History pagination / infinite scroll | Scale UX | M | Wire `nextCursor` from API | -| [ ] | History list virtualization | Matches README | M | `@tanstack/react-virtual` | -| [ ] | Empty / error states with actions | UX | S | “Create session”, retry API | -| [ ] | Loading skeletons | Perceived performance | S | Replace “Loading…” text | -| [ ] | Dark mode toggle | Polish | M | CSS variables already themed | +| | Enhancement | Impact | Effort | Notes | +| --- | ------------------------------------ | --------------------- | ------ | -------------------------------- | +| [x] | Session picker / tabs | Multi-active venues | M | `SessionPicker` on Overview | +| [x] | “End session” → `status: completed` | Complete lifecycle | S | PATCH + WS broadcast | +| [x] | Score history timeline | Richer detail view | M | `ScoreTimeline` on SessionDetail | +| [x] | History pagination / infinite scroll | Scale UX | M | `useInfiniteQuery` + nextCursor | +| [x] | History list virtualization | Matches README | M | `@tanstack/react-virtual` | +| [x] | Empty / error states with actions | UX | S | `QueryFeedback` components | +| [x] | Loading skeletons | Perceived performance | S | `ui/skeleton.tsx` | +| [x] | Dark mode toggle | Polish | M | `useTheme` + light palette | --- diff --git a/package-lock.json b/package-lock.json index 6149d4f..38b3a03 100644 --- a/package-lock.json +++ b/package-lock.json @@ -65,6 +65,7 @@ "@oche/shared": "*", "@radix-ui/react-slot": "^1.1.1", "@tanstack/react-query": "^5.62.0", + "@tanstack/react-virtual": "^3.14.5", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "react": "^19.0.0", @@ -3057,6 +3058,33 @@ "react": "^18 || ^19" } }, + "node_modules/@tanstack/react-virtual": { + "version": "3.14.5", + "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.14.5.tgz", + "integrity": "sha512-4EKRXh7zBLkbKbFmG3AUVkircuHd+7OdT1pocJSepxtfBd3qnrJgJ5rtPkRYyo9fmyVb2+pI2xPy5oYvMLQy6A==", + "license": "MIT", + "dependencies": { + "@tanstack/virtual-core": "3.17.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tanstack/virtual-core": { + "version": "3.17.3", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.17.3.tgz", + "integrity": "sha512-8Np/TFELpI0ySuJoVmjvOrQYXH/8sTX0Biv9szhFhY39xOdAAY+smrMxjxOum/ux3eM8MUJQsEJ0/R0UpvC8dw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", diff --git a/tests/e2e/dashboard.spec.ts b/tests/e2e/dashboard.spec.ts index 20463cf..e0df38b 100644 --- a/tests/e2e/dashboard.spec.ts +++ b/tests/e2e/dashboard.spec.ts @@ -53,7 +53,7 @@ test('score edit persists after reload', async ({ page }) => { test('match history navigates to a session and shows the video region', async ({ page }) => { await page.goto('/history'); - await expect(page.getByText('Loading history')).toBeHidden({ timeout: 20_000 }); + await expect(page.getByLabel('Match history')).toBeVisible({ timeout: 20_000 }); await page.locator('main ul a').first().click(); await expect(page.getByRole('heading', { level: 1 })).toBeVisible({ timeout: 20_000 }); await expect(page.locator('video, p').filter({ hasText: /video/i })).toBeVisible(); From e53c1b7b152b5d292377972e7d2b6f01dca9a555 Mon Sep 17 00:00:00 2001 From: Humza Butt Date: Fri, 3 Jul 2026 13:06:43 +0100 Subject: [PATCH 11/23] Implement media upload features and enhance session management - Added MediaUpload component for handling video and photo uploads with progress tracking and cancellation. - Introduced SessionMediaSection for attaching media to sessions, including video and poster uploads. - Enhanced CreateSession route to support optional player photo uploads during session creation. - Updated SessionDetail to display video and media upload options. - Improved media handling in the API, allowing for video URLs, posters, and player photos to be updated in sessions. - Added support for new video MIME types and updated OpenAPI specifications accordingly. - Implemented responsive image handling for session posters using PosterThumb component. --- SUBMISSION.md | 2 +- apps/api/src/lib/media-policy.ts | 13 +- apps/api/src/openapi/spec.ts | 15 +++ apps/api/src/services/sessions.ts | 22 +++- apps/web/src/components/MediaUpload.tsx | 111 ++++++++++++++++++ apps/web/src/components/PosterThumb.tsx | 19 +++ .../src/components/SessionMediaSection.tsx | 46 ++++++++ apps/web/src/components/SessionVideo.tsx | 83 +++++++++++++ apps/web/src/lib/media-src.ts | 25 ++++ apps/web/src/lib/upload-media.ts | 56 +++++++++ apps/web/src/routes/CreateSession.tsx | 101 +++++++++++----- apps/web/src/routes/History.tsx | 11 +- apps/web/src/routes/SessionDetail.tsx | 19 +-- apps/web/vite.config.d.ts.map | 2 +- apps/web/vite.config.js | 18 ++- apps/web/vite.config.ts | 18 ++- docs/ROADMAP.md | 16 +-- docs/checklists/03-known-gaps.md | 16 +-- docs/checklists/04-enhancements.md | 16 +-- package.json | 1 + packages/shared/src/__tests__/schema.test.ts | 8 ++ packages/shared/src/schema.ts | 24 +++- scripts/transcode-local.mjs | 104 ++++++++++++++-- 23 files changed, 653 insertions(+), 93 deletions(-) create mode 100644 apps/web/src/components/MediaUpload.tsx create mode 100644 apps/web/src/components/PosterThumb.tsx create mode 100644 apps/web/src/components/SessionMediaSection.tsx create mode 100644 apps/web/src/components/SessionVideo.tsx create mode 100644 apps/web/src/lib/upload-media.ts diff --git a/SUBMISSION.md b/SUBMISSION.md index 63c150f..09458b7 100644 --- a/SUBMISSION.md +++ b/SUBMISSION.md @@ -18,7 +18,7 @@ Frontend performance · efficient image/video handling · what changes at scale ## Notable extras -RLS on every table (proven by `npm run test:rls`) · SQLite Durable Object websockets · two isolated environments on a custom domain · session picker + end-session lifecycle · virtualised history with cursor pagination · score timeline on detail · light/dark theme · full docs hub + ADRs + diagrams. +RLS on every table (proven by `npm run test:rls`) · SQLite Durable Object websockets · two isolated environments on a custom domain · R2 media upload from the UI (progress + cancel) · offline FFmpeg transcode script · Media Capabilities video source pick · session picker + end-session lifecycle · virtualised history · full docs hub + ADRs + diagrams. ## Systematic checklists & roadmap diff --git a/apps/api/src/lib/media-policy.ts b/apps/api/src/lib/media-policy.ts index 912833d..71aacaf 100644 --- a/apps/api/src/lib/media-policy.ts +++ b/apps/api/src/lib/media-policy.ts @@ -1,6 +1,11 @@ /** Allowed MIME types and size limits for uploads. */ export const PHOTO_MIMES = new Set(['image/jpeg', 'image/png', 'image/webp', 'image/avif']); -export const VIDEO_MIMES = new Set(['video/mp4', 'video/webm']); +export const VIDEO_MIMES = new Set([ + 'video/mp4', + 'video/webm', + 'application/vnd.apple.mpegurl', + 'application/x-mpegURL', +]); export const MAX_PHOTO_BYTES = 5 * 1024 * 1024; export const MAX_VIDEO_BYTES = 100 * 1024 * 1024; @@ -14,7 +19,8 @@ export function classifyMedia(mime: string): MediaKind | null { } export function maxBytesFor(kind: MediaKind): number { - return kind === 'photo' ? MAX_PHOTO_BYTES : MAX_VIDEO_BYTES; + if (kind === 'photo') return MAX_PHOTO_BYTES; + return MAX_VIDEO_BYTES; } export function extensionFor(mime: string): string { @@ -31,6 +37,9 @@ export function extensionFor(mime: string): string { return 'mp4'; case 'video/webm': return 'webm'; + case 'application/vnd.apple.mpegurl': + case 'application/x-mpegURL': + return 'm3u8'; default: return 'bin'; } diff --git a/apps/api/src/openapi/spec.ts b/apps/api/src/openapi/spec.ts index e674a1e..71eca22 100644 --- a/apps/api/src/openapi/spec.ts +++ b/apps/api/src/openapi/spec.ts @@ -133,6 +133,21 @@ export const openApiDocument = { }, }, }, + videoUrl: { type: 'string', description: 'HTTPS URL or R2 key' }, + videoPoster: { type: 'string', description: 'HTTPS URL or R2 key' }, + hlsUrl: { type: 'string', description: 'HTTPS URL or R2 key' }, + playerPhotos: { + type: 'array', + maxItems: 12, + items: { + type: 'object', + required: ['playerId', 'photoUrl'], + properties: { + playerId: { type: 'string', format: 'uuid' }, + photoUrl: { type: 'string' }, + }, + }, + }, }, }, }, diff --git a/apps/api/src/services/sessions.ts b/apps/api/src/services/sessions.ts index 8f8f9ad..2413c83 100644 --- a/apps/api/src/services/sessions.ts +++ b/apps/api/src/services/sessions.ts @@ -143,7 +143,27 @@ export async function patchSession( }); } - if (input.scores?.length || input.status) { + for (const photo of input.playerPhotos ?? []) { + await tx + .update(players) + .set({ photoUrl: photo.photoUrl }) + .where(and(eq(players.id, photo.playerId), eq(players.sessionId, sessionId))); + } + + const mediaPatch: Partial = {}; + if (input.videoUrl !== undefined) mediaPatch.videoUrl = input.videoUrl; + if (input.videoPoster !== undefined) mediaPatch.videoPoster = input.videoPoster; + if (input.hlsUrl !== undefined) mediaPatch.hlsUrl = input.hlsUrl; + + const hasMediaPatch = Object.keys(mediaPatch).length > 0; + if (hasMediaPatch) { + await tx + .update(sessions) + .set({ ...mediaPatch, updatedAt: new Date() }) + .where(eq(sessions.id, sessionId)); + } + + if (input.scores?.length || input.status || input.playerPhotos?.length || hasMediaPatch) { await tx.update(sessions).set({ updatedAt: new Date() }).where(eq(sessions.id, sessionId)); } diff --git a/apps/web/src/components/MediaUpload.tsx b/apps/web/src/components/MediaUpload.tsx new file mode 100644 index 0000000..c8085af --- /dev/null +++ b/apps/web/src/components/MediaUpload.tsx @@ -0,0 +1,111 @@ +import { useRef, useState } from 'react'; +import { Button } from '@/components/ui/button'; +import { uploadMedia, type UploadProgress } from '@/lib/upload-media'; + +type MediaUploadProps = { + accept: string; + label: string; + hint?: string; + disabled?: boolean; + onUploaded: (result: { key: string; kind: 'photo' | 'video' }) => void | Promise; +}; + +/** File picker with upload progress bar and cancel (XHR abort). */ +export function MediaUpload({ accept, label, hint, disabled, onUploaded }: MediaUploadProps) { + const inputRef = useRef(null); + const abortRef = useRef(null); + const [progress, setProgress] = useState(null); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + + async function onFileChange(e: React.ChangeEvent) { + const file = e.target.files?.[0]; + e.target.value = ''; + if (!file) return; + + setError(null); + setBusy(true); + setProgress({ loaded: 0, total: file.size, percent: 0 }); + + const controller = new AbortController(); + abortRef.current = controller; + + try { + const result = await uploadMedia(file, { + signal: controller.signal, + onProgress: setProgress, + }); + await onUploaded(result); + setProgress(null); + } catch (err) { + const msg = err instanceof Error ? err.message : 'Upload failed'; + if (msg !== 'Upload cancelled') setError(msg); + setProgress(null); + } finally { + abortRef.current = null; + setBusy(false); + } + } + + function cancel() { + abortRef.current?.abort(); + } + + return ( +
+
+
+

{label}

+ {hint ?

{hint}

: null} +
+ +
+ + void onFileChange(e)} + /> + + {progress ? ( +
+
+
+
+
+ {progress.percent}% + +
+
+ ) : null} + + {error ? ( +

+ {error} +

+ ) : null} +
+ ); +} diff --git a/apps/web/src/components/PosterThumb.tsx b/apps/web/src/components/PosterThumb.tsx new file mode 100644 index 0000000..7fe1b19 --- /dev/null +++ b/apps/web/src/components/PosterThumb.tsx @@ -0,0 +1,19 @@ +import { posterSrcSet } from '@/lib/media-src'; + +/** History row thumbnail with responsive srcset when derivable. */ +export function PosterThumb({ src, alt = '' }: { src: string; alt?: string }) { + const srcSet = posterSrcSet(src); + return ( + {alt} + ); +} diff --git a/apps/web/src/components/SessionMediaSection.tsx b/apps/web/src/components/SessionMediaSection.tsx new file mode 100644 index 0000000..edfb625 --- /dev/null +++ b/apps/web/src/components/SessionMediaSection.tsx @@ -0,0 +1,46 @@ +import { useQueryClient } from '@tanstack/react-query'; +import { useState } from 'react'; +import { MediaUpload } from '@/components/MediaUpload'; +import { api } from '@/lib/api'; + +/** Upload game video / poster and attach to a session via PATCH. */ +export function SessionMediaSection({ sessionId, hasVideo }: { sessionId: string; hasVideo: boolean }) { + const qc = useQueryClient(); + const [notice, setNotice] = useState(null); + + async function attach(fields: { videoUrl?: string; videoPoster?: string; hlsUrl?: string }) { + setNotice(null); + await api.patchSession(sessionId, fields); + await qc.invalidateQueries({ queryKey: ['session', sessionId] }); + await qc.invalidateQueries({ queryKey: ['sessions'] }); + await qc.invalidateQueries({ queryKey: ['sessions', 'history'] }); + setNotice('Media attached to this session.'); + } + + return ( +
+

+ Session media +

+ {!hasVideo ? ( +

No video yet — upload a clip or poster below.

+ ) : null} + + attach({ videoUrl: result.key })} + /> + + attach({ videoPoster: result.key })} + /> + + {notice ?

{notice}

: null} +
+ ); +} diff --git a/apps/web/src/components/SessionVideo.tsx b/apps/web/src/components/SessionVideo.tsx new file mode 100644 index 0000000..58e9f4a --- /dev/null +++ b/apps/web/src/components/SessionVideo.tsx @@ -0,0 +1,83 @@ +import { useEffect, useState } from 'react'; + +type VideoSource = { src: string; type: string }; + +function buildCandidates(videoUrl: string, hlsUrl?: string | null): VideoSource[] { + const out: VideoSource[] = []; + if (hlsUrl) out.push({ src: hlsUrl, type: 'application/vnd.apple.mpegurl' }); + const isWebm = videoUrl.includes('.webm'); + out.push({ src: videoUrl, type: isWebm ? 'video/webm' : 'video/mp4' }); + return out; +} + +async function rankByMediaCapabilities(sources: VideoSource[]): Promise { + if ( + !('mediaCapabilities' in navigator) || + typeof navigator.mediaCapabilities?.decodingInfo !== 'function' + ) { + return sources; + } + + const scored = await Promise.all( + sources.map(async (source) => { + try { + const info = await navigator.mediaCapabilities.decodingInfo({ + type: 'media-source', + video: { + contentType: source.type, + width: 1280, + height: 720, + bitrate: 2_500_000, + framerate: 30, + }, + }); + const score = (info.supported ? 2 : 0) + (info.smooth ? 1 : 0) + (info.powerEfficient ? 1 : 0); + return { source, score }; + } catch { + return { source, score: 1 }; + } + }), + ); + + return scored.sort((a, b) => b.score - a.score).map((row) => row.source); +} + +/** Video player that orders sources via Media Capabilities when available. */ +export function SessionVideo({ + videoUrl, + videoPoster, + hlsUrl, +}: { + videoUrl: string; + videoPoster?: string | null; + hlsUrl?: string | null; +}) { + const [sources, setSources] = useState(() => buildCandidates(videoUrl, hlsUrl)); + + useEffect(() => { + let cancelled = false; + const candidates = buildCandidates(videoUrl, hlsUrl); + void rankByMediaCapabilities(candidates).then((ranked) => { + if (!cancelled) setSources(ranked.length ? ranked : candidates); + }); + return () => { + cancelled = true; + }; + }, [videoUrl, hlsUrl]); + + return ( + + ); +} diff --git a/apps/web/src/lib/media-src.ts b/apps/web/src/lib/media-src.ts index 3e19102..6e0d1aa 100644 --- a/apps/web/src/lib/media-src.ts +++ b/apps/web/src/lib/media-src.ts @@ -8,3 +8,28 @@ export function avatarSrcSet(url: string): string | undefined { const large = u.toString(); return `${small} 40w, ${large} 80w`; } + +/** Poster/thumbnail srcset — ui-avatars density variants or Cloudflare-style width params. */ +export function posterSrcSet(url: string): string | undefined { + if (url.includes('ui-avatars.com')) { + const u = new URL(url); + u.searchParams.set('size', '80'); + const small = u.toString(); + u.searchParams.set('size', '160'); + const large = u.toString(); + return `${small} 80w, ${large} 160w`; + } + + try { + const u = new URL(url); + if (u.searchParams.has('w') || u.searchParams.has('width')) { + const base = u.toString(); + u.searchParams.set('w', '160'); + return `${base} 80w, ${u.toString()} 160w`; + } + } catch { + return undefined; + } + + return undefined; +} diff --git a/apps/web/src/lib/upload-media.ts b/apps/web/src/lib/upload-media.ts new file mode 100644 index 0000000..a53330d --- /dev/null +++ b/apps/web/src/lib/upload-media.ts @@ -0,0 +1,56 @@ +const BASE = import.meta.env.VITE_API_BASE ?? 'http://localhost:8787'; +const OWNER_HEADER = 'demo-key-a'; + +export type UploadResult = { key: string; kind: 'photo' | 'video'; url: string }; +export type UploadProgress = { loaded: number; total: number; percent: number }; + +/** Upload via XHR so we get progress events and can abort mid-flight. */ +export function uploadMedia( + file: File, + opts?: { onProgress?: (p: UploadProgress) => void; signal?: AbortSignal }, +): Promise { + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + xhr.open('POST', `${BASE}/media/upload`); + xhr.setRequestHeader('content-type', file.type); + xhr.setRequestHeader('x-oche-owner', OWNER_HEADER); + + const onAbort = () => xhr.abort(); + opts?.signal?.addEventListener('abort', onAbort, { once: true }); + + xhr.upload.onprogress = (e) => { + if (!e.lengthComputable || !opts?.onProgress) return; + opts.onProgress({ + loaded: e.loaded, + total: e.total, + percent: Math.min(100, Math.round((e.loaded / e.total) * 100)), + }); + }; + + xhr.onload = () => { + opts?.signal?.removeEventListener('abort', onAbort); + if (xhr.status >= 200 && xhr.status < 300) { + resolve(JSON.parse(xhr.responseText) as UploadResult); + return; + } + try { + const body = JSON.parse(xhr.responseText) as { error: string }; + reject(new Error(body.error)); + } catch { + reject(new Error('Upload failed')); + } + }; + + xhr.onerror = () => { + opts?.signal?.removeEventListener('abort', onAbort); + reject(new Error('Network error during upload')); + }; + + xhr.onabort = () => { + opts?.signal?.removeEventListener('abort', onAbort); + reject(new Error('Upload cancelled')); + }; + + xhr.send(file); + }); +} diff --git a/apps/web/src/routes/CreateSession.tsx b/apps/web/src/routes/CreateSession.tsx index 5f361e0..39ff269 100644 --- a/apps/web/src/routes/CreateSession.tsx +++ b/apps/web/src/routes/CreateSession.tsx @@ -4,14 +4,17 @@ import { useState } from 'react'; import { Link, useNavigate } from 'react-router-dom'; import { Button } from '@/components/ui/button'; import { api } from '@/lib/api'; +import { uploadMedia } from '@/lib/upload-media'; -/** Create a session + players (POST /sessions). */ +/** Create a session + players (POST /sessions), with optional player photo uploads. */ export function CreateSession() { const navigate = useNavigate(); const qc = useQueryClient(); const [title, setTitle] = useState(''); const [names, setNames] = useState(['', '']); + const [photos, setPhotos] = useState<(File | null)[]>([null, null]); const [error, setError] = useState(null); + const [uploading, setUploading] = useState(false); const create = useMutation({ mutationFn: (body: CreateSessionInput) => api.createSession(body), @@ -27,19 +30,28 @@ export function CreateSession() { setNames((prev) => prev.map((n, idx) => (idx === i ? value : n))); } + function setPhoto(i: number, file: File | null) { + setPhotos((prev) => prev.map((p, idx) => (idx === i ? file : p))); + } + function addPlayer() { - if (names.length < 12) setNames((prev) => [...prev, '']); + if (names.length < 12) { + setNames((prev) => [...prev, '']); + setPhotos((prev) => [...prev, null]); + } } function removePlayer(i: number) { if (names.length <= 1) return; setNames((prev) => prev.filter((_, idx) => idx !== i)); + setPhotos((prev) => prev.filter((_, idx) => idx !== i)); } - function submit(e: React.FormEvent) { + async function submit(e: React.FormEvent) { e.preventDefault(); setError(null); - const players = names.map((name) => name.trim()).filter(Boolean); + const trimmed = names.map((name) => name.trim()); + const players = trimmed.map((name, position) => ({ name, position })).filter((p) => p.name); if (!title.trim()) { setError('Title is required.'); return; @@ -48,21 +60,43 @@ export function CreateSession() { setError('Add at least one player.'); return; } - create.mutate({ - title: title.trim(), - status: 'active', - players: players.map((name, position) => ({ name, score: 0, position })), - }); + + setUploading(true); + try { + const withPhotos = await Promise.all( + players.map(async (p, index) => { + const file = photos[index]; + if (!file) { + return { name: p.name, score: 0, position: p.position }; + } + const uploaded = await uploadMedia(file); + return { name: p.name, score: 0, position: p.position, photoUrl: uploaded.key }; + }), + ); + + create.mutate({ + title: title.trim(), + status: 'active', + players: withPhotos, + }); + } catch (err) { + setError(err instanceof Error ? err.message : 'Photo upload failed.'); + } finally { + setUploading(false); + } } + const busy = create.isPending || uploading; + return (

New session

- Creates a session via POST /sessions. + Creates a session via POST /sessions. Optional player photos upload + to R2 first.

-
+ void submit(e)} className="mt-6 grid gap-4">