7.8 KiB
Vocabulary Trainer — Roadmap
Each phase produces a working, deployable increment. Nothing is built speculatively.
Phase 0 — Foundation
Goal: Empty repo that builds, lints, and runs end-to-end.
Done when: pnpm dev starts both apps; GET /api/health returns 200; React renders a hello page.
[x] Initialise pnpm workspace monorepo: apps/web, apps/api, packages/shared, packages/db
[x] Configure TypeScript project references across packages
[x] Set up ESLint + Prettier with shared configs in root
[x] Set up Vitest in api and web and both packages
[x] Scaffold Express app with GET /api/health
[x] Scaffold Vite + React app with TanStack Router (single root route)
[x] Configure Drizzle ORM + connection to local PostgreSQL
[x] Write first migration (empty — just validates the pipeline works)
[x] docker-compose.yml for local dev: api, web, postgres, valkey
[x] .env.example files for apps/api and apps/web
[x] update decisions.md
Phase 1 — Vocabulary Data
Goal: Word data lives in the DB and can be queried via the API.
Done when: GET /api/decks/1/terms?limit=10 returns 10 terms from a specific deck.
[x] Run extract-en-it-nouns.py locally → generates datafiles/en-it-nouns.json
-- Import ALL available OMW noun synsets (no frequency filtering)
[x] Write Drizzle schema: terms, translations, language_pairs, term_glosses, decks, deck_terms
[x] Write and run migration (includes CHECK constraints for pos, gloss_type)
[x] Write packages/db/src/seed.ts (imports ALL terms + translations, NO decks)
[x] Download CEFR A1/A2 noun lists (from GitHub repos)
[ ] Write scripts/build_decks.ts (reads external CEFR lists, matches to DB, creates decks)
[ ] Run pnpm db:seed → populates terms
[ ] Run pnpm db:build-decks → creates curated decks
[ ] Define Zod response schemas in packages/shared
[ ] Implement DeckRepository.getTerms(deckId, limit, offset)
[ ] Implement QuizService.attachDistractors(terms) — same POS, server-side, no duplicates
[ ] Implement GET /language-pairs, GET /decks, GET /decks/:id/terms endpoints
[ ] Unit tests for QuizService (correct POS filtering, never includes the answer)
[ ] update decisions.md
Phase 2 — Auth
Goal: Users can log in via Google or GitHub and stay logged in. Done when: JWT from OpenAuth is validated by the API; protected routes redirect unauthenticated users; user row is created on first login.
[ ] Add OpenAuth service to docker-compose.yml
[ ] Write Drizzle schema: users (uuid id, text openauth_sub, no games_played/won columns)
[ ] Write and run migration (includes updated_at + triggers)
[ ] Implement JWT validation middleware in apps/api
[ ] Implement GET /api/auth/me (validate token, upsert user row via openauth_sub, return user)
[ ] Define auth Zod schemas in packages/shared
[ ] Frontend: login page with "Continue with Google" + "Continue with GitHub" buttons
[ ] Frontend: redirect to auth.yourdomain.com → receive JWT → store in memory + HttpOnly cookie
[ ] Frontend: TanStack Router auth guard (redirects unauthenticated users)
[ ] Frontend: TanStack Query api.ts attaches token to every request
[ ] Unit tests for JWT middleware
[ ] update decisions.md
Phase 3 — Single-player Mode
Goal: A logged-in user can complete a full solo quiz session. Done when: User sees 10 questions, picks answers, sees their final score.
[ ] Frontend: /singleplayer route
[ ] useQuizSession hook: fetch terms, manage question index + score state
[ ] QuestionCard component: prompt word + 4 answer buttons
[ ] OptionButton component: idle / correct / wrong states
[ ] ScoreScreen component: final score + play-again button
[ ] TanStack Query integration for GET /terms
[ ] RTL tests for QuestionCard and OptionButton
[ ] update decisions.md
Phase 4 — Multiplayer Rooms (Lobby)
Goal: Players can create and join rooms; the host sees all joined players in real time. Done when: Two browser tabs can join the same room and see each other's display names update live via WebSocket.
[ ] Write Drizzle schema: rooms, room_players (add deck_id FK to rooms)
[ ] Write and run migration (includes CHECK constraints: code=UPPER(code), status, max_players)
[ ] Add indexes: idx_rooms_host, idx_room_players_score
[ ] POST /rooms and POST /rooms/:code/join REST endpoints
[ ] RoomService: create room with short code, join room, enforce max player limit
[ ] POST /rooms accepts deck_id (which vocabulary deck to use)
[ ] WebSocket server: attach ws upgrade handler to the Express HTTP server
[ ] WS auth middleware: validate OpenAuth JWT on upgrade
[ ] WS message router: dispatch incoming messages by type
[ ] room:join / room:leave handlers → broadcast room:state to all room members
[ ] Room membership tracked in Valkey (ephemeral) + room_players in PostgreSQL (durable)
[ ] Define all WS event Zod schemas in packages/shared
[ ] Frontend: /multiplayer/lobby — create room form + join-by-code form
[ ] Frontend: /multiplayer/room/:code — player list, room code display, "Start Game" (host only)
[ ] Frontend: ws.ts singleton WS client with reconnect on drop
[ ] Frontend: Zustand gameStore handles incoming room:state events
[ ] update decisions.md
Phase 5 — Multiplayer Game
Goal: Host starts a game; all players answer simultaneously in real time; a winner is declared. Done when: 2–4 players complete a 10-round game with correct live scores and a winner screen.
[ ] GameService: generate question sequence for a room, enforce server-side 15 s timer
[ ] room:start WS handler → begin question loop, broadcast first game:question
[ ] game:answer WS handler → collect per-player answers
[ ] On all-answered or timeout → evaluate, broadcast game:answer_result
[ ] After N rounds → broadcast game:finished, update rooms.status + room_players.score in DB (transactional)
[ ] Frontend: /multiplayer/game/:code route
[ ] Frontend: extend Zustand store with currentQuestion, roundAnswers, scores
[ ] Frontend: reuse QuestionCard + OptionButton; add countdown timer ring
[ ] Frontend: ScoreBoard component — live per-player scores after each round
[ ] Frontend: GameFinished screen — winner highlight, final scores, "Play Again" button
[ ] Unit tests for GameService (round evaluation, tie-breaking, timeout auto-advance)
[ ] update decisions.md
Phase 6 — Production Deployment
Goal: App is live on Hetzner, accessible via HTTPS on all subdomains.
Done when: https://app.yourdomain.com loads; wss://api.yourdomain.com connects; auth flow works end-to-end.
[ ] docker-compose.prod.yml: all services + nginx-proxy + acme-companion
[ ] Nginx config per container: VIRTUAL_HOST + LETSENCRYPT_HOST env vars
[ ] Production .env files on VPS (OpenAuth secrets, DB credentials, Valkey URL)
[ ] Drizzle migration runs on api container start (includes CHECK constraints + triggers)
[ ] Seed production DB (run seed.ts once)
[ ] Smoke test: login → solo game → create room → multiplayer game end-to-end
[ ] update decisions.md
Phase 7 — Polish & Hardening (post-MVP)
Not required to ship, but address before real users arrive.
[ ] Rate limiting on API endpoints (express-rate-limit)
[ ] Graceful WS reconnect with exponential back-off
[ ] React error boundaries
[ ] GET /users/me/stats endpoint (aggregates from room_players) + profile page
[ ] Accessibility pass (keyboard nav, ARIA on quiz buttons)
[ ] Favicon, page titles, Open Graph meta
[ ] CI/CD pipeline (GitHub Actions → SSH deploy on push to main)
[ ] Database backups (cron → Hetzner Object Storage)
[ ] update decisions.md
Dependency Graph Phase 0 (Foundation) └── Phase 1 (Vocabulary Data) └── Phase 2 (Auth) ├── Phase 3 (Singleplayer) ← parallel with Phase 4 └── Phase 4 (Room Lobby) └── Phase 5 (Multiplayer Game) └── Phase 6 (Deployment)