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>
14 KiB
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
positionvoor handmatige sortering binnen hun parent. - Lessen hebben een
bidirectionalflag (defaultfalse): alstrueworden 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, optionelehint,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:
- Vraag wordt getoond (groot, gecentreerd).
- "Hint" knop indien aanwezig.
- "Toon antwoord" knop. Klik triggert een 3D card-flip animatie.
- Antwoord verschijnt; gelijktijdig verschijnen Goed (groen) en Fout (rood) knoppen.
- 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".
- Knop "Importeer Excel" → upload
- Excel export:
- Per les (inclusief sublessen optioneel) →
.xlsxmet dezelfde kolommen.
- Per les (inclusief sublessen optioneel) →
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)
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 + wachtrijlessonsStore— boom-cache met optimistic updatessettingsStore— UI prefs (dark mode, defaults)statsStore— gecachede stats per les/kaart
5.6 Animaties (Framer Motion)
- Card flip:
rotateY0→180 metperspective(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:
- Resolveer alle kaarten in de les + descendants.
- Bouw per kaart een lijst van te oefenen "directions" (forward; backward indien de les
bidirectional). - Filter: alleen items met
card_progress.next_due_at ≤ now. Indien minder danmax_cards: vul aan met laagste-doos items, dan willekeurig. - Sorteer: doos 1 eerst, dan oplopend; shuffle binnen elke doos.
- Trim tot
max_cards. - Sla op als
queue_snapshotJSON.
Bij elke attempts POST:
- Update
card_progress: bij correct → box+1 (max 5),next_due_atop interval; bij incorrect → box=1,next_due_atdirect beschikbaar. - Update
attemptsrij. - Update sessie tellers.
- Intra-sessie: bij incorrect, item terug in queue op positie
current_index + 3(of einde indien minder posities resteren). - 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)
- Monorepo + tooling + shared types
- DB-schema + migraties + seed
- Lessen CRUD (BE + FE admin)
- Kaarten CRUD (BE + FE admin)
- Excel import/export
- Sessie-engine + Leitner (TDD)
- Oefen-UI (kaart-flip, animaties)
- Statistieken (BE aggregaties + FE pagina's)
- Dashboard + streak + heatmap
- Polish: dark mode, responsive, sessie-hervat, confetti
- 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.