# 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 `` + 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.