docs: spec for sub-project C — UX extensions
This commit is contained in:
303
docs/superpowers/specs/2026-05-21-ux-extensions-design.md
Normal file
303
docs/superpowers/specs/2026-05-21-ux-extensions-design.md
Normal file
@@ -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 `<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
|
||||
- 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=<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
|
||||
Reference in New Issue
Block a user