diff --git a/docs/superpowers/specs/2026-05-21-ux-extensions-design.md b/docs/superpowers/specs/2026-05-21-ux-extensions-design.md new file mode 100644 index 0000000..3e5e1a8 --- /dev/null +++ b/docs/superpowers/specs/2026-05-21-ux-extensions-design.md @@ -0,0 +1,303 @@ +# 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