updating documentation, formatting
This commit is contained in:
parent
e320f43d8e
commit
047196c973
8 changed files with 523 additions and 2282 deletions
|
|
@ -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 English–Italian 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 2–4 players. Designed from the ground up to be language-pair agnostic.
|
||||
---
|
||||
|
||||
## 1. Project Overview
|
||||
|
||||
A vocabulary trainer for English–Italian 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 English–Italian 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, 2–4 players answer simultaneously in real time, live scores, winner screen
|
||||
- 1000+ English–Italian nouns seeded from WordNet
|
||||
|
||||
### Why `ws` over Socket.io
|
||||
|
||||
`ws` is the raw WebSocket library. For rooms of 2–4 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 A1–C2)
|
||||
|
||||
```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 (A1–C2) → 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 English–Italian 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
|
||||
- [ ] 10–20 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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue