Files
flashcards/docs/superpowers/specs/2026-05-21-ux-extensions-design.md

304 lines
13 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 `<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")
- 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
- 14 → success-200
- 514 → success-400
- 1549 → 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=<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 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