Files
flashcards/docs/superpowers/specs/2026-05-20-flashcard-app-design.md
Bert Hausmans 35fd3d6adc 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>
2026-05-20 20:08:09 +02:00

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 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)

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.