# 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: 1. **Les-detailpagina** — één rijke pagina die overzicht, statistiek, sublessen en kaarten samenbrengt 2. **App-brede search** — een ⌘K-stijl command-palette die zowel lessen als kaarten doorzoekt 3. **Statistieken-pagina** — heatmap + per-les voortgang + reviews-due overzicht 4. **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 `` 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=&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") - Match op `LIKE %q%` (lowercase) op `lessons.name`, `lessons.description`, `cards.question`, `cards.answer`, `cards.hint` - Response: ```ts { 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 `library` eerst, dan `marketplace`; 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) - **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-progress` ```ts { rows: Array<{ lessonId: number; name: string; totalCards: number; masteredCards: number; // box >= 4 scorePct: number; // 0..100, computed like getLessonStats lastSessionAt: number | null; }>; } ``` Alleen **root-lessen** van de user's tree (lessen waar canRead + parent ofwel null of niet zichtbaar). Sublessen aggregeren in de root. - 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 #### C. Reviews-due overzicht - Backend: `GET /api/stats/due` ```ts { 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 } ``` Berekend over alle `card_progress` rijen 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__/setup` met een query `?mode=due` die de session start filtert op enkel due cards over de hele library. - Dit vereist een uitbreiding: `POST /api/sessions` accepteert `{ lessonId: 'due', mode: 'due' }` of een nieuwe alias-route `POST /api/sessions/due` die een sessie maakt over **alle** due cards van de user. - **Eenvoudiger compromis**: een nieuw endpoint `POST /api/sessions/due` dat dit verzorgt. Spec kiest deze variant. ### 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/move` aan met nieuwe `parentId` en `position` - 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=&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` | `` | | `/admin/lessons/:id` | `/lessons/:id` | `` | | `/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 door `pages/LessonDetail.tsx` - `pages/Admin.tsx` → vervangen door `pages/Lessons.tsx` ## 8. Tests ### Backend unit - `search.test.ts`: filter op canRead, marketplace inclusion, card snippet generation, empty query, special chars - `stats.test.ts` (extend): `getLessonsProgress` per-root grouping, `getDueOverview` bucket 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 navigation - `LessonStatsPanel`: rendering met null/zero waarden - `Heatmap`: kleurintensiteit-buckets, tooltip - `LessonTree`: 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-motion` respect via dnd-kit's `MeasuringStrategy.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/:id` blijft 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