docs: add flashcard app design spec

Initial design for a single-user local flashcard webapp with hierarchical
lessons, Leitner-based spaced repetition, Excel import/export, and
animated practice UI.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-20 20:08:09 +02:00
commit 35fd3d6adc
2 changed files with 373 additions and 0 deletions

11
.gitignore vendored Normal file
View File

@@ -0,0 +1,11 @@
node_modules/
dist/
build/
.DS_Store
.env
.env.local
data/*.db
data/*.db-*
coverage/
.vite/
*.log

View File

@@ -0,0 +1,362 @@
# Flashcard Webapplicatie — Design Spec
**Datum:** 2026-05-20
**Status:** Draft, ready for review
**Scope:** v1, single-user lokale webapp
---
## 1. Doel
Een moderne, snelle webapplicatie waarin een student flashcards kan oefenen, georganiseerd in een hiërarchische lessenstructuur. De applicatie houdt voortgang en statistieken bij en gebruikt een spaced-repetition algoritme om kaarten die nog niet beheerst worden vaker te tonen.
## 2. Uitgangspunten & non-goals
**Single-user, lokaal.** Geen authenticatie, geen multi-tenant. De student is ook de admin.
**v1 is text-only.** Geen audio, afbeeldingen of formules op kaarten (toekomstwerk).
**Geen cloud sync.** Data leeft lokaal in SQLite. Back-up via Excel-export.
**Modern en snel.** Animaties, dark mode, responsive. Geen native mobiele app.
## 3. Functionele eisen
### 3.1 Lessenstructuur
- Lessen vormen een boom (n-diep, geen vaste limiet). Elke les heeft een `parent_id` (nullable voor wortels).
- Lessen hebben naam, optionele beschrijving, en een `position` voor handmatige sortering binnen hun parent.
- Lessen hebben een `bidirectional` flag (default `false`): als `true` worden kaarten in deze les óók in omgekeerde richting (antwoord → vraag) geoefend.
- Een les kan verplaatst, hernoemd en verwijderd worden. Bij verwijdering: cascade naar onderliggende lessen en kaarten (na bevestiging).
### 3.2 Flashcards
- Een kaart hoort bij precies één les.
- Velden: `question`, `answer`, optionele `hint`, `position`.
- Kaarten kunnen handmatig worden aangemaakt, bewerkt, verwijderd en herordend.
- Bulk import via Excel; bulk export naar Excel.
### 3.3 Oefensessie
- De student start een sessie vanaf een les. De sessie bevat alle kaarten van die les **én alle onderliggende lessen** (recursief).
- Sessie-instellingen vooraf:
- **Max. aantal kaarten** (default 20, of "alle")
- **Shuffle** (default aan)
- **Richting** (alleen relevant als één of meer betrokken lessen `bidirectional=true`): voorwaarts / achterwaarts / beide
- Per kaart:
1. Vraag wordt getoond (groot, gecentreerd).
2. "Hint" knop indien aanwezig.
3. "Toon antwoord" knop. Klik triggert een 3D card-flip animatie.
4. Antwoord verschijnt; gelijktijdig verschijnen **Goed** (groen) en **Fout** (rood) knoppen.
5. Klik → resultaat opgeslagen, volgende kaart verschijnt met slide-animatie.
- Na de laatste kaart: sessie-overzicht met confetti bij score ≥80%.
### 3.4 Spaced repetition — Leitner met intra-sessie correctie
**Tussen sessies — Leitner (5 dozen):**
- Nieuwe kaart start in doos 1.
- **Goed** → kaart naar volgende doos (max 5).
- **Fout** → kaart terug naar doos 1.
- Volgende-due-datum per doos:
- Doos 1: direct (volgende sessie)
- Doos 2: +1 dag
- Doos 3: +3 dagen
- Doos 4: +7 dagen
- Doos 5: +14 dagen
**Volgorde binnen een sessie:**
- Selecteer eerst alle "due" kaarten (next_due_at ≤ nu) uit de scope; vul aan met laagste-doos kaarten tot het sessiemaximum.
- Volgorde: shuffle, maar gewogen — kaarten uit lagere dozen krijgen meer prioriteit aan het begin.
- **Intra-sessie regel:** bij fout antwoord komt de kaart terug in de wachtrij ~3 posities later in dezelfde sessie. Een kaart kan in één sessie meerdere keren voorkomen, maar telt voor Leitner-progressie alleen op het eindresultaat (laatste poging).
### 3.5 Statistieken
**Per kaart:**
- Totaal getoond (aantal pogingen)
- Goed / fout (aantal en %)
- Huidige doos (1-5)
- Laatst getoond, eerstvolgende due-datum
- Geschiedenis: lijst pogingen met datum/tijd en resultaat
**Per les (inclusief onderliggende lessen, optioneel toe te tonen):**
- Score: gewogen gemiddelde van kaart-scores. Een kaart-score = `correct / (correct + incorrect)`, mits ≥3 pogingen; anders telt de kaart niet mee.
- Aantal kaarten / aantal beheerst (in doos ≥4)
- Aantal sessies gespeeld
- Totale oefenduur
**Per sessie:**
- Start- en eindtijd, totale duur
- Aantal kaarten behandeld, goed/fout-verdeling
- Gemiddelde reactietijd per kaart
**Globaal (dashboard):**
- Daily streak (aaneengesloten dagen met ≥1 sessie)
- Totale oefenduur, totale aantal sessies
- Heatmap van laatste 12 weken
- Recente sessies
### 3.6 Admin-interface
- Boomweergave van lessen met inline rename, drag-drop herordening en "+ subles" knoppen.
- Klik op een les → kaartenbeheer rechts (of nieuwe pagina): tabel met question/answer/hint, inline edit, snel toevoegen, sortering.
- **Excel import:**
- Knop "Importeer Excel" → upload `.xlsx`
- Verwachte kolommen (header rij verplicht): `question`, `answer`, `hint` (optioneel), `lesson_path` (optioneel; bv. `Spaans/Werkwoorden/Onregelmatig`. Indien leeg: kaart komt in de huidige les. Indien pad niet bestaat: lessen worden aangemaakt na bevestiging.)
- Preview met aantal rijen, fouten en duplicaten (op question binnen dezelfde les)
- "Bevestig import" knop
- Idempotent: bestaande kaart (zelfde question in zelfde les) wordt geüpdatet, niet gedupliceerd. Optie: "alleen toevoegen, niet updaten".
- **Excel export:**
- Per les (inclusief sublessen optioneel) → `.xlsx` met dezelfde kolommen.
### 3.7 Sessie hervatten
Als de gebruiker een sessie verlaat zonder hem af te ronden, blijft de sessie "open". Op de volgende bezoek toont de app: "Je hebt een open sessie — hervatten of afsluiten?". Bij hervatten wordt de wachtrij hersteld.
### 3.8 Daily streak
Een dag telt als de gebruiker minstens één voltooide sessie heeft. Bij twee opeenvolgende dagen zonder sessie: streak reset naar 0.
## 4. Tech stack
| Laag | Keuze | Reden |
|---|---|---|
| Monorepo | npm workspaces + concurrently | Eenvoudig, geen extra tooling |
| Frontend | React 18 + TS + Vite 7 + Tailwind 3 + React Router 6 + Zustand + Framer Motion | Modern, snel; Framer Motion voor de gevraagde animaties |
| Backend | Node.js + Express 4 + TS (ESM, tsx in dev) | Pragmatisch, brede ervaring |
| Database | SQLite (better-sqlite3) | Single-user lokaal; geen Postgres-overhead. Eén bestand, simpele back-up. |
| ORM | Drizzle ORM | TS-first, lichtgewicht, eenvoudige migratie naar Postgres later |
| Validatie | Zod | Hergebruikbaar tussen API en Excel-import |
| Excel | SheetJS (`xlsx`) | De-facto standaard, server-side |
| Test | Vitest (unit) + Playwright (smoke) | Past bij Vite-stack |
| Lint/format | ESLint + Prettier | Standaard |
## 5. Architectuur
### 5.1 Mappenstructuur
```
flashcard/
├── packages/
│ ├── backend/
│ │ ├── src/
│ │ │ ├── db/ Drizzle schema + migraties
│ │ │ ├── routes/ Express routers per resource
│ │ │ ├── services/ Business logic (sessions, leitner, stats, import)
│ │ │ ├── lib/ Helpers (excel, errors)
│ │ │ └── index.ts Server entry
│ │ └── package.json
│ ├── frontend/
│ │ ├── src/
│ │ │ ├── pages/ Route components
│ │ │ ├── components/ UI atoms/molecules
│ │ │ ├── stores/ Zustand stores
│ │ │ ├── api/ Fetch client
│ │ │ ├── lib/ Helpers
│ │ │ └── main.tsx
│ │ └── package.json
│ └── shared/
│ ├── types.ts Domain types
│ └── schemas.ts Zod schemas (gedeeld FE/BE)
├── data/
│ ├── flashcard.db SQLite (gitignored)
│ └── templates/import.xlsx
└── package.json workspaces + scripts
```
### 5.2 Datamodel (Drizzle / SQLite)
```ts
lessons {
id: integer pk
parent_id: integer fk lessons.id, nullable
name: text not null
description: text
position: integer not null default 0
bidirectional: integer (boolean) not null default 0
created_at: integer (timestamp) not null
updated_at: integer (timestamp) not null
}
cards {
id: integer pk
lesson_id: integer fk lessons.id, not null, on delete cascade
question: text not null
answer: text not null
hint: text
position: integer not null default 0
created_at: integer not null
updated_at: integer not null
}
card_progress {
card_id: integer fk cards.id pk, on delete cascade
direction: text ('forward'|'backward') pk -- bidirectional twee rijen per kaart
box: integer not null default 1 -- 1..5
correct_count: integer not null default 0
incorrect_count: integer not null default 0
last_shown_at: integer
next_due_at: integer not null -- direct due bij aanmaak
}
sessions {
id: integer pk
lesson_id: integer fk lessons.id, not null
started_at: integer not null
ended_at: integer
duration_seconds: integer
cards_shown: integer not null default 0
cards_correct: integer not null default 0
cards_incorrect: integer not null default 0
status: text ('active'|'completed'|'abandoned') not null
queue_snapshot: text -- JSON: voor hervatten
}
attempts {
id: integer pk
session_id: integer fk sessions.id, on delete cascade
card_id: integer fk cards.id, on delete cascade
direction: text ('forward'|'backward')
shown_at: integer not null
result: text ('correct'|'incorrect') not null
time_to_answer_ms: integer
}
```
Indexen op `cards.lesson_id`, `attempts.session_id`, `attempts.card_id`, `card_progress.next_due_at`.
### 5.3 API endpoints
```
# Lessen
GET /api/lessons/tree
POST /api/lessons
PATCH /api/lessons/:id
DELETE /api/lessons/:id
POST /api/lessons/:id/move { parent_id, position }
# Kaarten
GET /api/lessons/:id/cards
POST /api/lessons/:id/cards
PATCH /api/cards/:id
DELETE /api/cards/:id
POST /api/lessons/:id/cards/import (multipart: file)
GET /api/lessons/:id/cards/export (?include_descendants=true)
# Sessies
POST /api/sessions { lesson_id, max_cards?, shuffle?, direction? }
GET /api/sessions/active -- nog niet afgesloten sessie
GET /api/sessions/:id/next -- volgende kaart of {done:true}
POST /api/sessions/:id/attempts { card_id, direction, result, time_to_answer_ms }
POST /api/sessions/:id/end
POST /api/sessions/:id/abandon
# Statistieken
GET /api/stats/overview
GET /api/stats/lessons/:id
GET /api/stats/cards/:id
GET /api/stats/heatmap (?weeks=12)
```
### 5.4 Frontend routes
```
/ Dashboard
/practice/:lessonId Oefensessie
/practice/:lessonId/setup Pre-sessie instellingen
/practice/:lessonId/done Sessie-overzicht
/admin Lessenboom + (rechts) selectie
/admin/lessons/:id Kaartenbeheer + import/export
/stats Overzicht / heatmap
/stats/lessons/:id Les-detail
/stats/cards/:id Kaart-detail
/settings Dark mode toggle, max kaarten default, e.d.
```
### 5.5 State management (Zustand)
- `sessionStore` — actieve sessie + huidige kaart + wachtrij
- `lessonsStore` — boom-cache met optimistic updates
- `settingsStore` — UI prefs (dark mode, defaults)
- `statsStore` — gecachede stats per les/kaart
### 5.6 Animaties (Framer Motion)
- Card flip: `rotateY` 0→180 met `perspective` (300ms ease-out)
- Kaart-overgang: outgoing slide-left + fade, incoming slide-right + fade (200ms)
- Goed/Fout: korte border-glow puls (groen/rood, 400ms)
- Sessie-einde: confetti bij score ≥80% (`canvas-confetti`)
- Streak counter: count-up animatie
- Progress bar: tween op `width`
### 5.7 Sessie-engine — gedetailleerd
Bij `POST /api/sessions`:
1. Resolveer alle kaarten in de les + descendants.
2. Bouw per kaart een lijst van te oefenen "directions" (forward; backward indien de les `bidirectional`).
3. Filter: alleen items met `card_progress.next_due_at ≤ now`. Indien minder dan `max_cards`: vul aan met laagste-doos items, dan willekeurig.
4. Sorteer: doos 1 eerst, dan oplopend; shuffle binnen elke doos.
5. Trim tot `max_cards`.
6. Sla op als `queue_snapshot` JSON.
Bij elke `attempts` POST:
1. Update `card_progress`: bij correct → box+1 (max 5), `next_due_at` op interval; bij incorrect → box=1, `next_due_at` direct beschikbaar.
2. Update `attempts` rij.
3. Update sessie tellers.
4. Intra-sessie: bij incorrect, item terug in queue op positie `current_index + 3` (of einde indien minder posities resteren).
5. Sla nieuwe queue_snapshot op (voor hervatten).
Bij `GET /sessions/:id/next`: pop volgende uit queue snapshot of return `{done:true}`.
## 6. Foutafhandeling
- API: alle endpoints retourneren `{ error: { code, message, details? } }` bij fout. Codes: `VALIDATION_ERROR`, `NOT_FOUND`, `CONFLICT`, `INTERNAL`.
- Validatie via Zod, gedeelde schemas in `packages/shared`.
- Frontend: globale `<ErrorBoundary>` + toasts voor API-fouten.
- Import: rijen die falen worden gerapporteerd, geldige rijen worden geïmporteerd (transactie per rij, niet per file).
## 7. Test-strategie
- **Backend unit (Vitest):** Leitner-progressie, queue-bouwer, score-berekening, import-parser. Lessen/kaart CRUD via integratietest met in-memory SQLite.
- **Frontend unit (Vitest + RTL):** kaartcomponent, statistiek-componenten, store-gedrag.
- **E2E smoke (Playwright):** kaart aanmaken → sessie starten → goed/fout-flow → stats zichtbaar. Excel-import flow.
- **TDD voor sessie-engine en Leitner-logica** — dat is het hart van de app.
## 8. Deployment / scripts
```
# root package.json
"scripts": {
"dev": "concurrently -k -n be,fe \"npm:dev:be\" \"npm:dev:fe\"",
"dev:be": "npm -w backend run dev",
"dev:fe": "npm -w frontend run dev",
"build": "npm -w backend run build && npm -w frontend run build",
"start": "node packages/backend/dist/index.js",
"db:migrate": "npm -w backend run db:migrate",
"db:seed": "npm -w backend run db:seed"
}
```
In production: backend serveert ook de gebuilde frontend (statische assets) → één proces, één poort. Default `http://localhost:5173` (dev) / `http://localhost:3000` (prod).
## 9. Volgorde van implementatie (hoog over)
1. Monorepo + tooling + shared types
2. DB-schema + migraties + seed
3. Lessen CRUD (BE + FE admin)
4. Kaarten CRUD (BE + FE admin)
5. Excel import/export
6. Sessie-engine + Leitner (TDD)
7. Oefen-UI (kaart-flip, animaties)
8. Statistieken (BE aggregaties + FE pagina's)
9. Dashboard + streak + heatmap
10. Polish: dark mode, responsive, sessie-hervat, confetti
11. E2E smoke tests
Concrete taken volgen in het implementation plan.
## 10. Open punten (laag risico)
- Drag-drop in admin: gebruik `@dnd-kit/sortable`.
- Confetti library: `canvas-confetti` (klein, geen React deps).
- Of de admin een aparte route blijft of een tabblad binnen het dashboard — voorkeur: aparte route voor duidelijkheid.
- Excel-template downloadbaar in admin import-scherm.