lila/documentation/roadmap.md
lila 2b177aad5b feat(db): add incremental upsert seed script for WordNet vocabulary
Implements packages/db/src/seed.ts — reads all JSON files from
scripts/datafiles/, validates filenames against supported language
codes and POS, and upserts synsets into  and
via onConflictDoNothing. Safe to re-run; produces 0 writes on
a duplicate run.
2026-03-30 15:58:01 +02:00

7.9 KiB
Raw Blame History

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) [ ] 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: 24 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)