13 KiB
Sub-project C — UX Extensions
Datum: 2026-05-21
Status: Draft, ready for review
Parent app: Flashcard webapplicatie
Voorgaande specs: 2026-05-20-auth-and-roles-design.md (A, opgeleverd), 2026-05-20-ownership-and-sharing-design.md (B, opgeleverd)
Scope: Derde en laatste van drie subprojecten in de multi-user uitbreiding (A→B→C).
1. Doel
De gebruikerservaring van de flashcard-app op een professioneel niveau brengen door vier samenhangende UX-uitbreidingen:
- Les-detailpagina — één rijke pagina die overzicht, statistiek, sublessen en kaarten samenbrengt
- App-brede search — een ⌘K-stijl command-palette die zowel lessen als kaarten doorzoekt
- Statistieken-pagina — heatmap + per-les voortgang + reviews-due overzicht
- Admin-lessen polish — drag-and-drop herordening + inline filter in de lessen-boom
Geen nieuwe data-modellen. Wel drie nieuwe backend endpoints (search, lessons-progress, due-overview).
2. Uitgangspunten
- Routes restructureren:
/admin→/lessons,/admin/lessons/:id→/lessons/:id. Oude routes worden client-side<Navigate replace>redirects. - Geen extra charts-library: heatmap en progressbars met Tailwind + simpele SVG.
- Drag-and-drop library:
@dnd-kit/core+@dnd-kit/sortable. - Search-strategie: SQLite
LIKE %q%zonder FTS5. Bovengrens: 30 lessen + 30 kaarten per query. Debounce 200ms in UI. - Sublessen op detail page: alleen directe children; diepere navigatie via doorklikken.
- Card preview op detail page: client-side limit 30; "Toon alle X" expandt.
3. Functionele eisen
3.1 Les-detailpagina (/lessons/:id)
Vervangt AdminLessonPage. Eén pagina, in vier secties + header.
Header
- Breadcrumb-pad (parents → huidige les)
- Titel + beschrijving inline-editable voor eigenaar (klik op tekst → input → blur saves)
- Badges: 🔒 Privé / 🌍 Gedeeld / ⭐ Curated / 📥 Geabonneerd
- Primary CTA: "Start oefenen →" (groot, gradient)
- Secundaire acties (rechts uitgelijnd):
- Eigenaar: Visibility-toggle, Curated-toggle (sysadmin op shared lesson), Importeer Excel, Exporteer Excel, Verwijder les
- Niet-eigenaar geabonneerd: Fork, Abonnement opzeggen
- Niet-eigenaar niet-geabonneerd (curated): Fork, Abonneer
Sectie 1: Stats summary — 4 horizontale stat-cards (compact):
- Kaarten — totaal recursief over subtree
- Beheerst — count(box≥4) / totaal, met percentage
- Score — gewogen gemiddelde van card scores
- Laatst geoefend — relatieve tijd (bv. "3 dagen geleden")
Sectie 2: Sublessen (verbergen indien leeg)
- Lijst met: naam, # kaarten (recursief), score%, klikbaar →
/lessons/:childId - Voor eigenaar: "+ Subles" knop opent inline form
Sectie 3: Kaarten
- Voor eigenaar: bestaande
CardTable(editable), default 30 zichtbaar, "Toon alle X" knop expandt - Voor niet-eigenaar: read-only weergave van dezelfde tabel
- Empty state: "Nog geen kaarten — voeg er hieronder een toe" (eigenaar) of "Deze les heeft nog geen kaarten" (niet-eigenaar)
Sectie 4: Recente sessies op deze les
- Laatste 5 voltooide sessies voor deze user op deze les
- Per rij: datum, score%, duur
3.2 App-brede search
Backend endpoint: GET /api/search?q=<term>&limit=30
- Authenticatie vereist
- Min query length: 2 chars; lege query →
{ lessons: [], cards: [] } - Doorzoekt:
- Lessen: alle waar
canReadLesson(currentUser, lesson)of (visibility='shared'en owner ≠ currentUser) — d.w.z. eigen library + marketplace - Kaarten: alleen kaarten in lessen die de user mag lezen (geen marketplace cards — die staan niet in de "library")
- Lessen: alle waar
- Match op
LIKE %q%(lowercase) oplessons.name,lessons.description,cards.question,cards.answer,cards.hint - Response:
{ lessons: Array<{ id: number; name: string; ownerDisplayName: string; location: 'library' | 'marketplace'; totalCards: number; isCurated: boolean; }>; cards: Array<{ id: number; lessonId: number; lessonName: string; question: string; snippet: string; // first 80 chars of question or answer with match }>; } - Sort: lessen
libraryeerst, danmarketplace; binnen elke groep alfabetisch - Kaarten: alfabetisch op lesson name + question
Frontend
- Trigger: ⌘K / Ctrl+K (global key listener) of klik op zoekicoon in header
- Modal: full-screen overlay met centered command-palette panel
- Input: autofocus, debounced 200ms request
- Sections: "Lessen — Jouw bibliotheek", "Lessen — Marketplace", "Kaarten"
- Keyboard nav: ↑/↓ door results, Enter selecteert, Esc sluit
- Click/Enter:
- Lesson result →
/lessons/:id - Card result →
/lessons/:lessonId#card-:id(anchor scrollt + flash highlight)
- Lesson result →
- Empty/error states: "Begin met typen om te zoeken", "Geen resultaten gevonden", error message
3.3 Statistieken-pagina (/stats)
Vervangt huidige StatsPage. Drie blokken in deze volgorde.
A. Activiteit-heatmap (12 maanden)
- Bron:
GET /api/stats/heatmap?weeks=52 - Component:
Heatmap - Layout: 53 kolommen × 7 rijen (per dag een cell). Kleur intensiteit op basis van
attempts:- 0 → gray-100
- 1–4 → success-200
- 5–14 → success-400
- 15–49 → success-500
- 50+ → success-700
- Maand-labels boven, weekday-labels links (Ma, Wo, Vr)
- Tooltip op hover: "X mei: Y pogingen, Z sessies"
B. Voortgang per les
- Backend:
GET /api/stats/lessons-progressAlleen root-lessen van de user's tree (lessen waar canRead + parent ofwel null of niet zichtbaar). Sublessen aggregeren in de root.{ rows: Array<{ lessonId: number; name: string; totalCards: number; masteredCards: number; // box >= 4 scorePct: number; // 0..100, computed like getLessonStats lastSessionAt: number | null; }>; } - Frontend: gesorteerde lijst (default: hoogste score eerst; sortable op naam/score/laatst).
Per rij:
- Lesnaam (link →
/lessons/:id) - Progressbar: mastered/total fractie, achtergrond brand-100, vulling success-500
- Score% rechts
- Laatst geoefend relative time
- Lesnaam (link →
C. Reviews-due overzicht
- Backend:
GET /api/stats/dueBerekend over alle{ overdue: number; // next_due_at < now() today: number; // due tussen now en eindigend at midnight UTC tomorrow: number; // morgen thisWeek: number; // 7 dagen incl. vandaag }card_progressrijen van de user op cards in lessen die hij kan lezen. - Frontend: 4 grote "badges" met aantallen en kleuren (overdue=red, today=brand, tomorrow=success, thisWeek=slate)
- CTA-knop onder: "Start review (X kaarten)" start een sessie op een speciale "due-only" mode. Voor v1 implementatie: navigate naar
/practice/__due__/setupmet een query?mode=duedie de session start filtert op enkel due cards over de hele library.- Dit vereist een uitbreiding:
POST /api/sessionsaccepteert{ lessonId: 'due', mode: 'due' }of een nieuwe alias-routePOST /api/sessions/duedie een sessie maakt over alle due cards van de user. - Eenvoudiger compromis: een nieuw endpoint
POST /api/sessions/duedat dit verzorgt. Spec kiest deze variant.
- Dit vereist een uitbreiding:
3.4 Admin polish — /lessons (lessen-tree)
Inline filter
- Tekstveld bovenin de tree (zelfde rij als "Nieuwe wortel-les...")
- Live filter (geen debounce nodig, alles client-side)
- Case-insensitive substring match op
name - Bij hit: het node + alle voorouders blijven zichtbaar
- Bij geen-hit: voorouders gedimd, geen-hit-nodes verborgen
- Bij geen filter: alles normaal
Drag-and-drop
- Library:
@dnd-kit/core+@dnd-kit/sortable - Alleen lessen die de user bezit zijn sleepbaar (
isOwner) - Drop targets: een andere positie binnen dezelfde parent (herordenen) of in een andere les (re-parent)
- Bij drop: client roept
POST /api/lessons/:id/moveaan met nieuweparentIdenposition - Visual feedback: drop-zone hint highlight, snap-back op invalid drop
- Keyboard support: dnd-kit's built-in (pijltoetsen + space om op te pakken)
- Restrictions enforced client-side: niet eigenaar → geen drag handle; restricties op backend (cycle check) blijven gelden
4. Datamodel
Geen wijzigingen.
5. API-overzicht
Nieuw
GET /api/search?q=<term>&limit=30 # globale zoek
GET /api/stats/lessons-progress # voortgang per root-les
GET /api/stats/due # due counts
POST /api/sessions/due # speciale sessie over alle due cards
Aangepast
GET /api/stats/heatmap # default weeks 12 → 52
6. Routes-restructure (frontend)
| Oud | Nieuw | Migratie |
|---|---|---|
/admin |
/lessons |
<Navigate to="/lessons" replace /> |
/admin/lessons/:id |
/lessons/:id |
<Navigate to="/lessons/${id}" replace /> |
/admin/users |
/admin/users |
(ongewijzigd; alleen sysadmin) |
Nav-link "Lessen" in Layout wijst naar /lessons.
7. UI-componenten
Nieuwe
| Bestand | Verantwoordelijkheid |
|---|---|
pages/LessonDetail.tsx |
Detail page met header + stats + sublessen + kaarten + recente sessies |
pages/Lessons.tsx |
Vervangt Admin.tsx; tree-pagina met filter + drag-drop |
components/SearchPalette.tsx |
Cmd-K modal met grouped results |
components/LessonStatsPanel.tsx |
4 mini stat-cards voor detail page header |
components/SublessonList.tsx |
Lijst sublessen met progress |
components/RecentSessionsList.tsx |
Laatste 5 sessies op les |
components/Heatmap.tsx |
12-maands grid |
components/LessonProgressList.tsx |
Sortable lijst met progressbars |
components/DueOverviewCard.tsx |
4-badge widget met "Start review" CTA |
api/search.ts |
Client voor /api/search |
Aanpassingen
| Bestand | Wat |
|---|---|
components/Layout.tsx |
+ Search icoon + Cmd-K listener; "Lessen" link → /lessons |
components/LessonTree.tsx |
+ filter prop, + drag-drop (dnd-kit) |
pages/Stats.tsx |
Volledig herschrijven naar drie nieuwe componenten |
router.tsx |
Routes restructuren + redirects |
services/sessions.ts |
+ startDueSession(userId) |
routes/sessions.ts |
+ POST /due |
services/stats.ts |
+ getLessonsProgress(userId), getDueOverview(userId) |
routes/stats.ts |
+ /lessons-progress, /due |
services/search.ts |
Nieuw service module |
routes/search.ts |
Nieuwe router |
app.ts |
Mount nieuwe routers |
Te verwijderen
pages/AdminLesson.tsx→ vervangen doorpages/LessonDetail.tsxpages/Admin.tsx→ vervangen doorpages/Lessons.tsx
8. Tests
Backend unit
search.test.ts: filter op canRead, marketplace inclusion, card snippet generation, empty query, special charsstats.test.ts(extend):getLessonsProgressper-root grouping,getDueOverviewbucket boundaries
Backend integration
/api/search: user A's private niet in user B's results; curated zichtbaar voor B; marketplace lessons; cards niet uit marketplace/api/stats/lessons-progress: alleen roots; correct aggregeren over subtree/api/stats/due: edge cases (geen kaarten, alles future, alles overdue)/api/sessions/due: start sessie met alleen due cards
Frontend unit (Vitest + RTL)
SearchPalette: opent op ⌘K, debouncing, keyboard navigation, click navigationLessonStatsPanel: rendering met null/zero waardenHeatmap: kleurintensiteit-buckets, tooltipLessonTree: filter-functie, drag-drop call op move
E2E (Playwright)
- Detail-page flow: open lesson, zie sublessen, start practice
- Search flow: ⌘K, type query, klik result → navigatie naar detail
- Stats page laadt en toont alle drie blokken
- Drag-drop reorder (smoke; kan flaky zijn — accept een minimum-versie)
9. Performance / a11y
- Search backend: response time-budget 200ms voor q<=10 cards/lessons (typische tree). Met
LIKE %q%zonder index is dit OK voor verwachte schaal (~1000 lessen, ~10000 kaarten). Indien problematisch later: FTS5 index. - Heatmap rendert als statisch SVG/grid (max 371 cells × kleine div). Geen virtualization nodig.
- Cmd-K modal: focus management (autofocus input, Tab cycle binnen modal, Esc sluit, return focus naar trigger)
- Drag-drop:
prefers-reduced-motionrespect via dnd-kit'sMeasuringStrategy.Always+ animated reorder kan uit - All interactive elements ARIA-labeled
10. Migratie
Geen DB-wijzigingen. Frontend route-redirects voor bookmarks naar oude URLs.
11. Out of scope (YAGNI)
- Card-level detail-pagina re-design (
/stats/cards/:idblijft staan) - Bulk-operaties op kaarten (multi-select, batch delete/move)
- Duplicate-lesson knop (fork dekt het)
- Charts-library (recharts, echarts) — voor 3 blokken niet nodig
- Search saved queries / search history
- Box-distributie chart, cumulative study curve, streak milestones (afgewezen tijdens brainstorm)
- FTS5 full-text search index
- Pagineren in search results boven de 30
- Editing van een card via search-modal (alleen navigatie)
- Bulk move bij drag-drop (één les per keer)
- Drag-drop reorder van cards binnen een les