373 lines
17 KiB
Markdown
373 lines
17 KiB
Markdown
# lila — Project Specification
|
||
|
||
> **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.
|
||
|
||
---
|
||
|
||
## 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
|
||
- **Type safety end-to-end**: TypeScript + Zod schemas shared between frontend and backend
|
||
|
||
---
|
||
|
||
## 2. Full Product Vision (Long-Term)
|
||
|
||
- Users log in via Google or GitHub (Better Auth)
|
||
- 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
|
||
|
||
This is the full vision. The MVP deliberately ignores most of it.
|
||
|
||
---
|
||
|
||
## 3. MVP Scope
|
||
|
||
**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 (Better Auth) | 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.
|
||
|
||
| Layer | Technology | Status |
|
||
| ------------ | ------------------------------ | ----------- |
|
||
| 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 | Better Auth (Google + GitHub) | ✅ |
|
||
| Deployment | Docker Compose, Caddy, Hetzner | ✅ |
|
||
| CI/CD | Forgejo Actions | ✅ |
|
||
| Realtime | WebSockets (`ws` library) | ❌ post-MVP |
|
||
| Cache | Valkey | ❌ post-MVP |
|
||
|
||
---
|
||
|
||
## 5. Repository Structure
|
||
|
||
```text
|
||
vocab-trainer/
|
||
├── .forgejo/
|
||
│ └── workflows/
|
||
│ └── deploy.yml — CI/CD pipeline (build, push, deploy)
|
||
├── apps/
|
||
│ ├── api/
|
||
│ │ └── src/
|
||
│ │ ├── app.ts — createApp() factory, CORS, auth handler, error middleware
|
||
│ │ ├── server.ts — starts server on PORT
|
||
│ │ ├── errors/
|
||
│ │ │ └── AppError.ts — AppError, ValidationError, NotFoundError
|
||
│ │ ├── lib/
|
||
│ │ │ └── auth.ts — Better Auth config (Google + GitHub providers)
|
||
│ │ ├── middleware/
|
||
│ │ │ ├── authMiddleware.ts — session validation for protected routes
|
||
│ │ │ └── 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/
|
||
│ ├── Dockerfile — multi-stage: dev + production (nginx:alpine)
|
||
│ ├── nginx.conf — SPA fallback routing
|
||
│ └── src/
|
||
│ ├── routes/
|
||
│ │ ├── index.tsx — landing page
|
||
│ │ ├── play.tsx — the quiz
|
||
│ │ ├── login.tsx — Google + GitHub login buttons
|
||
│ │ ├── about.tsx
|
||
│ │ └── __root.tsx
|
||
│ ├── lib/
|
||
│ │ └── auth-client.ts — Better Auth React client
|
||
│ ├── 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/
|
||
│ │ └── 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 (terms, translations, auth tables)
|
||
│ ├── 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 — local dev stack
|
||
├── docker-compose.prod.yml — production config reference
|
||
├── Caddyfile — reverse proxy routing
|
||
└── pnpm-workspace.yaml
|
||
```
|
||
|
||
`packages/shared` is the contract between frontend and backend. All request/response shapes are defined there as Zod schemas — never duplicated.
|
||
|
||
---
|
||
|
||
## 6. Architecture
|
||
|
||
### The Layered Architecture
|
||
|
||
```text
|
||
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
|
||
```
|
||
|
||
**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`.
|
||
|
||
### Production Infrastructure
|
||
|
||
```text
|
||
Internet → Caddy (HTTPS termination)
|
||
├── lilastudy.com → web container (nginx, static files)
|
||
├── api.lilastudy.com → api container (Express, port 3000)
|
||
└── git.lilastudy.com → forgejo container (git + registry, port 3000)
|
||
|
||
SSH (port 2222) → forgejo container (git push/pull)
|
||
```
|
||
|
||
All containers communicate over an internal Docker network. Only Caddy (80/443) and Forgejo SSH (2222) are exposed to the internet.
|
||
|
||
---
|
||
|
||
## 7. Data Model (Current State)
|
||
|
||
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`.
|
||
|
||
**Core tables:** `terms`, `translations`, `term_glosses`, `decks`, `deck_terms`, `topics`, `term_topics`
|
||
|
||
**Auth tables (managed by Better Auth):** `user`, `session`, `account`, `verification`
|
||
|
||
Key columns on `terms`: `id` (uuid), `pos` (CHECK-constrained), `source`, `source_id` (unique pair for idempotent imports)
|
||
|
||
Key columns on `translations`: `id`, `term_id` (FK), `language_code` (CHECK-constrained), `text`, `cefr_level` (nullable varchar(2), CHECK A1–C2)
|
||
|
||
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.
|
||
|
||
Full schema is in `packages/db/src/db/schema.ts`.
|
||
|
||
---
|
||
|
||
## 8. API
|
||
|
||
### Endpoints
|
||
|
||
```text
|
||
POST /api/v1/game/start GameRequest → GameSession (requires auth)
|
||
POST /api/v1/game/answer AnswerSubmission → AnswerResult (requires auth)
|
||
GET /api/v1/health Health check (public)
|
||
ALL /api/auth/* Better Auth handlers (public)
|
||
```
|
||
|
||
### Schemas (packages/shared)
|
||
|
||
**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)
|
||
|
||
---
|
||
|
||
## 9. Game Mechanics
|
||
|
||
- **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
|
||
- **Auth required**: users must log in via Google or GitHub
|
||
- **Submit-before-send**: user selects, then confirms (prevents misclicks)
|
||
|
||
---
|
||
|
||
## 10. Working Methodology
|
||
|
||
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 | Status |
|
||
| ------------------- | ----------------------------------------------------------------------- | ------ |
|
||
| Auth | Better Auth (Google + GitHub), embedded in Express API, user rows in DB | ✅ |
|
||
| Deployment | Docker Compose, Caddy, Forgejo, CI/CD, Hetzner VPS | ✅ |
|
||
| Hardening (partial) | CI/CD pipeline, DB backups | ✅ |
|
||
| 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 | ❌ |
|
||
| Hardening (rest) | Rate limiting, error boundaries, monitoring, accessibility | ❌ |
|
||
|
||
### 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 (current)
|
||
|
||
- `lilastudy.com` → React frontend (nginx serving static files)
|
||
- `api.lilastudy.com` → Express API + Better Auth
|
||
- `git.lilastudy.com` → Forgejo (git server + container registry)
|
||
- Docker Compose with Caddy for automatic HTTPS via Let's Encrypt
|
||
- CI/CD via Forgejo Actions (build on push to main, deploy via SSH)
|
||
- Daily DB backups with cron, synced to dev laptop
|
||
|
||
See `deployment.md` for full infrastructure documentation.
|
||
|
||
---
|
||
|
||
## 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
|
||
|
||
See `roadmap.md` for the full roadmap with task-level checkboxes.
|
||
|
||
### Dependency Graph
|
||
|
||
```text
|
||
Phase 0 (Foundation) ✅
|
||
└── Phase 1 (Vocabulary Data + API) ✅
|
||
└── Phase 2 (Singleplayer UI) ✅
|
||
├── Phase 3 (Auth) ✅
|
||
│ └── Phase 6 (Deployment + CI/CD) ✅
|
||
└── Phase 4 (Multiplayer Lobby)
|
||
└── Phase 5 (Multiplayer Game)
|
||
└── Phase 7 (Hardening)
|
||
```
|
||
|
||
---
|
||
|
||
## 14. Game Flow (Future)
|
||
|
||
Singleplayer: choose direction (en→it or it→en) → top-level category → part of speech → difficulty (A1–C2) → round count → game starts.
|
||
|
||
**Top-level categories (post-MVP):**
|
||
|
||
- **Grammar** — practice nouns, verb conjugations, etc.
|
||
- **Media** — practice vocabulary from specific books, films, songs, etc.
|
||
- **Thematic** — animals, kitchen, etc. (requires category metadata research)
|