updating documentation, formatting

This commit is contained in:
lila 2026-04-12 09:28:35 +02:00
parent e320f43d8e
commit 047196c973
8 changed files with 523 additions and 2282 deletions

View file

@ -1,518 +1,366 @@
# Vocabulary Trainer — Project Specification
# Glossa — Project Specification
## 1. Overview
> **This document is the single source of truth for the project.**
> It is written to be handed to any LLM as context. It contains the project vision, the current MVP scope, the tech stack, the architecture, and the roadmap.
A multiplayer EnglishItalian vocabulary trainer with a Duolingo-style quiz interface (one word prompt, four answer choices). Supports both single-player practice and real-time competitive multiplayer rooms of 24 players. Designed from the ground up to be language-pair agnostic.
---
## 1. Project Overview
A vocabulary trainer for EnglishItalian words. The quiz format is Duolingo-style: one word is shown as a prompt, and the user picks the correct translation from four choices (1 correct + 3 distractors of the same part-of-speech). The long-term vision is a multiplayer competitive game, but the MVP is a polished singleplayer experience.
**The core learning loop:**
Show word → pick answer → see result → next word → final score
The vocabulary data comes from WordNet + the Open Multilingual Wordnet (OMW). A one-time Python script extracts EnglishItalian noun pairs and seeds the database. The data model is language-pair agnostic by design — adding a new language later requires no schema changes.
### Core Principles
- **Minimal but extendable**: Working product fast, clean architecture for future growth
- **Mobile-first**: Touch-friendly Duolingo-like UX
- **Minimal but extendable**: working product fast, clean architecture for future growth
- **Mobile-first**: touch-friendly Duolingo-like UX
- **Type safety end-to-end**: TypeScript + Zod schemas shared between frontend and backend
---
## 2. Technology Stack
## 2. Full Product Vision (Long-Term)
| Layer | Technology |
| -------------------- | ----------------------------- |
| Monorepo | pnpm workspaces |
| Frontend | React 18, Vite, TypeScript |
| Routing | TanStack Router |
| Server state | TanStack Query |
| Client state | Zustand |
| Styling | Tailwind CSS + shadcn/ui |
| Backend | Node.js, Express, TypeScript |
| Realtime | WebSockets (`ws` library) |
| Database | PostgreSQL 18 |
| ORM | Drizzle ORM |
| Cache / Queue | Valkey 9 |
| Auth | OpenAuth (Google + GitHub) |
| Validation | Zod (shared schemas) |
| Testing | Vitest, React Testing Library |
| Linting / Formatting | ESLint, Prettier |
| Containerisation | Docker, Docker Compose |
| Hosting | Hetzner VPS |
- Users log in via Google or GitHub (OpenAuth)
- Singleplayer mode: 10-round quiz, score screen
- Multiplayer mode: create a room, share a code, 24 players answer simultaneously in real time, live scores, winner screen
- 1000+ EnglishItalian nouns seeded from WordNet
### Why `ws` over Socket.io
`ws` is the raw WebSocket library. For rooms of 24 players there is no need for Socket.io's transport fallbacks or room-management abstractions. The protocol is defined explicitly in `packages/shared`, which gives the same guarantees without the overhead.
### Why Valkey
Valkey stores ephemeral room state that does not need to survive a server restart. It keeps the PostgreSQL schema clean and makes room lookups O(1).
### Why pnpm workspaces without Turborepo
Turborepo adds parallel task running and build caching on top of pnpm workspaces. For a two-app monorepo of this size, the plain pnpm workspace commands (`pnpm -r run build`, `pnpm --filter`) are sufficient and there is one less tool to configure and maintain.
This is the full vision. The MVP deliberately ignores most of it.
---
## 3. Repository Structure
## 3. MVP Scope
```tree
**Goal:** A working, presentable singleplayer quiz that can be shown to real people.
### What is IN the MVP
- Vocabulary data in a PostgreSQL database (already seeded)
- REST API that returns quiz terms with distractors
- Singleplayer quiz UI: configurable rounds (3 or 10), answer feedback, score screen
- Clean, mobile-friendly UI (Tailwind + shadcn/ui)
- Global error handler with typed error classes
- Unit + integration tests for the API
- Local dev only (no deployment for MVP)
### What is CUT from the MVP
| Feature | Why cut |
| ------------------------------- | -------------------------------------- |
| Authentication (OpenAuth) | No user accounts needed for a demo |
| Multiplayer (WebSockets, rooms) | Core quiz works without it |
| Valkey / Redis cache | Only needed for multiplayer room state |
| Deployment to Hetzner | Ship to people locally first |
| User stats / profiles | Needs auth |
These are not deleted from the plan — they are deferred. The architecture is already designed to support them. See Section 11 (Post-MVP Ladder).
---
## 4. Technology Stack
The monorepo structure and tooling are already set up. This is the full stack — the MVP uses a subset of it.
| Layer | Technology | MVP? |
| ------------ | ------------------------------ | ----------- |
| Monorepo | pnpm workspaces | ✅ |
| Frontend | React 18, Vite, TypeScript | ✅ |
| Routing | TanStack Router | ✅ |
| Server state | TanStack Query | ✅ |
| Client state | Zustand | ✅ |
| Styling | Tailwind CSS + shadcn/ui | ✅ |
| Backend | Node.js, Express, TypeScript | ✅ |
| Database | PostgreSQL + Drizzle ORM | ✅ |
| Validation | Zod (shared schemas) | ✅ |
| Testing | Vitest, supertest | ✅ |
| Auth | OpenAuth (Google + GitHub) | ❌ post-MVP |
| Realtime | WebSockets (`ws` library) | ❌ post-MVP |
| Cache | Valkey | ❌ post-MVP |
| Deployment | Docker Compose, Hetzner, Nginx | ❌ post-MVP |
---
## 5. Repository Structure
```text
vocab-trainer/
├── apps/
│ ├── web/ # React SPA (Vite + TanStack Router)
│ │ ├── src/
│ │ │ ├── routes/
│ │ │ ├── components/
│ │ │ ├── stores/ # Zustand stores
│ │ │ └── lib/
│ │ └── Dockerfile
│ └── api/ # Express REST + WebSocket server
│ ├── src/
│ │ ├── routes/
│ │ ├── services/
│ │ ├── repositories/
│ │ └── websocket/
│ └── Dockerfile
│ ├── api/
│ │ └── src/
│ │ ├── app.ts — createApp() factory, express.json(), error middleware
│ │ ├── server.ts — starts server on PORT
│ │ ├── errors/
│ │ │ └── AppError.ts — AppError, ValidationError, NotFoundError
│ │ ├── middleware/
│ │ │ └── errorHandler.ts — central error middleware
│ │ ├── routes/
│ │ │ ├── apiRouter.ts — mounts /health and /game routers
│ │ │ ├── gameRouter.ts — POST /start, POST /answer
│ │ │ └── healthRouter.ts
│ │ ├── controllers/
│ │ │ └── gameController.ts — validates input, calls service, sends response
│ │ ├── services/
│ │ │ ├── gameService.ts — builds quiz sessions, evaluates answers
│ │ │ └── gameService.test.ts — unit tests (mocked DB)
│ │ └── gameSessionStore/
│ │ ├── GameSessionStore.ts — interface (async, Valkey-ready)
│ │ ├── InMemoryGameSessionStore.ts
│ │ └── index.ts
│ └── web/
│ └── src/
│ ├── routes/
│ │ ├── index.tsx — landing page
│ │ └── play.tsx — the quiz
│ ├── components/
│ │ └── game/
│ │ ├── GameSetup.tsx — settings UI
│ │ ├── QuestionCard.tsx — prompt + 4 options
│ │ ├── OptionButton.tsx — idle / correct / wrong states
│ │ └── ScoreScreen.tsx — final score + play again
│ └── main.tsx
├── packages/
│ ├── shared/ # Zod schemas, TypeScript types, constants
│ └── db/ # Drizzle schema, migrations, seed script
├── scripts/
| ├── datafiles/
│ | └── en-it-nouns.json
│ └── extract-en-it-nouns.py # One-time WordNet + OMW extraction → seed.json
│ ├── shared/
│ │ └── src/
│ │ ├── constants.ts — SUPPORTED_POS, DIFFICULTY_LEVELS, etc.
│ │ ├── schemas/game.ts — Zod schemas for all game types
│ │ └── index.ts
│ └── db/
│ ├── drizzle/ — migration SQL files
│ └── src/
│ ├── db/schema.ts — Drizzle schema
│ ├── models/termModel.ts — getGameTerms(), getDistractors()
│ ├── seeding-datafiles.ts — seeds terms + translations from JSON
│ ├── seeding-cefr-levels.ts — enriches translations with CEFR data
│ ├── generating-deck.ts — builds curated decks
│ └── index.ts
├── scripts/ — Python extraction/comparison/merge scripts
├── documentation/ — project docs
├── docker-compose.yml
├── docker-compose.prod.yml
├── pnpm-workspace.yaml
└── package.json
└── pnpm-workspace.yaml
```
`packages/shared` is the contract between frontend and backend. All request/response shapes and WebSocket event payloads are defined there as Zod schemas and inferred TypeScript types — never duplicated.
### pnpm workspace config
`pnpm-workspace.yaml` declares:
```yaml
packages:
- 'apps/*'
- 'packages/*'
```
### Root scripts
The root `package.json` defines convenience scripts that delegate to workspaces:
- `dev` — starts `api` and `web` in parallel
- `build` — builds all packages in dependency order
- `test` — runs Vitest across all workspaces
- `lint` — runs ESLint across all workspaces
For parallel dev, use `concurrently` or just two terminal tabs for MVP.
`packages/shared` is the contract between frontend and backend. All request/response shapes are defined there as Zod schemas — never duplicated.
---
## 4. Architecture — N-Tier / Layered
## 6. Architecture
### The Layered Architecture
```text
┌────────────────────────────────────┐
│ Presentation (React SPA) │ apps/web
├────────────────────────────────────┤
│ API / Transport │ HTTP REST + WebSocket
├────────────────────────────────────┤
│ Application (Controllers) │ apps/api/src/routes
│ Domain (Business logic) │ apps/api/src/services
│ Data Access (Repositories) │ apps/api/src/repositories
├────────────────────────────────────┤
│ Database (PostgreSQL via Drizzle) │ packages/db
│ Cache (Valkey) │ apps/api/src/lib/valkey.ts
└────────────────────────────────────┘
HTTP Request
Router — maps URL + HTTP method to a controller
Controller — handles HTTP only: validates input, calls service, sends response
Service — business logic only: no HTTP, no direct DB access
Model — database queries only: no business logic
Database
```
Each layer only communicates with the layer directly below it. Business logic lives in services, not in route handlers or repositories.
**The rule:** each layer only talks to the layer directly below it. A controller never touches the database. A service never reads `req.body`. A model never knows what a quiz is.
### Monorepo Package Responsibilities
| Package | Owns |
| ----------------- | -------------------------------------------------------- |
| `packages/shared` | Zod schemas, constants, derived TypeScript types |
| `packages/db` | Drizzle schema, DB connection, all model/query functions |
| `apps/api` | Router, controllers, services, error handling |
| `apps/web` | React frontend, consumes types from shared |
**Key principle:** all database code lives in `packages/db`. `apps/api` never imports `drizzle-orm` for queries — it only calls functions exported from `packages/db`.
---
## 5. Infrastructure
## 7. Data Model (Current State)
### Domain structure
Words are modelled as language-neutral concepts (terms) separate from learning curricula (decks). Adding a new language pair requires no schema changes — only new rows in `translations`, `decks`.
| Subdomain | Service |
| --------------------- | ----------------------- |
| `app.yourdomain.com` | React frontend |
| `api.yourdomain.com` | Express API + WebSocket |
| `auth.yourdomain.com` | OpenAuth service |
**Core tables:** `terms`, `translations`, `term_glosses`, `decks`, `deck_terms`, `categories`, `term_categories`
### Docker Compose services (production)
Key columns on `terms`: `id` (uuid), `pos` (CHECK-constrained), `source`, `source_id` (unique pair for idempotent imports)
| Container | Role |
| ---------------- | ------------------------------------------- |
| `postgres` | PostgreSQL 16, named volume |
| `valkey` | Valkey 8, ephemeral (no persistence needed) |
| `openauth` | OpenAuth service |
| `api` | Express + WS server |
| `web` | Nginx serving the Vite build |
| `nginx-proxy` | Automatic reverse proxy |
| `acme-companion` | Let's Encrypt certificate automation |
Key columns on `translations`: `id`, `term_id` (FK), `language_code` (CHECK-constrained), `text`, `cefr_level` (nullable varchar(2), CHECK A1C2)
```docker
nginx-proxy (:80/:443)
app.domain → web:80
api.domain → api:3000 (HTTP + WS upgrade)
auth.domain → openauth:3001
```
Deck model uses `source_language` + `validated_languages` array — one deck serves multiple target languages. Decks are frequency tiers (e.g. `en-core-1000`), not POS splits.
SSL is fully automatic via `nginx-proxy` + `acme-companion`. No manual Certbot needed.
### 5.1 Valkey Key Structure
Ephemeral room state is stored in Valkey with TTL (e.g., 1 hour).
PostgreSQL stores durable history only.
Key Format: `room:{code}:{field}`
| Key | Type | TTL | Description |
|------------------------------|---------|-------|-------------|
| `room:{code}:state` | Hash | 1h | Current question index, round status |
| `room:{code}:players` | Set | 1h | List of connected user IDs |
| `room:{code}:answers:{round}`| Hash | 15m | Temp storage for current round answers |
Recovery Strategy
If server crashes mid-game, Valkey data is lost.
PostgreSQL `room_players.score` remains 0.
Room status is reset to `finished` via startup health check if `updated_at` is stale.
Full schema is in `packages/db/src/db/schema.ts`.
---
## 6. Data Model
## 8. API
## Design principle
Words are modelled as language-neutral concepts (terms) separate from learning curricula (decks).
Adding a new language pair requires no schema changes — only new rows in `translations`, `decks`, and `language_pairs`.
## Core tables
terms
id uuid PK
synset_id text UNIQUE -- OMW ILI (e.g. "ili:i12345")
pos varchar(20) -- NOT NULL, CHECK (pos IN ('noun', 'verb', 'adjective', 'adverb'))
created_at timestamptz DEFAULT now()
-- REMOVED: frequency_rank (handled at deck level)
translations
id uuid PK
term_id uuid FK → terms.id
language_code varchar(10) -- NOT NULL, BCP 47: "en", "it"
text text -- NOT NULL
created_at timestamptz DEFAULT now()
UNIQUE (term_id, language_code, text) -- Allow synonyms, prevent exact duplicates
term_glosses
id uuid PK
term_id uuid FK → terms.id
language_code varchar(10) -- NOT NULL
text text -- NOT NULL
created_at timestamptz DEFAULT now()
language_pairs
id uuid PK
source varchar(10) -- NOT NULL
target varchar(10) -- NOT NULL
label text
active boolean DEFAULT true
UNIQUE (source, target)
decks
id uuid PK
name text -- NOT NULL (e.g. "A1 Italian Nouns", "Most Common 1000")
description text -- NULLABLE
pair_id uuid FK → language_pairs.id -- NULLABLE (for single-language or multi-pair decks)
created_by uuid FK → users.id -- NULLABLE (for system decks)
is_public boolean DEFAULT true
created_at timestamptz DEFAULT now()
deck_terms
deck_id uuid FK → decks.id
term_id uuid FK → terms.id
position smallint -- NOT NULL, ordering within deck (1, 2, 3...)
added_at timestamptz DEFAULT now()
PRIMARY KEY (deck_id, term_id)
users
id uuid PK -- Internal stable ID (FK target)
openauth_sub text UNIQUE -- NOT NULL, OpenAuth `sub` claim (e.g. "google|12345")
email varchar(255) UNIQUE -- NULLABLE (GitHub users may lack email)
display_name varchar(100)
created_at timestamptz DEFAULT now()
last_login_at timestamptz
-- REMOVED: games_played, games_won (derive from room_players)
rooms
id uuid PK
code varchar(8) UNIQUE -- NOT NULL, CHECK (code = UPPER(code))
host_id uuid FK → users.id
pair_id uuid FK → language_pairs.id
deck_id uuid FK → decks.id -- Which vocabulary deck this room uses
status varchar(20) -- NOT NULL, CHECK (status IN ('waiting', 'in_progress', 'finished'))
max_players smallint -- NOT NULL, DEFAULT 4, CHECK (max_players BETWEEN 2 AND 10)
round_count smallint -- NOT NULL, DEFAULT 10, CHECK (round_count BETWEEN 5 AND 20)
created_at timestamptz DEFAULT now()
updated_at timestamptz DEFAULT now() -- For stale room recovery
room_players
room_id uuid FK → rooms.id
user_id uuid FK → users.id
score integer DEFAULT 0 -- Final score only (written at game end)
joined_at timestamptz DEFAULT now()
left_at timestamptz -- Populated on WS disconnect/leave
PRIMARY KEY (room_id, user_id)
Indexes
-- Vocabulary
CREATE INDEX idx_terms_pos ON terms (pos);
CREATE INDEX idx_translations_lang ON translations (language_code, term_id);
-- Decks
CREATE INDEX idx_decks_pair ON decks (pair_id, is_public);
CREATE INDEX idx_decks_creator ON decks (created_by);
CREATE INDEX idx_deck_terms_term ON deck_terms (term_id);
-- Language Pairs
CREATE INDEX idx_pairs_active ON language_pairs (active, source, target);
-- Rooms
CREATE INDEX idx_rooms_status ON rooms (status);
CREATE INDEX idx_rooms_host ON rooms (host_id);
-- NOTE: idx_rooms_code omitted (UNIQUE constraint creates index automatically)
-- Room Players
CREATE INDEX idx_room_players_user ON room_players (user_id);
CREATE INDEX idx_room_players_score ON room_players (room_id, score DESC);
Repository Logic Note
`DeckRepository.getTerms(deckId, limit, offset)` fetches terms from a specific deck.
Query uses `deck_terms.position` for ordering.
For random practice within a deck: `WHERE deck_id = X ORDER BY random() LIMIT N`
(safe because deck is bounded, e.g., 500 terms max, not full table).
---
## 7. Vocabulary Data — WordNet + OMW
### Source
Open Multilingual Wordnet (OMW) — English & Italian nouns via Interlingual Index (ILI)
External CEFR lists — For deck curation (e.g. GitHub: ecom/cefr-lists)
### Extraction process
1. Run `extract-en-it-nouns.py` once locally using `wn` library
- Imports ALL bilingual noun synsets (no frequency filtering)
- Output: `datafiles/en-it-nouns.json` — committed to repo
2. Run `pnpm db:seed` — populates `terms` + `translations` tables from JSON
3. Run `pnpm db:build-decks` — matches external CEFR lists to DB terms, creates `decks` + `deck_terms`
### Benefits of deck-based approach
- WordNet frequency data is unreliable (e.g. chemical symbols rank high)
- Curricula can come from external sources (CEFR, Oxford 3000, SUBTLEX)
- Bad data excluded at deck level, not schema level
- Users can create custom decks later
- Multiple difficulty levels without schema changes
`terms.synset_id` stores the OMW ILI (e.g. `ili:i12345`) for traceability and future re-imports with additional languages.
---
## 8. Authentication — OpenAuth
All auth is delegated to the OpenAuth service at `auth.yourdomain.com`. Providers: Google, GitHub.
The API validates the JWT from OpenAuth on every protected request. User rows are created or updated on first login via the `sub` claim as the primary key.
**Auth endpoint on the API:**
| Method | Path | Description |
| ------ | -------------- | --------------------------- |
| GET | `/api/auth/me` | Validate token, return user |
All other auth flows (login, callback, token refresh) are handled entirely by OpenAuth — the frontend redirects to `auth.yourdomain.com` and receives a JWT back.
---
## 9. REST API
All endpoints prefixed `/api`. Request and response bodies validated with Zod on both sides using schemas from `packages/shared`.
### Vocabulary
| Method | Path | Description |
| ------ | ---------------------------- | --------------------------------- |
| GET | `/language-pairs` | List active language pairs |
| GET | `/terms?pair=en-it&limit=10` | Fetch quiz terms with distractors |
### Rooms
| Method | Path | Description |
| ------ | ------------------- | ----------------------------------- |
| POST | `/rooms` | Create a room → returns room + code |
| GET | `/rooms/:code` | Get current room state |
| POST | `/rooms/:code/join` | Join a room |
### Users
| Method | Path | Description |
| ------ | ----------------- | ---------------------- |
| GET | `/users/me` | Current user profile |
| GET | `/users/me/stats` | Games played, win rate |
---
## 10. WebSocket Protocol
One WS connection per client. Authenticated by passing the OpenAuth JWT as a query param on the upgrade request: `wss://api.yourdomain.com?token=...`.
All messages are JSON: `{ type: string, payload: unknown }`. The full set of types is a Zod discriminated union in `packages/shared` — both sides validate every message they receive.
### Client → Server
| type | payload | Description |
| ------------- | -------------------------- | -------------------------------- |
| `room:join` | `{ code }` | Subscribe to a room's WS channel |
| `room:leave` | — | Unsubscribe |
| `room:start` | — | Host starts the game |
| `game:answer` | `{ questionId, answerId }` | Player submits an answer |
### Server → Client
| type | payload | Description |
| -------------------- | -------------------------------------------------- | ----------------------------------------- |
| `room:state` | Full room snapshot | Sent on join and on any player join/leave |
| `game:question` | `{ id, prompt, options[], timeLimit }` | New question broadcast to all players |
| `game:answer_result` | `{ questionId, correct, correctAnswerId, scores }` | Broadcast after all answer or timeout |
| `game:finished` | `{ scores[], winner }` | End of game summary |
| `error` | `{ message }` | Protocol or validation error |
### Multiplayer game mechanic — simultaneous answers
All players see the same question at the same time. Everyone submits independently. The server waits until all players have answered **or** the 15-second timeout fires — then broadcasts `game:answer_result` with updated scores. There is no buzz-first mechanic. This keeps the experience Duolingo-like and symmetric.
### Game flow
### Endpoints
```text
host creates room (REST) →
players join via room code (REST + WS room:join) →
room:state broadcasts player list →
host sends room:start →
server broadcasts game:question →
players send game:answer →
server collects all answers or waits for timeout →
server broadcasts game:answer_result →
repeat for N rounds →
server broadcasts game:finished
POST /api/v1/game/start GameRequest → GameSession
POST /api/v1/game/answer AnswerSubmission → AnswerResult
GET /api/v1/health Health check
```
### Room state in Valkey
### Schemas (packages/shared)
Active room state (connected players, current question, answers received this round) is stored in Valkey with a TTL. PostgreSQL holds the durable record (`rooms`, `room_players`). On server restart, in-progress games are considered abandoned — acceptable for MVP.
**GameRequest:** `{ source_language, target_language, pos, difficulty, rounds }`
**GameSession:** `{ sessionId: uuid, questions: GameQuestion[] }`
**GameQuestion:** `{ questionId: uuid, prompt: string, gloss: string | null, options: AnswerOption[4] }`
**AnswerOption:** `{ optionId: number (0-3), text: string }`
**AnswerSubmission:** `{ sessionId: uuid, questionId: uuid, selectedOptionId: number (0-3) }`
**AnswerResult:** `{ questionId: uuid, isCorrect: boolean, correctOptionId: number (0-3), selectedOptionId: number (0-3) }`
### Error Handling
Typed error classes (`AppError` base, `ValidationError` 400, `NotFoundError` 404) with central error middleware. Controllers validate with `safeParse`, throw on failure, and call `next(error)` in the catch. The middleware maps `AppError` instances to HTTP status codes; unknown errors return 500.
### Key Design Rules
- Server-side answer evaluation: the correct answer is never sent to the frontend
- `POST` not `GET` for game start (configuration in request body)
- `safeParse` over `parse` (clean 400s, not raw Zod 500s)
- Session state stored in `GameSessionStore` (in-memory now, Valkey later)
---
## 11. Game Mechanics
## 9. Game Mechanics
- **Question format**: source-language word prompt + 4 target-language choices (1 correct + 3 distractors of the same POS)
- **Distractors**: generated server-side, never include the correct answer, never repeat within a session
- **Scoring**: +1 point per correct answer. Speed bonus is out of scope for MVP.
- **Timer**: 15 seconds per question, server-authoritative
- **Single-player**: uses `GET /terms` and runs entirely client-side. No WebSocket.
- **Format**: source-language word prompt + 4 target-language choices
- **Distractors**: same POS, same difficulty, server-side, never the correct answer, never repeated within a session
- **Session length**: 3 or 10 questions (configurable)
- **Scoring**: +1 per correct answer (no speed bonus for MVP)
- **Timer**: none in singleplayer MVP
- **No auth required**: anonymous users
- **Submit-before-send**: user selects, then confirms (prevents misclicks)
---
## 12. Frontend Structure
## 10. Working Methodology
```tree
apps/web/src/
├── routes/
│ ├── index.tsx # Landing / mode select
│ ├── auth/
│ ├── singleplayer/
│ └── multiplayer/
│ ├── lobby.tsx # Create or join by code
│ ├── room.$code.tsx # Waiting room
│ └── game.$code.tsx # Active game
├── components/
│ ├── quiz/ # QuestionCard, OptionButton, ScoreBoard
│ ├── room/ # PlayerList, RoomCode, ReadyState
│ └── ui/ # shadcn/ui wrappers: Button, Card, Dialog ...
├── stores/
│ └── gameStore.ts # Zustand: game session, scores, WS state
├── lib/
│ ├── api.ts # TanStack Query wrappers
│ └── ws.ts # WS client singleton
└── main.tsx
This project is a learning exercise. The goal is to understand the code, not just to ship it.
### How to use an LLM for help
1. Paste this document as context
2. Describe what you're working on and what you're stuck on
3. Ask for hints, not solutions
### Refactoring workflow
After completing a task: share the code, ask what to refactor and why. The LLM should explain the concept, not write the implementation.
---
## 11. Post-MVP Ladder
| Phase | What it adds |
| ----------------- | -------------------------------------------------------------- |
| Auth | OpenAuth (Google + GitHub), JWT middleware, user rows in DB |
| User Stats | Games played, score history, profile page |
| Multiplayer Lobby | Room creation, join by code, WebSocket connection |
| Multiplayer Game | Simultaneous answers, server timer, live scores, winner screen |
| Deployment | Docker Compose prod config, Nginx, Let's Encrypt, Hetzner VPS |
| Hardening | Rate limiting, error boundaries, CI/CD, DB backups |
### Future Data Model Extensions (deferred, additive)
- `noun_forms` — gender, singular, plural, articles per language
- `verb_forms` — conjugation tables per language
- `term_pronunciations` — IPA and audio URLs per language
- `user_decks` — which decks a user is studying
- `user_term_progress` — spaced repetition state per user/term/language
- `quiz_answers` — history log for stats
All are new tables referencing existing `terms` rows via FK. No existing schema changes required.
### Multiplayer Architecture (deferred)
- WebSocket protocol: `ws` library, Zod discriminated union for message types
- Room model: human-readable codes (e.g. `WOLF-42`), not matchmaking queue
- Game mechanic: simultaneous answers, 15-second server timer, all players see same question
- Valkey for ephemeral room state, PostgreSQL for durable records
### Infrastructure (deferred)
- `app.yourdomain.com` → React frontend
- `api.yourdomain.com` → Express API + WebSocket
- `auth.yourdomain.com` → OpenAuth service
- Docker Compose with `nginx-proxy` + `acme-companion` for automatic SSL
---
## 12. Definition of Done (MVP)
- [x] API returns quiz terms with correct distractors
- [x] User can complete a quiz without errors
- [x] Score screen shows final result and a play-again option
- [x] App is usable on a mobile screen
- [x] No hardcoded data — everything comes from the database
- [x] Global error handler with typed error classes
- [x] Unit + integration tests for API
---
## 13. Roadmap
### Phase 0 — Foundation ✅
Empty repo that builds, lints, and runs end-to-end. `pnpm dev` starts both apps; `GET /api/health` returns 200; React renders a hello page.
### Phase 1 — Vocabulary Data + API ✅
Word data lives in the DB. API returns quiz sessions with distractors. CEFR enrichment pipeline complete. Global error handler and tests implemented.
### Phase 2 — Singleplayer Quiz UI ✅
User can complete a full quiz in the browser. Settings UI, question cards, answer feedback, score screen.
### Phase 3 — Auth
Users can log in via Google or GitHub and stay logged in. JWT validated by API. User row created on first login.
### Phase 4 — Multiplayer Lobby
Players can create and join rooms. Two browser tabs can join the same room and see each other via WebSocket.
### Phase 5 — Multiplayer Game
Host starts a game. All players answer simultaneously in real time. Winner declared.
### Phase 6 — Production Deployment
App is live on Hetzner with HTTPS. Auth flow works end-to-end.
### Phase 7 — Polish & Hardening
Rate limiting, reconnect logic, error boundaries, CI/CD, DB backups.
### Dependency Graph
```text
Phase 0 (Foundation)
└── Phase 1 (Vocabulary Data + API)
└── Phase 2 (Singleplayer UI)
└── Phase 3 (Auth)
├── Phase 4 (Room Lobby)
│ └── Phase 5 (Multiplayer Game)
│ └── Phase 6 (Deployment)
└── Phase 7 (Hardening)
```
### Zustand store (single store for MVP)
```typescript
interface AppStore {
user: User | null;
gameSession: GameSession | null;
currentQuestion: Question | null;
scores: Record<string, number>;
isLoading: boolean;
error: string | null;
}
```
TanStack Query handles all server data fetching. Zustand handles ephemeral UI and WebSocket-driven state.
---
## 13. Testing Strategy
## 14. Game Flow (Future)
| Type | Tool | Scope |
| ----------- | -------------------- | --------------------------------------------------- |
| Unit | Vitest | Services, QuizService distractor logic, Zod schemas |
| Component | Vitest + RTL | QuestionCard, OptionButton, auth forms |
| Integration | Vitest | API route handlers against a test DB |
| E2E | Out of scope for MVP | — |
Singleplayer: choose direction (en→it or it→en) → top-level category → part of speech → difficulty (A1C2) → round count → game starts.
Tests are co-located with source files (`*.test.ts` / `*.test.tsx`).
**Top-level categories (post-MVP):**
**Critical paths to cover:**
- Distractor generation (correct POS, no duplicates, never includes answer)
- Answer validation (server-side, correct scoring)
- Game session lifecycle (create → play → complete)
- JWT validation middleware
---
## 14. Definition of Done
### Functional
- [ ] User can log in via Google or GitHub (OpenAuth)
- [ ] User can play singleplayer: 10 rounds, score, result screen
- [ ] User can create a room and share a code
- [ ] User can join a room via code
- [ ] Multiplayer: 10 rounds, simultaneous answers, real-time score sync
- [ ] 1 000 EnglishItalian words seeded from WordNet + OMW
### Technical
- [ ] Deployed to Hetzner with HTTPS on all three subdomains
- [ ] Docker Compose running all services
- [ ] Drizzle migrations applied on container start
- [ ] 1020 passing tests covering critical paths
- [ ] pnpm workspace build pipeline green
---
## 15. Out of Scope (MVP)
- Difficulty levels _(`frequency_rank` column exists, ready to use)_
- Additional language pairs _(schema already supports it — just add rows)_
- Leaderboards _(`games_played`, `games_won` columns exist)_
- Streaks / daily challenges
- Friends / private invites
- Audio pronunciation
- CI/CD pipeline (manual deploy for now)
- Rate limiting _(add before going public)_
- Admin panel for vocabulary management
- **Grammar** — practice nouns, verb conjugations, etc.
- **Media** — practice vocabulary from specific books, films, songs, etc.
- **Thematic** — animals, kitchen, etc. (requires category metadata research)