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:
11
.gitignore
vendored
Normal file
11
.gitignore
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
node_modules/
|
||||
dist/
|
||||
build/
|
||||
.DS_Store
|
||||
.env
|
||||
.env.local
|
||||
data/*.db
|
||||
data/*.db-*
|
||||
coverage/
|
||||
.vite/
|
||||
*.log
|
||||
362
docs/superpowers/specs/2026-05-20-flashcard-app-design.md
Normal file
362
docs/superpowers/specs/2026-05-20-flashcard-app-design.md
Normal 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.
|
||||
Reference in New Issue
Block a user