diff --git a/docs/superpowers/plans/2026-05-21-ux-extensions.md b/docs/superpowers/plans/2026-05-21-ux-extensions.md new file mode 100644 index 0000000..8958cfc --- /dev/null +++ b/docs/superpowers/plans/2026-05-21-ux-extensions.md @@ -0,0 +1,2669 @@ +# UX Extensions Implementation Plan (Sub-project C) + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ship a rich lesson-detail page, global ⌘K search, a redesigned stats page (heatmap + per-lesson progress + reviews-due), and admin polish (inline tree filter + drag-and-drop reordering). + +**Architecture:** Three new backend endpoints (`/api/search`, `/api/stats/lessons-progress`, `/api/stats/due`) plus one new session helper (`POST /api/sessions/due`). The frontend gains a `SearchPalette` (⌘K modal), a new `LessonDetail` page that replaces the current `AdminLesson`, a rewritten `Stats` page with three composable widgets, and a `LessonTree` extended with client-side filter + `@dnd-kit/sortable` drag-and-drop. Routes `/admin` → `/lessons` and `/admin/lessons/:id` → `/lessons/:id` with `` redirects for backwards compatibility. + +**Tech Stack:** Express + Drizzle (SQLite `LIKE %q%`), Zod, React + Zustand, `@dnd-kit/core` + `@dnd-kit/sortable`, Vitest + supertest, Playwright + Mailpit. + +**Spec:** `docs/superpowers/specs/2026-05-21-ux-extensions-design.md` + +**Pre-implementation:** No schema changes. Reuses sub-projects A (auth) and B (ownership/sharing). Permission check via `canReadLesson` from `services/permissions.ts`. + +--- + +## File Structure + +``` +flashcard/ +├── packages/ +│ ├── backend/src/ +│ │ ├── services/ +│ │ │ ├── search.ts NEW +│ │ │ ├── search.test.ts NEW +│ │ │ ├── stats.ts MODIFIED (+ getLessonsProgress, getDueOverview) +│ │ │ ├── stats.test.ts MODIFIED +│ │ │ └── sessions.ts MODIFIED (+ startDueSession) +│ │ ├── routes/ +│ │ │ ├── search.ts NEW +│ │ │ ├── stats.ts MODIFIED +│ │ │ └── sessions.ts MODIFIED +│ │ ├── tests/ +│ │ │ └── ux.integration.test.ts NEW +│ │ └── app.ts MODIFIED (mount search router) +│ └── frontend/src/ +│ ├── api/ +│ │ ├── search.ts NEW +│ │ ├── stats.ts MODIFIED (+ lessonsProgress, due) +│ │ └── sessions.ts MODIFIED (+ startDue) +│ ├── components/ +│ │ ├── SearchPalette.tsx NEW +│ │ ├── Heatmap.tsx NEW +│ │ ├── LessonProgressList.tsx NEW +│ │ ├── DueOverviewCard.tsx NEW +│ │ ├── LessonStatsPanel.tsx NEW +│ │ ├── SublessonList.tsx NEW +│ │ ├── RecentSessionsList.tsx NEW +│ │ ├── LessonTree.tsx MODIFIED (filter + dnd) +│ │ └── Layout.tsx MODIFIED (search trigger + ⌘K listener) +│ ├── pages/ +│ │ ├── Lessons.tsx NEW (replaces Admin.tsx) +│ │ ├── LessonDetail.tsx NEW (replaces AdminLesson.tsx) +│ │ └── Stats.tsx REWRITTEN +│ └── router.tsx MODIFIED (route restructure + redirects) +└── e2e/ + └── ux.spec.ts NEW (search + detail + stats smoke) +``` + +--- + +## Task 1: Backend — Search service (TDD) + +**Files:** +- Create: `packages/backend/src/services/search.ts` +- Create: `packages/backend/src/services/search.test.ts` + +- [ ] **Step 1: Write failing tests** + +```ts +import { describe, it, expect, beforeEach } from 'vitest'; +import { makeTestDb, createUserDirect, createLessonOwnedBy } from '../tests/dbHelper.js'; +import { createCard } from './cards.js'; +import { searchAll } from './search.js'; + +let env: ReturnType; +beforeEach(() => { env = makeTestDb(); }); + +describe('search', () => { + it('returns empty for short queries', async () => { + const u = await createUserDirect(env.db, { email: 'u@example.com' }); + const r = await searchAll(env.db, u.id, '', 30); + expect(r.lessons).toHaveLength(0); + expect(r.cards).toHaveLength(0); + }); + + it('finds own lessons by name', async () => { + const u = await createUserDirect(env.db, { email: 'u@example.com', displayName: 'Me' }); + await createLessonOwnedBy(env.db, u.id, { name: 'Spaans basis' }); + const r = await searchAll(env.db, u.id, 'spaans', 30); + expect(r.lessons).toHaveLength(1); + expect(r.lessons[0]!.location).toBe('library'); + }); + + it('hides private lessons of other users', async () => { + const o = await createUserDirect(env.db, { email: 'o@example.com' }); + const u = await createUserDirect(env.db, { email: 'u@example.com' }); + await createLessonOwnedBy(env.db, o.id, { name: 'Geheim', visibility: 'private' }); + const r = await searchAll(env.db, u.id, 'geheim', 30); + expect(r.lessons).toHaveLength(0); + }); + + it('marks shared lessons from others as marketplace', async () => { + const o = await createUserDirect(env.db, { email: 'o@example.com', displayName: 'Owner' }); + const u = await createUserDirect(env.db, { email: 'u@example.com' }); + await createLessonOwnedBy(env.db, o.id, { name: 'Public', visibility: 'shared' }); + const r = await searchAll(env.db, u.id, 'public', 30); + expect(r.lessons).toHaveLength(1); + expect(r.lessons[0]!.location).toBe('marketplace'); + expect(r.lessons[0]!.ownerDisplayName).toBe('Owner'); + }); + + it('finds cards in readable lessons only', async () => { + const o = await createUserDirect(env.db, { email: 'o@example.com' }); + const u = await createUserDirect(env.db, { email: 'u@example.com' }); + const own = await createLessonOwnedBy(env.db, u.id, { name: 'Mine' }); + const other = await createLessonOwnedBy(env.db, o.id, { name: 'Hers', visibility: 'private' }); + await createCard(env.db, u.id, own.id, { question: 'hola', answer: 'hello' }); + await createCard(env.db, o.id, other.id, { question: 'hola', answer: 'hello' }); + const r = await searchAll(env.db, u.id, 'hola', 30); + expect(r.cards).toHaveLength(1); + expect(r.cards[0]!.lessonId).toBe(own.id); + }); + + it('search is case-insensitive', async () => { + const u = await createUserDirect(env.db, { email: 'u@example.com' }); + await createLessonOwnedBy(env.db, u.id, { name: 'Frans' }); + const r = await searchAll(env.db, u.id, 'FRA', 30); + expect(r.lessons).toHaveLength(1); + }); + + it('respects limit on each group', async () => { + const u = await createUserDirect(env.db, { email: 'u@example.com' }); + for (let i = 0; i < 5; i++) { + await createLessonOwnedBy(env.db, u.id, { name: `lesson${i}` }); + } + const r = await searchAll(env.db, u.id, 'lesson', 2); + expect(r.lessons).toHaveLength(2); + }); +}); +``` + +- [ ] **Step 2: Run — fail** + +```bash +cd /Users/berthausmans/Documents/Development/flashcard +npm -w @flashcard/backend test -- search +``` + +Expected: module `./search.js` not found. + +- [ ] **Step 3: Implement `services/search.ts`** + +```ts +import { and, eq, inArray, like, ne, or, sql } from 'drizzle-orm'; +import type { Db } from '../db/client.js'; +import { cards, lessons, lessonSubscriptions, users } from '../db/schema.js'; + +export interface SearchLessonResult { + id: number; + name: string; + ownerDisplayName: string; + location: 'library' | 'marketplace'; + totalCards: number; + isCurated: boolean; +} + +export interface SearchCardResult { + id: number; + lessonId: number; + lessonName: string; + question: string; + snippet: string; +} + +export interface SearchResult { + lessons: SearchLessonResult[]; + cards: SearchCardResult[]; +} + +const MIN_QUERY_LEN = 2; +const SNIPPET_LEN = 80; + +function snippet(text: string, term: string): string { + const lower = text.toLowerCase(); + const idx = lower.indexOf(term.toLowerCase()); + if (idx < 0) return text.slice(0, SNIPPET_LEN); + const start = Math.max(0, idx - 20); + const end = Math.min(text.length, start + SNIPPET_LEN); + return (start > 0 ? '…' : '') + text.slice(start, end) + (end < text.length ? '…' : ''); +} + +export async function searchAll( + db: Db, userId: number, q: string, limit: number +): Promise { + const term = q.trim(); + if (term.length < MIN_QUERY_LEN) return { lessons: [], cards: [] }; + + const pattern = `%${term.toLowerCase()}%`; + + // Library lessons: owned + subscribed-or-descendant + curated-or-descendant + const ownerLessons = db.select().from(lessons).where(eq(lessons.ownerId, userId)).all(); + const subRoots = db.select({ + id: lessons.id, parentId: lessons.parentId, name: lessons.name, + description: lessons.description, position: lessons.position, + bidirectional: lessons.bidirectional, ownerId: lessons.ownerId, + visibility: lessons.visibility, isCurated: lessons.isCurated, + sourceLessonId: lessons.sourceLessonId, createdAt: lessons.createdAt, updatedAt: lessons.updatedAt, + }).from(lessons) + .innerJoin(lessonSubscriptions, eq(lessonSubscriptions.lessonId, lessons.id)) + .where(eq(lessonSubscriptions.userId, userId)) + .all(); + + // Gather readable lesson IDs (owned + descendants of subRoots + descendants of curated) + const allLessons = db.select().from(lessons).all(); + const byId = new Map(allLessons.map((l) => [l.id, l])); + const byParent = new Map(); + for (const l of allLessons) { + const k = l.parentId ?? null; + if (!byParent.has(k)) byParent.set(k, []); + byParent.get(k)!.push(l); + } + const readableIds = new Set(); + for (const l of ownerLessons) readableIds.add(l.id); + const stack: number[] = []; + for (const sr of subRoots) stack.push(sr.id); + for (const l of allLessons) if (l.visibility === 'shared' && l.isCurated) stack.push(l.id); + while (stack.length) { + const cur = stack.pop()!; + if (readableIds.has(cur)) continue; + readableIds.add(cur); + for (const c of byParent.get(cur) ?? []) stack.push(c.id); + } + + // Matching library lessons (readable + name/desc match) + const libraryMatches = allLessons.filter((l) => + readableIds.has(l.id) && ( + l.name.toLowerCase().includes(term.toLowerCase()) + || (l.description ?? '').toLowerCase().includes(term.toLowerCase()) + ) + ); + + // Marketplace matches: visibility=shared AND ownerId != userId AND parent not shared (root) AND match + const sharedIds = new Set(allLessons.filter((l) => l.visibility === 'shared').map((l) => l.id)); + const marketplaceMatches = allLessons.filter((l) => + l.visibility === 'shared' + && l.ownerId !== userId + && !readableIds.has(l.id) // hide what's already in library + && (l.parentId === null || !sharedIds.has(l.parentId)) + && ( + l.name.toLowerCase().includes(term.toLowerCase()) + || (l.description ?? '').toLowerCase().includes(term.toLowerCase()) + ) + ); + + // Owner display names (single batch) + const ownerIds = Array.from(new Set([...libraryMatches, ...marketplaceMatches] + .map((l) => l.ownerId).filter((id): id is number => id !== null && id !== undefined))); + const ownerRows = ownerIds.length === 0 ? [] : + db.select({ id: users.id, displayName: users.displayName }).from(users) + .where(inArray(users.id, ownerIds)).all(); + const ownerMap = new Map(ownerRows.map((u) => [u.id, u.displayName])); + + // Card counts (cheap: count cards.lessonId in batch over relevant lesson trees) + // For simplicity in this v1 search, count direct cards only (not subtree). + // Subtree counts are available on detail pages. + const matchingLessonIds = [...libraryMatches, ...marketplaceMatches].map((l) => l.id); + const cardCounts = matchingLessonIds.length === 0 ? [] : + db.select({ lessonId: cards.lessonId, c: sql`count(*)`.as('c') }).from(cards) + .where(inArray(cards.lessonId, matchingLessonIds)) + .groupBy(cards.lessonId).all(); + const cardCountByLesson = new Map(cardCounts.map((r) => [r.lessonId, Number(r.c)])); + + const lessonResults: SearchLessonResult[] = [ + ...libraryMatches.map((l): SearchLessonResult => ({ + id: l.id, name: l.name, + ownerDisplayName: ownerMap.get(l.ownerId ?? -1) ?? '—', + location: 'library', + totalCards: cardCountByLesson.get(l.id) ?? 0, + isCurated: l.isCurated, + })), + ...marketplaceMatches.map((l): SearchLessonResult => ({ + id: l.id, name: l.name, + ownerDisplayName: ownerMap.get(l.ownerId ?? -1) ?? '—', + location: 'marketplace', + totalCards: cardCountByLesson.get(l.id) ?? 0, + isCurated: l.isCurated, + })), + ]; + lessonResults.sort((a, b) => + a.location === b.location ? a.name.localeCompare(b.name) : a.location === 'library' ? -1 : 1 + ); + + // Cards: only in readable lessons (no marketplace) + const readableIdsArr = Array.from(readableIds); + const cardMatches = readableIdsArr.length === 0 ? [] : + db.select({ + id: cards.id, lessonId: cards.lessonId, question: cards.question, + answer: cards.answer, hint: cards.hint, lessonName: lessons.name, + }).from(cards) + .innerJoin(lessons, eq(lessons.id, cards.lessonId)) + .where(and( + inArray(cards.lessonId, readableIdsArr), + or( + like(sql`lower(${cards.question})`, pattern), + like(sql`lower(${cards.answer})`, pattern), + like(sql`lower(${cards.hint})`, pattern), + )! + )) + .all(); + + const cardResults: SearchCardResult[] = cardMatches.map((c) => { + const matched = c.question.toLowerCase().includes(term.toLowerCase()) + ? c.question + : c.answer.toLowerCase().includes(term.toLowerCase()) + ? c.answer + : (c.hint ?? c.question); + return { + id: c.id, lessonId: c.lessonId, lessonName: c.lessonName, + question: c.question, snippet: snippet(matched, term), + }; + }); + cardResults.sort((a, b) => a.lessonName.localeCompare(b.lessonName) || a.question.localeCompare(b.question)); + + return { + lessons: lessonResults.slice(0, limit), + cards: cardResults.slice(0, limit), + }; +} +``` + +- [ ] **Step 4: Run — pass** + +```bash +npm -w @flashcard/backend test -- search +``` + +Expected: 7 tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add packages/backend/src/services/search.ts packages/backend/src/services/search.test.ts +git -c commit.gpgsign=false -c user.email=bert@hausmans.nl -c user.name="Bert Hausmans" commit -m "feat(search): global search service with library/marketplace + cards" +``` + +--- + +## Task 2: Backend — Search route + integration test + +**Files:** +- Create: `packages/backend/src/routes/search.ts` +- Modify: `packages/backend/src/app.ts` (mount router) + +- [ ] **Step 1: Create `routes/search.ts`** + +```ts +import { Router } from 'express'; +import type { Db } from '../db/client.js'; +import { searchAll } from '../services/search.js'; + +export function searchRouter(db: Db): Router { + const r = Router(); + + r.get('/', async (req, res, next) => { + try { + const q = typeof req.query.q === 'string' ? req.query.q : ''; + const limit = Math.min(100, Math.max(1, Number(req.query.limit ?? 30))); + res.json(await searchAll(db, req.user!.id, q, limit)); + } catch (e) { next(e); } + }); + + return r; +} +``` + +- [ ] **Step 2: Mount in `app.ts`** + +Read `packages/backend/src/app.ts`. Add the import near the top with the other route imports: + +```ts +import { searchRouter } from './routes/search.js'; +``` + +After the `app.use('/api/marketplace', ...)` line, add: + +```ts +app.use('/api/search', requireAuth, searchRouter(db)); +``` + +(No verifyCsrf needed — GET only.) + +- [ ] **Step 3: Typecheck** + +```bash +cd /Users/berthausmans/Documents/Development/flashcard +npm -w @flashcard/backend run typecheck +``` + +Must pass. + +- [ ] **Step 4: Commit** + +```bash +git add packages/backend/src/routes/search.ts packages/backend/src/app.ts +git -c commit.gpgsign=false -c user.email=bert@hausmans.nl -c user.name="Bert Hausmans" commit -m "feat(search): /api/search route" +``` + +--- + +## Task 3: Backend — Stats `lessons-progress` + `due` services (TDD) + +**Files:** +- Modify: `packages/backend/src/services/stats.ts` +- Modify: `packages/backend/src/services/stats.test.ts` + +- [ ] **Step 1: Write failing tests** (append to `stats.test.ts`) + +```ts +import { getLessonsProgress, getDueOverview } from './stats.js'; +import { cardProgress } from '../db/schema.js'; + +describe('lessonsProgress', () => { + it('returns root lessons of the user with mastered + total counts', async () => { + const u = await createUserDirect(env.db, { email: 'u@example.com' }); + const root = await createLesson(env.db, u.id, { name: 'Root' }); + const child = await createLesson(env.db, u.id, { name: 'Child', parentId: root.id }); + const c1 = await createCard(env.db, u.id, root.id, { question: 'q1', answer: 'a' }); + const c2 = await createCard(env.db, u.id, child.id, { question: 'q2', answer: 'a' }); + + // Make c1 mastered (box 4) by inserting progress directly + env.db.insert(cardProgress).values({ + cardId: c1.id, direction: 'forward', userId: u.id, box: 4, nextDueAt: 0, + }).run(); + + const r = await getLessonsProgress(env.db, u.id); + expect(r.rows).toHaveLength(1); + expect(r.rows[0]!.lessonId).toBe(root.id); + expect(r.rows[0]!.totalCards).toBe(2); + expect(r.rows[0]!.masteredCards).toBe(1); + }); + + it('excludes subscribed roots (only owned)', async () => { + const o = await createUserDirect(env.db, { email: 'o@example.com' }); + const u = await createUserDirect(env.db, { email: 'u@example.com' }); + await createLesson(env.db, u.id, { name: 'Own' }); + await createLessonOwnedBy(env.db, o.id, { name: 'Theirs', visibility: 'shared' }); + const r = await getLessonsProgress(env.db, u.id); + expect(r.rows.map((x) => x.name)).toEqual(['Own']); + }); +}); + +describe('dueOverview', () => { + it('counts cards into overdue/today/tomorrow/thisWeek buckets', async () => { + const u = await createUserDirect(env.db, { email: 'u@example.com' }); + const l = await createLesson(env.db, u.id, { name: 'L' }); + const c1 = await createCard(env.db, u.id, l.id, { question: 'q1', answer: 'a' }); + const c2 = await createCard(env.db, u.id, l.id, { question: 'q2', answer: 'a' }); + const c3 = await createCard(env.db, u.id, l.id, { question: 'q3', answer: 'a' }); + + const now = Math.floor(Date.now() / 1000); + const day = 24 * 60 * 60; + // c1 overdue, c2 today (+1hr), c3 tomorrow + env.db.insert(cardProgress).values([ + { cardId: c1.id, direction: 'forward', userId: u.id, box: 1, nextDueAt: now - 100 }, + { cardId: c2.id, direction: 'forward', userId: u.id, box: 1, nextDueAt: now + 3600 }, + { cardId: c3.id, direction: 'forward', userId: u.id, box: 1, nextDueAt: now + day + 3600 }, + ]).run(); + + const r = await getDueOverview(env.db, u.id); + expect(r.overdue).toBe(1); + expect(r.today).toBeGreaterThanOrEqual(1); + expect(r.thisWeek).toBeGreaterThanOrEqual(3); + }); + + it('ignores progress on cards user cannot read', async () => { + const o = await createUserDirect(env.db, { email: 'o@example.com' }); + const u = await createUserDirect(env.db, { email: 'u@example.com' }); + const l = await createLessonOwnedBy(env.db, o.id, { name: 'Theirs', visibility: 'private' }); + const card = await createCard(env.db, o.id, l.id, { question: 'q', answer: 'a' }); + env.db.insert(cardProgress).values({ + cardId: card.id, direction: 'forward', userId: u.id, box: 1, nextDueAt: 0, + }).run(); + const r = await getDueOverview(env.db, u.id); + expect(r.overdue).toBe(0); + }); +}); +``` + +Make sure `createLesson` is imported at the top of the test file (`from './lessons.js'`). If not yet there from existing imports, add it. + +- [ ] **Step 2: Run — fail** + +```bash +npm -w @flashcard/backend test -- stats +``` + +Expected: `getLessonsProgress`/`getDueOverview` not exported. + +- [ ] **Step 3: Implement in `services/stats.ts`** + +Append at the end of `services/stats.ts`: + +```ts +export interface LessonsProgressRow { + lessonId: number; + name: string; + totalCards: number; + masteredCards: number; + scorePct: number; + lastSessionAt: number | null; +} +export interface LessonsProgressResult { rows: LessonsProgressRow[]; } + +export async function getLessonsProgress(db: Db, userId: number): Promise { + // Owner roots only (lessons where ownerId = userId AND parentId IS NULL OR parent not owned by user) + const ownLessons = db.select().from(lessons).where(eq(lessons.ownerId, userId)).all(); + const ownIds = new Set(ownLessons.map((l) => l.id)); + const roots = ownLessons.filter((l) => l.parentId === null || !ownIds.has(l.parentId)); + + const rows: LessonsProgressRow[] = []; + for (const root of roots) { + const ids = await getDescendantLessonIds(db, root.id); + const cardRows = db.select({ id: cards.id }).from(cards).where(inArray(cards.lessonId, ids)).all(); + const cardIds = cardRows.map((c) => c.id); + let totalCards = cardIds.length; + let masteredCards = 0; + let scoreSum = 0; + let scoreCount = 0; + if (cardIds.length > 0) { + const prog = db.select().from(cardProgress).where(and( + inArray(cardProgress.cardId, cardIds), + eq(cardProgress.userId, userId), + )).all(); + const byCard = new Map(); + for (const p of prog) { + if (!byCard.has(p.cardId)) byCard.set(p.cardId, []); + byCard.get(p.cardId)!.push(p); + } + for (const id of cardIds) { + const ps = byCard.get(id) ?? []; + if (ps.some((p) => p.box >= 4)) masteredCards += 1; + const total = ps.reduce((s, p) => s + p.correctCount + p.incorrectCount, 0); + const correct = ps.reduce((s, p) => s + p.correctCount, 0); + if (total >= 3) { scoreSum += correct / total; scoreCount += 1; } + } + } + const scorePct = scoreCount === 0 ? 0 : Math.round((scoreSum / scoreCount) * 100); + + const lastSess = db.select({ startedAt: sessions.startedAt }).from(sessions) + .where(and( + inArray(sessions.lessonId, ids), + eq(sessions.userId, userId), + eq(sessions.status, 'completed'), + )) + .orderBy(desc(sessions.startedAt)).limit(1).get(); + + rows.push({ + lessonId: root.id, + name: root.name, + totalCards, + masteredCards, + scorePct, + lastSessionAt: lastSess?.startedAt ?? null, + }); + } + + rows.sort((a, b) => b.scorePct - a.scorePct || a.name.localeCompare(b.name)); + return { rows }; +} + +export interface DueOverview { + overdue: number; + today: number; + tomorrow: number; + thisWeek: number; +} + +export async function getDueOverview(db: Db, userId: number): Promise { + // Only count progress on cards in lessons the user can read. + // For simplicity and correctness, we restrict to cards in user-owned lessons. + // (Progress rows for other users' cards may exist if user practiced subscribed/curated content; + // those rows have user_id = userId but the card's lesson is not owned. We still want to count + // them if user still has read access.) + // Strategy: take all card_progress rows for this user, then filter to those whose card's lesson + // is still readable. Cheaper here than a recursive ancestor walk per row: gather readable lesson + // IDs once, then filter in JS. + + const ownerLessons = db.select({ id: lessons.id }).from(lessons).where(eq(lessons.ownerId, userId)).all(); + const subRoots = db.select({ id: lessonSubscriptions.lessonId }).from(lessonSubscriptions) + .where(eq(lessonSubscriptions.userId, userId)).all(); + const curatedRoots = db.select({ id: lessons.id }).from(lessons) + .where(and(eq(lessons.visibility, 'shared'), eq(lessons.isCurated, true))).all(); + + const allLessons = db.select({ id: lessons.id, parentId: lessons.parentId }).from(lessons).all(); + const byParent = new Map(); + for (const l of allLessons) { + const k = l.parentId ?? null; + if (!byParent.has(k)) byParent.set(k, []); + byParent.get(k)!.push(l); + } + const readableIds = new Set(); + for (const l of ownerLessons) readableIds.add(l.id); + const stack: number[] = []; + for (const sr of subRoots) stack.push(sr.id); + for (const cr of curatedRoots) stack.push(cr.id); + while (stack.length) { + const cur = stack.pop()!; + if (readableIds.has(cur)) continue; + readableIds.add(cur); + for (const c of byParent.get(cur) ?? []) stack.push(c.id); + } + + if (readableIds.size === 0) return { overdue: 0, today: 0, tomorrow: 0, thisWeek: 0 }; + + const cardRows = db.select({ id: cards.id }).from(cards) + .where(inArray(cards.lessonId, Array.from(readableIds))).all(); + const cardIds = cardRows.map((r) => r.id); + if (cardIds.length === 0) return { overdue: 0, today: 0, tomorrow: 0, thisWeek: 0 }; + + const progress = db.select({ nextDueAt: cardProgress.nextDueAt }).from(cardProgress) + .where(and(eq(cardProgress.userId, userId), inArray(cardProgress.cardId, cardIds))) + .all(); + + const now = Math.floor(Date.now() / 1000); + const dayInSec = 24 * 60 * 60; + const endOfToday = now + dayInSec; + const endOfTomorrow = now + 2 * dayInSec; + const endOfWeek = now + 7 * dayInSec; + + let overdue = 0, today = 0, tomorrow = 0, thisWeek = 0; + for (const p of progress) { + if (p.nextDueAt < now) { overdue += 1; thisWeek += 1; continue; } + if (p.nextDueAt < endOfToday) { today += 1; thisWeek += 1; continue; } + if (p.nextDueAt < endOfTomorrow) { tomorrow += 1; thisWeek += 1; continue; } + if (p.nextDueAt < endOfWeek) { thisWeek += 1; } + } + return { overdue, today, tomorrow, thisWeek }; +} +``` + +The imports needed at the top of `stats.ts` are `cards`, `cardProgress`, `lessons`, `lessonSubscriptions`, `sessions` from `../db/schema.js`, plus `and`, `eq`, `inArray`, `desc` from `drizzle-orm`. Verify they're already present; add any that are missing. + +- [ ] **Step 4: Run — pass** + +```bash +npm -w @flashcard/backend test -- stats +``` + +Expected: previous stats tests + 4 new tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add packages/backend/src/services/stats.ts packages/backend/src/services/stats.test.ts +git -c commit.gpgsign=false -c user.email=bert@hausmans.nl -c user.name="Bert Hausmans" commit -m "feat(stats): lessons-progress and due-overview services" +``` + +--- + +## Task 4: Backend — Sessions `startDueSession` + route (TDD) + +**Files:** +- Modify: `packages/backend/src/services/sessions.ts` +- Modify: `packages/backend/src/services/sessions.test.ts` +- Modify: `packages/backend/src/routes/sessions.ts` + +- [ ] **Step 1: Append test in `sessions.test.ts`** + +```ts +import { startDueSession } from './sessions.js'; + +describe('startDueSession', () => { + it('builds a session over all due cards of readable lessons', async () => { + const u = await createUserDirect(env.db, { email: 'u@example.com' }); + const l = await createLesson(env.db, u.id, { name: 'L' }); + const c1 = await createCard(env.db, u.id, l.id, { question: 'q1', answer: 'a' }); + const c2 = await createCard(env.db, u.id, l.id, { question: 'q2', answer: 'a' }); + const c3 = await createCard(env.db, u.id, l.id, { question: 'q3', answer: 'a' }); + + const now = Math.floor(Date.now() / 1000); + env.db.insert(cardProgress).values([ + { cardId: c1.id, direction: 'forward', userId: u.id, box: 1, nextDueAt: 0 }, // due + { cardId: c2.id, direction: 'forward', userId: u.id, box: 1, nextDueAt: now - 1 }, // due + { cardId: c3.id, direction: 'forward', userId: u.id, box: 1, nextDueAt: now + 86400 }, // not due + ]).run(); + + const s = await startDueSession(env.db, u.id); + expect(s.queue).toHaveLength(2); + expect(s.queue.map((q) => q.cardId).sort()).toEqual([c1.id, c2.id].sort()); + }); + + it('returns empty queue when nothing is due', async () => { + const u = await createUserDirect(env.db, { email: 'u@example.com' }); + const s = await startDueSession(env.db, u.id); + expect(s.queue).toHaveLength(0); + }); +}); +``` + +Add to existing imports in `sessions.test.ts`: `cardProgress` from schema if not present. + +- [ ] **Step 2: Run — fail** + +```bash +npm -w @flashcard/backend test -- sessions +``` + +Expected: `startDueSession` not exported. + +- [ ] **Step 3: Append to `services/sessions.ts`** + +```ts +export async function startDueSession(db: Db, userId: number): Promise { + // Find all due card_progress rows for this user where the card's lesson is readable. + const now = nowSec(); + + // Same readable-lessons gathering pattern as getDueOverview. + const ownerLessons = db.select({ id: lessons.id }).from(lessons).where(eq(lessons.ownerId, userId)).all(); + const subRoots = db.select({ id: lessonSubscriptions.lessonId }).from(lessonSubscriptions) + .where(eq(lessonSubscriptions.userId, userId)).all(); + const curatedRoots = db.select({ id: lessons.id }).from(lessons) + .where(and(eq(lessons.visibility, 'shared'), eq(lessons.isCurated, true))).all(); + + const allLessons = db.select({ id: lessons.id, parentId: lessons.parentId }).from(lessons).all(); + const byParent = new Map(); + for (const l of allLessons) { + const k = l.parentId ?? null; + if (!byParent.has(k)) byParent.set(k, []); + byParent.get(k)!.push(l); + } + const readableIds = new Set(); + for (const l of ownerLessons) readableIds.add(l.id); + const stack: number[] = []; + for (const r of subRoots) stack.push(r.id); + for (const r of curatedRoots) stack.push(r.id); + while (stack.length) { + const cur = stack.pop()!; + if (readableIds.has(cur)) continue; + readableIds.add(cur); + for (const c of byParent.get(cur) ?? []) stack.push(c.id); + } + + if (readableIds.size === 0) { + return await createEmptyDueSession(db, userId); + } + + const cardRows = db.select({ id: cards.id, lessonId: cards.lessonId }).from(cards) + .where(inArray(cards.lessonId, Array.from(readableIds))).all(); + const cardIds = cardRows.map((r) => r.id); + if (cardIds.length === 0) { + return await createEmptyDueSession(db, userId); + } + + const due = db.select({ cardId: cardProgress.cardId, direction: cardProgress.direction, box: cardProgress.box }) + .from(cardProgress) + .where(and( + eq(cardProgress.userId, userId), + inArray(cardProgress.cardId, cardIds), + sql`${cardProgress.nextDueAt} <= ${now}`, + )) + .all(); + + // Sort by box ascending (lower box first), then shuffle within box + due.sort((a, b) => a.box - b.box); + const queue: QueueItem[] = due.map((d) => ({ cardId: d.cardId, direction: d.direction })); + + // Persist a virtual session; pick the first card's lesson as the session lesson for stats joining. + // If no cards: choose the user's first owned lesson; else fall back to any readable lesson. + const sessionLessonId = cardRows[0]?.lessonId ?? ownerLessons[0]?.id ?? Array.from(readableIds)[0]!; + const [row] = db.insert(sessions).values({ + lessonId: sessionLessonId, + userId, + queueSnapshot: JSON.stringify({ remaining: queue, index: 0 }), + }).returning().all(); + return { session: rowToSession(row!), queue }; +} + +async function createEmptyDueSession(db: Db, userId: number): Promise { + const anyLesson = db.select({ id: lessons.id }).from(lessons).where(eq(lessons.ownerId, userId)).get() + ?? db.select({ id: lessons.id }).from(lessons).get(); + if (!anyLesson) { + throw new ApiError(409, 'NO_LESSONS', 'No lessons exist for this user yet'); + } + const [row] = db.insert(sessions).values({ + lessonId: anyLesson.id, + userId, + queueSnapshot: JSON.stringify({ remaining: [], index: 0 }), + }).returning().all(); + return { session: rowToSession(row!), queue: [] }; +} +``` + +Make sure `lessonSubscriptions` is imported at the top of `sessions.ts` (alongside the other schema imports). Add the import if missing. + +- [ ] **Step 4: Run — pass** + +```bash +npm -w @flashcard/backend test -- sessions +``` + +Expected: 2 new tests pass + existing. + +- [ ] **Step 5: Add route in `routes/sessions.ts`** + +In the existing `sessionsRouter` (`packages/backend/src/routes/sessions.ts`), after the `r.post('/', ...)` handler, add: + +```ts +import { startDueSession } from '../services/sessions.js'; + +r.post('/due', async (req, res, next) => { + try { + res.status(201).json(await startDueSession(db, req.user!.id)); + } catch (e) { next(e); } +}); +``` + +(The `startDueSession` import goes alongside the existing import group at the top.) + +- [ ] **Step 6: Commit** + +```bash +git add packages/backend/src/services/sessions.ts packages/backend/src/services/sessions.test.ts packages/backend/src/routes/sessions.ts +git -c commit.gpgsign=false -c user.email=bert@hausmans.nl -c user.name="Bert Hausmans" commit -m "feat(sessions): startDueSession + POST /api/sessions/due" +``` + +--- + +## Task 5: Backend — Stats routes (`lessons-progress`, `due`) + heatmap default + +**Files:** +- Modify: `packages/backend/src/routes/stats.ts` + +- [ ] **Step 1: Update `routes/stats.ts`** + +Replace contents with: + +```ts +import { Router } from 'express'; +import type { Db } from '../db/client.js'; +import { + getCardStats, getHeatmap, getLessonStats, getOverview, + getLessonsProgress, getDueOverview, +} from '../services/stats.js'; + +export function statsRouter(db: Db): Router { + const r = Router(); + + r.get('/overview', async (req, res, next) => { + try { res.json(await getOverview(db, req.user!.id)); } catch (e) { next(e); } + }); + r.get('/lessons-progress', async (req, res, next) => { + try { res.json(await getLessonsProgress(db, req.user!.id)); } catch (e) { next(e); } + }); + r.get('/due', async (req, res, next) => { + try { res.json(await getDueOverview(db, req.user!.id)); } catch (e) { next(e); } + }); + r.get('/lessons/:id', async (req, res, next) => { + try { res.json(await getLessonStats(db, req.user!.id, Number(req.params.id))); } catch (e) { next(e); } + }); + r.get('/cards/:id', async (req, res, next) => { + try { res.json(await getCardStats(db, req.user!.id, Number(req.params.id))); } catch (e) { next(e); } + }); + r.get('/heatmap', async (req, res, next) => { + try { + const weeks = Math.min(52, Math.max(1, Number(req.query.weeks ?? 52))); + res.json(await getHeatmap(db, req.user!.id, weeks)); + } catch (e) { next(e); } + }); + + return r; +} +``` + +Note: heatmap default changed from 12 to 52, and clamp upper bound unchanged. + +- [ ] **Step 2: Run all backend tests** + +```bash +cd /Users/berthausmans/Documents/Development/flashcard +NODE_ENV=test npm -w @flashcard/backend test 2>&1 | tail -6 +``` + +Expected: all pass. + +- [ ] **Step 3: Commit** + +```bash +git add packages/backend/src/routes/stats.ts +git -c commit.gpgsign=false -c user.email=bert@hausmans.nl -c user.name="Bert Hausmans" commit -m "feat(stats): /lessons-progress and /due routes + heatmap default 52 weeks" +``` + +--- + +## Task 6: UX integration tests (backend) + +**Files:** +- Create: `packages/backend/src/tests/ux.integration.test.ts` + +- [ ] **Step 1: Write integration tests** + +```ts +import { describe, it, expect, beforeEach } from 'vitest'; +import request from 'supertest'; +import { createApp } from '../app.js'; +import { makeTestDb, createUserDirect } from './dbHelper.js'; +import { hashPassword } from '../services/auth/passwords.js'; +import { setMailerForTests, type Mailer } from '../services/auth/email.js'; + +class StubMailer implements Mailer { async send() {} } + +async function login(app: ReturnType, email: string, password = 'secretpass') { + const r = await request(app).post('/api/auth/login').send({ email, password }); + if (r.status !== 200) throw new Error(`login failed: ${r.status}`); + const cookies = r.headers['set-cookie'] as unknown as string[]; + const csrf = cookies.find((c) => c.startsWith('flashcard_csrf='))!.split(';')[0]!.split('=')[1]!; + return { cookies, csrf }; +} + +async function makeUser(env: ReturnType, email: string, role: 'user'|'sysadmin' = 'user') { + return createUserDirect(env.db, { + email, role, isActive: true, + passwordHash: await hashPassword('secretpass'), + emailVerifiedAt: Math.floor(Date.now() / 1000), + }); +} + +let env: ReturnType; +let app: ReturnType; +beforeEach(async () => { + env = makeTestDb(); + setMailerForTests(new StubMailer()); + app = createApp(env.db); +}); + +describe('UX integration', () => { + it('GET /api/search filters by visibility', async () => { + await makeUser(env, 'a@example.com'); + await makeUser(env, 'b@example.com'); + const aAuth = await login(app, 'a@example.com'); + const lA = (await request(app).post('/api/lessons').set('Cookie', aAuth.cookies).set('x-csrf-token', aAuth.csrf) + .send({ name: 'Spaans private' })).body; + const lShared = (await request(app).post('/api/lessons').set('Cookie', aAuth.cookies).set('x-csrf-token', aAuth.csrf) + .send({ name: 'Spaans public' })).body; + await request(app).patch(`/api/lessons/${lShared.id}/visibility`).set('Cookie', aAuth.cookies).set('x-csrf-token', aAuth.csrf) + .send({ visibility: 'shared' }); + + const bAuth = await login(app, 'b@example.com'); + const r = await request(app).get('/api/search?q=spaans').set('Cookie', bAuth.cookies); + expect(r.status).toBe(200); + const names = r.body.lessons.map((l: { name: string }) => l.name); + expect(names).toContain('Spaans public'); + expect(names).not.toContain('Spaans private'); + }); + + it('GET /api/stats/lessons-progress returns only roots', async () => { + await makeUser(env, 'u@example.com'); + const uAuth = await login(app, 'u@example.com'); + const root = (await request(app).post('/api/lessons').set('Cookie', uAuth.cookies).set('x-csrf-token', uAuth.csrf) + .send({ name: 'Root' })).body; + await request(app).post('/api/lessons').set('Cookie', uAuth.cookies).set('x-csrf-token', uAuth.csrf) + .send({ name: 'Child', parentId: root.id }); + + const r = await request(app).get('/api/stats/lessons-progress').set('Cookie', uAuth.cookies); + expect(r.status).toBe(200); + expect(r.body.rows).toHaveLength(1); + expect(r.body.rows[0].name).toBe('Root'); + }); + + it('GET /api/stats/due returns counts', async () => { + await makeUser(env, 'u@example.com'); + const uAuth = await login(app, 'u@example.com'); + const r = await request(app).get('/api/stats/due').set('Cookie', uAuth.cookies); + expect(r.status).toBe(200); + expect(r.body).toHaveProperty('overdue'); + expect(r.body).toHaveProperty('today'); + expect(r.body).toHaveProperty('tomorrow'); + expect(r.body).toHaveProperty('thisWeek'); + }); + + it('POST /api/sessions/due creates a session', async () => { + await makeUser(env, 'u@example.com'); + const uAuth = await login(app, 'u@example.com'); + const lesson = (await request(app).post('/api/lessons').set('Cookie', uAuth.cookies).set('x-csrf-token', uAuth.csrf) + .send({ name: 'L' })).body; + await request(app).post(`/api/lessons/${lesson.id}/cards`).set('Cookie', uAuth.cookies).set('x-csrf-token', uAuth.csrf) + .send({ question: 'q', answer: 'a' }); + const r = await request(app).post('/api/sessions/due').set('Cookie', uAuth.cookies).set('x-csrf-token', uAuth.csrf); + expect(r.status).toBe(201); + expect(r.body.session.id).toBeGreaterThan(0); + }); +}); +``` + +- [ ] **Step 2: Run + commit** + +```bash +NODE_ENV=test npm -w @flashcard/backend test 2>&1 | tail -10 +git add packages/backend/src/tests/ux.integration.test.ts +git -c commit.gpgsign=false -c user.email=bert@hausmans.nl -c user.name="Bert Hausmans" commit -m "test(ux): integration coverage for search + stats + due session" +``` + +Expected: all backend tests pass with 4 new integration tests added. + +--- + +## Task 7: Frontend — API clients (search, stats extensions, sessions/due) + +**Files:** +- Create: `packages/frontend/src/api/search.ts` +- Modify: `packages/frontend/src/api/stats.ts` +- Modify: `packages/frontend/src/api/sessions.ts` + +- [ ] **Step 1: Create `api/search.ts`** + +```ts +import { api } from './client.js'; + +export interface SearchLessonResult { + id: number; + name: string; + ownerDisplayName: string; + location: 'library' | 'marketplace'; + totalCards: number; + isCurated: boolean; +} + +export interface SearchCardResult { + id: number; + lessonId: number; + lessonName: string; + question: string; + snippet: string; +} + +export interface SearchResult { + lessons: SearchLessonResult[]; + cards: SearchCardResult[]; +} + +export const searchApi = { + search: (q: string, limit = 30) => + api.get(`/search?q=${encodeURIComponent(q)}&limit=${limit}`), +}; +``` + +- [ ] **Step 2: Extend `api/stats.ts`** + +Read the current file and APPEND inside the `statsApi` const: + +```ts +lessonsProgress: () => api.get<{ rows: Array<{ + lessonId: number; name: string; totalCards: number; masteredCards: number; + scorePct: number; lastSessionAt: number | null; +}> }>(`/stats/lessons-progress`), +due: () => api.get<{ overdue: number; today: number; tomorrow: number; thisWeek: number }>(`/stats/due`), +``` + +- [ ] **Step 3: Extend `api/sessions.ts`** + +Append inside the `sessionsApi` const: + +```ts +startDue: () => api.post<{ session: import('@flashcard/shared').SessionRow; queue: import('@flashcard/shared').QueueItem[] }>(`/sessions/due`), +``` + +- [ ] **Step 4: Typecheck + commit** + +```bash +cd /Users/berthausmans/Documents/Development/flashcard +npm -w @flashcard/frontend run typecheck +git add packages/frontend/src/api/ +git -c commit.gpgsign=false -c user.email=bert@hausmans.nl -c user.name="Bert Hausmans" commit -m "feat(frontend): API clients for search + stats extensions + due session" +``` + +--- + +## Task 8: Frontend — Heatmap component + +**Files:** +- Create: `packages/frontend/src/components/Heatmap.tsx` + +- [ ] **Step 1: Create the component** + +```tsx +import { useMemo } from 'react'; + +interface HeatmapPoint { day: string; sessions: number; attempts: number; } + +export function Heatmap({ points }: { points: HeatmapPoint[] }) { + // Build a 53-week × 7-day grid ending today, aligned to Sundays. + const grid = useMemo(() => { + const map = new Map(); + for (const p of points) map.set(p.day, p); + const today = new Date(); + today.setUTCHours(0, 0, 0, 0); + // Find the most recent Saturday (end of the grid). + const lastSat = new Date(today); + while (lastSat.getUTCDay() !== 6) lastSat.setUTCDate(lastSat.getUTCDate() + 1); + + const weeks: { date: Date; data?: HeatmapPoint }[][] = []; + const cursor = new Date(lastSat); + cursor.setUTCDate(cursor.getUTCDate() - 53 * 7 + 1); + for (let w = 0; w < 53; w++) { + const col: { date: Date; data?: HeatmapPoint }[] = []; + for (let d = 0; d < 7; d++) { + const key = `${cursor.getUTCFullYear()}-${cursor.getUTCMonth()}-${cursor.getUTCDate()}`; + col.push({ date: new Date(cursor), data: map.get(key) }); + cursor.setUTCDate(cursor.getUTCDate() + 1); + } + weeks.push(col); + } + return weeks; + }, [points]); + + function colorFor(attempts: number): string { + if (attempts === 0) return 'bg-slate-100 dark:bg-slate-800'; + if (attempts < 5) return 'bg-success-200 dark:bg-success-400/30'; + if (attempts < 15) return 'bg-success-400 dark:bg-success-400/60'; + if (attempts < 50) return 'bg-success-500 dark:bg-success-500/80'; + return 'bg-success-700 dark:bg-success-500'; + } + + const monthLabels = useMemo(() => { + const labels: { col: number; text: string }[] = []; + let lastMonth = -1; + grid.forEach((col, i) => { + const m = col[0]!.date.getUTCMonth(); + if (m !== lastMonth) { + labels.push({ col: i, text: ['jan','feb','mrt','apr','mei','jun','jul','aug','sep','okt','nov','dec'][m]! }); + lastMonth = m; + } + }); + return labels; + }, [grid]); + + const todayKey = (() => { + const t = new Date(); + return `${t.getUTCFullYear()}-${t.getUTCMonth()}-${t.getUTCDate()}`; + })(); + + return ( +
+
+
+ {monthLabels.map((m, i) => { + const left = m.col * 16; + return {m.text}; + })} +
+
+
+ {['Ma', '', 'Wo', '', 'Vr', '', ''].map((label, i) => ( + {label} + ))} +
+
+ {grid.map((col, i) => ( +
+ {col.map((cell, j) => { + const key = `${cell.date.getUTCFullYear()}-${cell.date.getUTCMonth()}-${cell.date.getUTCDate()}`; + const isToday = key === todayKey; + const a = cell.data?.attempts ?? 0; + return ( +
+ ); + })} +
+ ))} +
+
+
+
+ ); +} +``` + +- [ ] **Step 2: Typecheck + commit** + +```bash +npm -w @flashcard/frontend run typecheck +git add packages/frontend/src/components/Heatmap.tsx +git -c commit.gpgsign=false -c user.email=bert@hausmans.nl -c user.name="Bert Hausmans" commit -m "feat(frontend): 12-month heatmap component" +``` + +--- + +## Task 9: Frontend — LessonProgressList component + +**Files:** +- Create: `packages/frontend/src/components/LessonProgressList.tsx` + +- [ ] **Step 1: Create** + +```tsx +import { useMemo, useState } from 'react'; +import { Link } from 'react-router-dom'; + +export interface LessonProgressRow { + lessonId: number; + name: string; + totalCards: number; + masteredCards: number; + scorePct: number; + lastSessionAt: number | null; +} + +type SortKey = 'name' | 'score' | 'last'; + +function relativeTime(unixSec: number | null): string { + if (!unixSec) return 'nooit'; + const diff = Math.floor(Date.now() / 1000) - unixSec; + if (diff < 60) return 'zojuist'; + if (diff < 3600) return `${Math.floor(diff / 60)}m geleden`; + if (diff < 86400) return `${Math.floor(diff / 3600)}u geleden`; + if (diff < 7 * 86400) return `${Math.floor(diff / 86400)}d geleden`; + return `${Math.floor(diff / 86400)}d geleden`; +} + +export function LessonProgressList({ rows }: { rows: LessonProgressRow[] }) { + const [sortBy, setSortBy] = useState('score'); + + const sorted = useMemo(() => { + const copy = [...rows]; + copy.sort((a, b) => { + if (sortBy === 'name') return a.name.localeCompare(b.name); + if (sortBy === 'last') return (b.lastSessionAt ?? 0) - (a.lastSessionAt ?? 0); + return b.scorePct - a.scorePct; + }); + return copy; + }, [rows, sortBy]); + + if (rows.length === 0) { + return

Nog geen lessen.

; + } + + return ( +
+
+ Sorteer: + {(['score', 'name', 'last'] as SortKey[]).map((k) => ( + + ))} +
+
    + {sorted.map((r) => { + const masteredFrac = r.totalCards === 0 ? 0 : r.masteredCards / r.totalCards; + return ( +
  • + + {r.name} + +
    +
    +
    + {r.masteredCards}/{r.totalCards} + {r.scorePct}% + {relativeTime(r.lastSessionAt)} +
  • + ); + })} +
+
+ ); +} +``` + +- [ ] **Step 2: Typecheck + commit** + +```bash +npm -w @flashcard/frontend run typecheck +git add packages/frontend/src/components/LessonProgressList.tsx +git -c commit.gpgsign=false -c user.email=bert@hausmans.nl -c user.name="Bert Hausmans" commit -m "feat(frontend): lesson progress list with sorting" +``` + +--- + +## Task 10: Frontend — DueOverviewCard component + +**Files:** +- Create: `packages/frontend/src/components/DueOverviewCard.tsx` + +- [ ] **Step 1: Create** + +```tsx +import { useNavigate } from 'react-router-dom'; +import { sessionsApi } from '../api/sessions.js'; +import { useSession } from '../stores/sessionStore.js'; + +export interface DueOverview { overdue: number; today: number; tomorrow: number; thisWeek: number; } + +export function DueOverviewCard({ data }: { data: DueOverview }) { + const navigate = useNavigate(); + const total = data.overdue + data.today; + async function startReview() { + const r = await sessionsApi.startDue(); + useSession.setState({ session: r.session, current: r.queue[0] ?? null, done: r.queue.length === 0, showAnswer: false, shownAt: Date.now() }); + navigate(`/practice/${r.session.lessonId}`); + } + + return ( +
+
+ + + + +
+ +
+ ); +} + +function Badge({ tone, label, value }: { tone: 'danger'|'brand'|'success'|'muted'; label: string; value: number }) { + const cls = + tone === 'danger' ? 'bg-danger-50 text-danger-700 dark:bg-danger-400/15 dark:text-danger-400' + : tone === 'brand' ? 'bg-brand-100 text-brand-700 dark:bg-brand-900/30 dark:text-brand-200' + : tone === 'success' ? 'bg-success-50 text-success-700 dark:bg-success-700/15 dark:text-success-400' + : 'bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-300'; + return ( +
+
{label}
+
{value}
+
+ ); +} +``` + +- [ ] **Step 2: Typecheck + commit** + +```bash +npm -w @flashcard/frontend run typecheck +git add packages/frontend/src/components/DueOverviewCard.tsx +git -c commit.gpgsign=false -c user.email=bert@hausmans.nl -c user.name="Bert Hausmans" commit -m "feat(frontend): due-overview card with start-review CTA" +``` + +--- + +## Task 11: Frontend — Rewrite Stats page + +**Files:** +- Modify: `packages/frontend/src/pages/Stats.tsx` + +- [ ] **Step 1: Replace contents** + +```tsx +import { useEffect, useState } from 'react'; +import { statsApi } from '../api/stats.js'; +import { Heatmap } from '../components/Heatmap.js'; +import { LessonProgressList, type LessonProgressRow } from '../components/LessonProgressList.js'; +import { DueOverviewCard, type DueOverview } from '../components/DueOverviewCard.js'; + +export function StatsPage() { + const [heatmap, setHeatmap] = useState<{ day: string; sessions: number; attempts: number }[]>([]); + const [progress, setProgress] = useState([]); + const [due, setDue] = useState(null); + + useEffect(() => { + statsApi.heatmap(52).then(setHeatmap).catch(() => {}); + statsApi.lessonsProgress().then((r) => setProgress(r.rows)).catch(() => {}); + statsApi.due().then(setDue).catch(() => {}); + }, []); + + return ( +
+
+

Statistieken

+

Houd zicht op je voortgang en wat er te oefenen valt.

+
+ + {due && ( +
+

⏰ Te reviewen

+ +
+ )} + +
+

📊 Voortgang per les

+ +
+ +
+

🔥 Activiteit

+
+ +
+ minder + + + + + + meer +
+
+
+
+ ); +} +``` + +- [ ] **Step 2: Build + commit** + +```bash +npm -w @flashcard/frontend run typecheck +npm -w @flashcard/frontend run build 2>&1 | tail -3 +git add packages/frontend/src/pages/Stats.tsx +git -c commit.gpgsign=false -c user.email=bert@hausmans.nl -c user.name="Bert Hausmans" commit -m "feat(frontend): rewritten stats page with heatmap + progress + due" +``` + +--- + +## Task 12: Frontend — LessonStatsPanel + SublessonList + RecentSessionsList + +**Files:** +- Create: `packages/frontend/src/components/LessonStatsPanel.tsx` +- Create: `packages/frontend/src/components/SublessonList.tsx` +- Create: `packages/frontend/src/components/RecentSessionsList.tsx` + +- [ ] **Step 1: Create `LessonStatsPanel.tsx`** + +```tsx +import type { LessonStats } from '../api/stats.js'; + +function relativeTime(unixSec: number | null): string { + if (!unixSec) return 'nooit'; + const diff = Math.floor(Date.now() / 1000) - unixSec; + if (diff < 3600) return `${Math.max(1, Math.floor(diff / 60))}m geleden`; + if (diff < 86400) return `${Math.floor(diff / 3600)}u geleden`; + return `${Math.floor(diff / 86400)}d geleden`; +} + +export function LessonStatsPanel({ stats, lastSessionAt }: { stats: LessonStats; lastSessionAt?: number | null }) { + const masteredFrac = stats.totalCards === 0 ? 0 : stats.mastered / stats.totalCards; + return ( +
+ + + + +
+ ); +} + +function Card({ label, value, sub }: { label: string; value: string; sub?: string }) { + return ( +
+
{label}
+
{value}
+ {sub &&
{sub}
} +
+ ); +} +``` + +- [ ] **Step 2: Create `SublessonList.tsx`** + +```tsx +import { Link } from 'react-router-dom'; +import type { LessonTreeNode } from '@flashcard/shared'; + +export function SublessonList({ children, parentId }: { children: LessonTreeNode[]; parentId: number }) { + if (children.length === 0) return null; + return ( +
+

Sublessen

+
    + {children.map((c) => ( +
  • + {c.name} + + {c.cardCount} + +
  • + ))} +
+
+ ); +} +``` + +- [ ] **Step 3: Create `RecentSessionsList.tsx`** + +```tsx +import type { LessonStats } from '../api/stats.js'; + +function relativeTime(unixSec: number): string { + const diff = Math.floor(Date.now() / 1000) - unixSec; + if (diff < 3600) return `${Math.max(1, Math.floor(diff / 60))}m geleden`; + if (diff < 86400) return `${Math.floor(diff / 3600)}u geleden`; + return `${Math.floor(diff / 86400)}d geleden`; +} + +function fmtDuration(s: number): string { + if (s < 60) return `${s}s`; + return `${Math.floor(s / 60)}m ${s % 60}s`; +} + +// Minimal recent session row coming from the existing stats overview endpoint (reused inline). +export interface RecentSessionRow { + id: number; + startedAt: number; + durationSeconds: number | null; + cardsShown: number; + cardsCorrect: number; +} + +export function RecentSessionsList({ rows }: { rows: RecentSessionRow[] }) { + if (rows.length === 0) { + return

Nog geen sessies op deze les.

; + } + return ( +
    + {rows.map((s) => { + const pct = s.cardsShown > 0 ? Math.round((s.cardsCorrect / s.cardsShown) * 100) : 0; + return ( +
  • + {relativeTime(s.startedAt)} + + {pct}% + {s.cardsCorrect}/{s.cardsShown} · {fmtDuration(s.durationSeconds ?? 0)} + +
  • + ); + })} +
+ ); +} +``` + +- [ ] **Step 4: Typecheck + commit** + +```bash +npm -w @flashcard/frontend run typecheck +git add packages/frontend/src/components/LessonStatsPanel.tsx packages/frontend/src/components/SublessonList.tsx packages/frontend/src/components/RecentSessionsList.tsx +git -c commit.gpgsign=false -c user.email=bert@hausmans.nl -c user.name="Bert Hausmans" commit -m "feat(frontend): lesson stats panel + sublesson list + recent sessions list" +``` + +--- + +## Task 13: Frontend — LessonDetail page (replaces AdminLesson) + +**Files:** +- Create: `packages/frontend/src/pages/LessonDetail.tsx` + +- [ ] **Step 1: Create `LessonDetail.tsx`** + +```tsx +import { useEffect, useMemo, useState } from 'react'; +import { Link, useNavigate, useParams } from 'react-router-dom'; +import type { Card, LessonTreeNode } from '@flashcard/shared'; +import { cardsApi } from '../api/cards.js'; +import { lessonsApi } from '../api/lessons.js'; +import { adminLessonsApi } from '../api/admin-lessons.js'; +import { statsApi, type LessonStats } from '../api/stats.js'; +import { sessionsApi } from '../api/sessions.js'; +import { useAuth } from '../stores/authStore.js'; +import { useLessons } from '../stores/lessonsStore.js'; +import { CardTable } from '../components/CardTable.js'; +import { ImportDialog } from '../components/ImportDialog.js'; +import { LessonStatsPanel } from '../components/LessonStatsPanel.js'; +import { SublessonList } from '../components/SublessonList.js'; +import { RecentSessionsList, type RecentSessionRow } from '../components/RecentSessionsList.js'; +import { ApiClientError } from '../api/client.js'; + +function findNode(tree: LessonTreeNode[], id: number, path: LessonTreeNode[] = []): { node: LessonTreeNode | null; path: LessonTreeNode[] } { + for (const n of tree) { + if (n.id === id) return { node: n, path: [...path, n] }; + const found = findNode(n.children, id, [...path, n]); + if (found.node) return found; + } + return { node: null, path: [] }; +} + +const PREVIEW_LIMIT = 30; + +export function LessonDetailPage() { + const { id } = useParams(); + const lessonId = Number(id); + const user = useAuth((s) => s.user); + const { tree, refresh: refreshTree } = useLessons(); + const navigate = useNavigate(); + + const [cards, setCards] = useState([]); + const [stats, setStats] = useState(null); + const [recent, setRecent] = useState([]); + const [showImport, setShowImport] = useState(false); + const [showAllCards, setShowAllCards] = useState(false); + const [busy, setBusy] = useState(false); + + const { node, path } = useMemo(() => findNode(tree, lessonId), [tree, lessonId]); + const isOwner = node?.ownerId === user?.id; + const visibility = node?.visibility ?? 'private'; + const isCurated = node?.isCurated ?? false; + + async function refresh() { + try { setCards(await cardsApi.list(lessonId)); } + catch (e) { if (e instanceof ApiClientError && e.status === 403) setCards([]); else throw e; } + statsApi.lesson(lessonId).then(setStats).catch(() => {}); + statsApi.overview().then((ov) => { + setRecent(ov.recentSessions.filter((s) => s.lessonId === lessonId).slice(0, 5)); + }).catch(() => {}); + } + + useEffect(() => { refresh(); refreshTree(); }, [lessonId]); + + async function toggleVisibility() { + setBusy(true); + try { + const next = visibility === 'shared' ? 'private' : 'shared'; + await lessonsApi.setVisibility(lessonId, next); + await refreshTree(); + } finally { setBusy(false); } + } + async function toggleCurated() { + if (!user || user.role !== 'sysadmin') return; + setBusy(true); + try { await adminLessonsApi.setCurated(lessonId, !isCurated); await refreshTree(); } + finally { setBusy(false); } + } + async function deleteLesson() { + if (!confirm('Verwijder les en alle sublessen + kaarten?')) return; + setBusy(true); + try { await lessonsApi.remove(lessonId); navigate('/lessons'); } + finally { setBusy(false); } + } + async function forkThis() { + setBusy(true); + try { const f = await lessonsApi.fork(lessonId); await refreshTree(); navigate(`/lessons/${f.id}`); } + finally { setBusy(false); } + } + async function unsubscribeThis() { + setBusy(true); + try { await lessonsApi.unsubscribe(lessonId); await refreshTree(); navigate('/lessons'); } + finally { setBusy(false); } + } + + const visibilityBadge = + isCurated ? '⭐ Curated' : visibility === 'shared' ? '🌍 Gedeeld' : '🔒 Privé'; + + const visibleCards = showAllCards ? cards : cards.slice(0, PREVIEW_LIMIT); + + return ( +
+ {/* Breadcrumb */} + + + {/* Header */} +
+
+

+ {node?.name ?? '…'} + + {visibilityBadge} + + {!isOwner && node && ( + 📥 Geabonneerd + )} +

+ {node?.description &&

{node.description}

} +
+
+ Start oefenen → + {isOwner ? ( + <> + + {user?.role === 'sysadmin' && visibility === 'shared' && ( + + )} + + 📤 Exporteer + + + ) : ( + <> + + + + )} +
+
+ + {/* Stats summary */} + {stats && } + + {/* Sublessons */} + {node && } + + {/* Cards */} +
+

Kaarten

+ {cards.length === 0 ? ( +
+ {isOwner ? 'Nog geen kaarten — voeg er hieronder een toe.' : 'Deze les heeft nog geen kaarten.'} +
+ ) : ( +
+ + {cards.length > PREVIEW_LIMIT && !showAllCards && ( +
+ +
+ )} +
+ )} +
+ + {/* Recent sessions */} +
+

Recente sessies

+ +
+ + {showImport && setShowImport(false)} onDone={refresh} />} +
+ ); +} +``` + +- [ ] **Step 2: Build + commit** + +```bash +npm -w @flashcard/frontend run typecheck +npm -w @flashcard/frontend run build 2>&1 | tail -3 +git add packages/frontend/src/pages/LessonDetail.tsx +git -c commit.gpgsign=false -c user.email=bert@hausmans.nl -c user.name="Bert Hausmans" commit -m "feat(frontend): rich lesson detail page" +``` + +--- + +## Task 14: Frontend — Install dnd-kit + LessonTree filter + drag-drop + +**Files:** +- Modify: `packages/frontend/package.json` (deps) +- Modify: `packages/frontend/src/components/LessonTree.tsx` + +- [ ] **Step 1: Install dnd-kit** + +```bash +cd /Users/berthausmans/Documents/Development/flashcard +npm i -w @flashcard/frontend @dnd-kit/core @dnd-kit/sortable +``` + +- [ ] **Step 2: Replace `LessonTree.tsx`** + +```tsx +import { useMemo, useState } from 'react'; +import { Link } from 'react-router-dom'; +import type { LessonTreeNode } from '@flashcard/shared'; +import { lessonsApi } from '../api/lessons.js'; +import { useLessons } from '../stores/lessonsStore.js'; +import { useAuth } from '../stores/authStore.js'; +import { + DndContext, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors, + type DragEndEvent, +} from '@dnd-kit/core'; +import { sortableKeyboardCoordinates, useSortable } from '@dnd-kit/sortable'; +import { CSS } from '@dnd-kit/utilities'; + +function filterTree(nodes: LessonTreeNode[], q: string): LessonTreeNode[] { + if (!q.trim()) return nodes; + const term = q.trim().toLowerCase(); + function visit(n: LessonTreeNode): LessonTreeNode | null { + const matches = n.name.toLowerCase().includes(term); + const kids = n.children.map(visit).filter((x): x is LessonTreeNode => x !== null); + if (matches || kids.length > 0) return { ...n, children: kids }; + return null; + } + return nodes.map(visit).filter((x): x is LessonTreeNode => x !== null); +} + +export function LessonTree({ nodes, filter = '' }: { nodes: LessonTreeNode[]; filter?: string }) { + const filtered = useMemo(() => filterTree(nodes, filter), [nodes, filter]); + const refresh = useLessons((s) => s.refresh); + const sensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 6 } }), + useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }), + ); + + async function handleDragEnd(e: DragEndEvent) { + const { active, over } = e; + if (!over || active.id === over.id) return; + // Find the active node's siblings to compute new position. + const allOwned = collectOwnedFlat(filtered); + const movedId = Number(active.id); + const overId = Number(over.id); + const overNode = allOwned.find((x) => x.id === overId); + if (!overNode) return; + // We support only intra-parent reorder via sortable here. + const movedNode = allOwned.find((x) => x.id === movedId); + if (!movedNode || movedNode.parentId !== overNode.parentId) return; + const siblings = allOwned.filter((x) => x.parentId === movedNode.parentId); + const oldIdx = siblings.findIndex((x) => x.id === movedId); + const newIdx = siblings.findIndex((x) => x.id === overId); + if (oldIdx === newIdx) return; + try { + await lessonsApi.move(movedId, { parentId: movedNode.parentId, position: newIdx }); + await refresh(); + } catch {/* ignore */} + } + + return ( + +
    + {filtered.map((n) => )} +
+
+ ); +} + +function collectOwnedFlat(nodes: LessonTreeNode[]): LessonTreeNode[] { + const out: LessonTreeNode[] = []; + function walk(arr: LessonTreeNode[]) { + for (const n of arr) { out.push(n); walk(n.children); } + } + walk(nodes); + return out; +} + +function TreeRow({ n, depth }: { n: LessonTreeNode; depth: number }) { + const refresh = useLessons((s) => s.refresh); + const currentUserId = useAuth((s) => s.user?.id); + const isOwner = n.ownerId === currentUserId; + const [addingTo, setAddingTo] = useState(null); + const [name, setName] = useState(''); + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ + id: n.id, disabled: !isOwner, + }); + const style = { transform: CSS.Transform.toString(transform), transition, opacity: isDragging ? 0.5 : 1 }; + + async function addChild() { + if (!name.trim()) return; + await lessonsApi.create({ name: name.trim(), parentId: n.id }); + setName(''); setAddingTo(null); await refresh(); + } + async function rename() { + const next = prompt('Nieuwe naam', n.name); + if (next && next.trim() && next !== n.name) { + await lessonsApi.update(n.id, { name: next.trim() }); + await refresh(); + } + } + async function remove() { + if (!confirm('Verwijder les en alle sublessen + kaarten?')) return; + await lessonsApi.remove(n.id); + await refresh(); + } + + const visibilityBadge = + n.isCurated ? '⭐ Curated' : n.visibility === 'shared' ? '🌍 Gedeeld' : '🔒 Privé'; + + return ( +
  • +
    + {isOwner && ( + ⋮⋮ + )} + + + {n.name} + + {n.cardCount} + + + {visibilityBadge} + + {!isOwner && ( + + 📥 Geabonneerd + + )} + + {isOwner && ( +
    + + + +
    + )} +
    + {addingTo === n.id && ( +
    + setName(e.target.value)} + onKeyDown={(e) => { if (e.key === 'Enter') addChild(); if (e.key === 'Escape') { setAddingTo(null); setName(''); } }} + placeholder="Naam van subles" + /> + + +
    + )} + {n.children.length > 0 && ( +
      + {n.children.map((c) => )} +
    + )} +
  • + ); +} +``` + +- [ ] **Step 3: Typecheck + build + commit** + +```bash +npm -w @flashcard/frontend run typecheck +npm -w @flashcard/frontend run build 2>&1 | tail -3 +git add packages/frontend/package.json package-lock.json packages/frontend/src/components/LessonTree.tsx +git -c commit.gpgsign=false -c user.email=bert@hausmans.nl -c user.name="Bert Hausmans" commit -m "feat(frontend): lesson tree with filter + dnd-kit drag reorder" +``` + +--- + +## Task 15: Frontend — Lessons page (replaces Admin.tsx) + +**Files:** +- Create: `packages/frontend/src/pages/Lessons.tsx` + +- [ ] **Step 1: Create** + +```tsx +import { useEffect, useState } from 'react'; +import { lessonsApi } from '../api/lessons.js'; +import { useLessons } from '../stores/lessonsStore.js'; +import { LessonTree } from '../components/LessonTree.js'; + +export function LessonsPage() { + const { tree, refresh, loading } = useLessons(); + const [newRoot, setNewRoot] = useState(''); + const [filter, setFilter] = useState(''); + + useEffect(() => { refresh(); }, [refresh]); + + async function addRoot() { + if (!newRoot.trim()) return; + await lessonsApi.create({ name: newRoot.trim(), parentId: null }); + setNewRoot(''); + await refresh(); + } + + return ( +
    +
    +

    Lessen

    +

    + Maak een hiërarchie van lessen en sublessen. Sleep aan ⋮⋮ om te herordenen. Klik op een les voor details. +

    +
    + +
    + setNewRoot(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && addRoot()} + /> + +
    + +
    + setFilter(e.target.value)} + /> + {loading ? ( +

    Laden…

    + ) : tree.length === 0 ? ( +
    + Nog geen lessen. Voeg er hierboven een toe. +
    + ) : ( + + )} +
    +
    + ); +} +``` + +- [ ] **Step 2: Typecheck + commit** + +```bash +npm -w @flashcard/frontend run typecheck +git add packages/frontend/src/pages/Lessons.tsx +git -c commit.gpgsign=false -c user.email=bert@hausmans.nl -c user.name="Bert Hausmans" commit -m "feat(frontend): lessons page with filter (replaces Admin.tsx)" +``` + +--- + +## Task 16: Frontend — SearchPalette component + +**Files:** +- Create: `packages/frontend/src/components/SearchPalette.tsx` + +- [ ] **Step 1: Create** + +```tsx +import { useEffect, useRef, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { motion, AnimatePresence } from 'framer-motion'; +import { searchApi, type SearchResult } from '../api/search.js'; + +export function SearchPalette({ open, onClose }: { open: boolean; onClose: () => void }) { + const [q, setQ] = useState(''); + const [result, setResult] = useState({ lessons: [], cards: [] }); + const [busy, setBusy] = useState(false); + const [activeIdx, setActiveIdx] = useState(0); + const inputRef = useRef(null); + const navigate = useNavigate(); + + useEffect(() => { + if (open) { + setQ(''); setResult({ lessons: [], cards: [] }); setActiveIdx(0); + setTimeout(() => inputRef.current?.focus(), 10); + } + }, [open]); + + useEffect(() => { + if (!open) return; + function onKey(e: KeyboardEvent) { + if (e.key === 'Escape') onClose(); + } + document.addEventListener('keydown', onKey); + return () => document.removeEventListener('keydown', onKey); + }, [open, onClose]); + + useEffect(() => { + if (!q.trim() || q.trim().length < 2) { + setResult({ lessons: [], cards: [] }); + return; + } + setBusy(true); + const t = setTimeout(async () => { + try { + const r = await searchApi.search(q.trim()); + setResult(r); + setActiveIdx(0); + } finally { setBusy(false); } + }, 200); + return () => clearTimeout(t); + }, [q]); + + const flat = [ + ...result.lessons.map((l) => ({ kind: 'lesson' as const, item: l })), + ...result.cards.map((c) => ({ kind: 'card' as const, item: c })), + ]; + + function selectAt(i: number) { + const it = flat[i]; + if (!it) return; + if (it.kind === 'lesson') navigate(`/lessons/${it.item.id}`); + else navigate(`/lessons/${it.item.lessonId}#card-${it.item.id}`); + onClose(); + } + + function onInputKey(e: React.KeyboardEvent) { + if (e.key === 'ArrowDown') { e.preventDefault(); setActiveIdx((i) => Math.min(flat.length - 1, i + 1)); } + if (e.key === 'ArrowUp') { e.preventDefault(); setActiveIdx((i) => Math.max(0, i - 1)); } + if (e.key === 'Enter') { e.preventDefault(); selectAt(activeIdx); } + } + + if (!open) return null; + + return ( + + + e.stopPropagation()} + > +
    + 🔎 + setQ(e.target.value)} + onKeyDown={onInputKey} + className="flex-1 bg-transparent text-base outline-none" + placeholder="Zoek lessen en kaarten…" + /> + {busy && } + Esc +
    +
    + {q.trim().length < 2 && ( +
    Begin met typen om te zoeken (min. 2 tekens)
    + )} + {q.trim().length >= 2 && flat.length === 0 && !busy && ( +
    Geen resultaten
    + )} + {result.lessons.length > 0 && + {result.lessons.filter((l) => l.location === 'library').map((l) => ( + f.kind === 'lesson' && f.item.id === l.id) === activeIdx} + onClick={() => selectAt(flat.findIndex((f) => f.kind === 'lesson' && f.item.id === l.id))} + > +
    {l.name}
    +
    door {l.ownerDisplayName} · {l.totalCards} kaarten
    +
    + ))} + {result.lessons.filter((l) => l.location === 'marketplace').length > 0 && ( +
    + )} + {result.lessons.filter((l) => l.location === 'marketplace').map((l) => ( + f.kind === 'lesson' && f.item.id === l.id) === activeIdx} + onClick={() => selectAt(flat.findIndex((f) => f.kind === 'lesson' && f.item.id === l.id))} + > +
    {l.name} — marketplace
    +
    door {l.ownerDisplayName} · {l.totalCards} kaarten{l.isCurated ? ' · ⭐' : ''}
    +
    + ))} + } + {result.cards.length > 0 && + {result.cards.map((c) => ( + f.kind === 'card' && f.item.id === c.id) === activeIdx} + onClick={() => selectAt(flat.findIndex((f) => f.kind === 'card' && f.item.id === c.id))} + > +
    {c.question}
    +
    {c.lessonName} — {c.snippet}
    +
    + ))} +
    } +
    + + + + ); +} + +function Group({ title, tone, children }: { title: string; tone: 'brand' | 'success'; children: React.ReactNode }) { + const toneCls = tone === 'brand' ? 'text-brand-700 dark:text-brand-200' : 'text-success-700 dark:text-success-400'; + return ( +
    +
    {title}
    +
      {children}
    +
    + ); +} + +function Row({ active, onClick, children }: { active: boolean; onClick: () => void; children: React.ReactNode }) { + return ( +
  • + {children} +
  • + ); +} +``` + +- [ ] **Step 2: Typecheck + commit** + +```bash +npm -w @flashcard/frontend run typecheck +git add packages/frontend/src/components/SearchPalette.tsx +git -c commit.gpgsign=false -c user.email=bert@hausmans.nl -c user.name="Bert Hausmans" commit -m "feat(frontend): ⌘K search palette modal" +``` + +--- + +## Task 17: Frontend — Layout integration (⌘K + search trigger) + nav update + +**Files:** +- Modify: `packages/frontend/src/components/Layout.tsx` + +- [ ] **Step 1: Replace Layout.tsx** + +```tsx +import { useEffect, useState } from 'react'; +import { NavLink, Outlet } from 'react-router-dom'; +import { useSettings } from '../stores/settingsStore.js'; +import { useAuth } from '../stores/authStore.js'; +import { UserMenu } from './UserMenu.js'; +import { SearchPalette } from './SearchPalette.js'; + +const navItems = [ + { to: '/', label: 'Dashboard', end: true }, + { to: '/lessons', label: 'Lessen' }, + { to: '/marketplace', label: 'Marketplace 🛍️' }, + { to: '/stats', label: 'Stats' }, +]; + +export function Layout() { + const { theme, toggleTheme } = useSettings(); + const user = useAuth((s) => s.user); + const [searchOpen, setSearchOpen] = useState(false); + + useEffect(() => { + function onKey(e: KeyboardEvent) { + if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') { + e.preventDefault(); + setSearchOpen(true); + } + } + document.addEventListener('keydown', onKey); + return () => document.removeEventListener('keydown', onKey); + }, []); + + return ( +
    +
    +
    + + + Flashcards + + {user && ( + + )} +
    + {user && ( + + )} + + +
    +
    + {user && ( + + )} +
    +
    +
    + +
    +
    + setSearchOpen(false)} /> +
    + ); +} +``` + +- [ ] **Step 2: Typecheck + build + commit** + +```bash +npm -w @flashcard/frontend run typecheck +npm -w @flashcard/frontend run build 2>&1 | tail -3 +git add packages/frontend/src/components/Layout.tsx +git -c commit.gpgsign=false -c user.email=bert@hausmans.nl -c user.name="Bert Hausmans" commit -m "feat(frontend): ⌘K search button + listener in layout, /lessons nav" +``` + +--- + +## Task 18: Frontend — Router restructure + redirects + +**Files:** +- Modify: `packages/frontend/src/router.tsx` + +- [ ] **Step 1: Replace contents** + +```tsx +import { lazy, Suspense, type ComponentType } from 'react'; +import { createBrowserRouter, Navigate, useParams } from 'react-router-dom'; +import { Layout } from './components/Layout.js'; +import { AuthBoundary } from './components/AuthBoundary.js'; +import { RoleGuard } from './components/RoleGuard.js'; + +function PageFallback() { + return ( +
    +
    +
    + ); +} + +function lazyPage( + loader: () => Promise>, + name: K, +): ComponentType { + const Component = lazy(async () => { + const mod = await loader(); + return { default: mod[name] as ComponentType }; + }); + return function LazyPage() { + return ( + }> + + + ); + }; +} + +const Dashboard = lazyPage(() => import('./pages/Dashboard.js'), 'DashboardPage'); +const Lessons = lazyPage(() => import('./pages/Lessons.js'), 'LessonsPage'); +const LessonDetail = lazyPage(() => import('./pages/LessonDetail.js'), 'LessonDetailPage'); +const PracticeSetup = lazyPage(() => import('./pages/PracticeSetup.js'), 'PracticeSetupPage'); +const Practice = lazyPage(() => import('./pages/Practice.js'), 'PracticePage'); +const PracticeDone = lazyPage(() => import('./pages/PracticeDone.js'), 'PracticeDonePage'); +const Stats = lazyPage(() => import('./pages/Stats.js'), 'StatsPage'); +const StatsLesson = lazyPage(() => import('./pages/StatsLesson.js'), 'StatsLessonPage'); +const StatsCard = lazyPage(() => import('./pages/StatsCard.js'), 'StatsCardPage'); +const Settings = lazyPage(() => import('./pages/Settings.js'), 'SettingsPage'); +const Profile = lazyPage(() => import('./pages/Profile.js'), 'ProfilePage'); +const AdminUsers = lazyPage(() => import('./pages/AdminUsers.js'), 'AdminUsersPage'); +const Marketplace = lazyPage(() => import('./pages/Marketplace.js'), 'MarketplacePage'); + +const Login = lazyPage(() => import('./pages/auth/Login.js'), 'LoginPage'); +const Register = lazyPage(() => import('./pages/auth/Register.js'), 'RegisterPage'); +const VerifyEmail = lazyPage(() => import('./pages/auth/VerifyEmail.js'), 'VerifyEmailPage'); +const ForgotPassword = lazyPage(() => import('./pages/auth/ForgotPassword.js'), 'ForgotPasswordPage'); +const ResetPassword = lazyPage(() => import('./pages/auth/ResetPassword.js'), 'ResetPasswordPage'); +const AcceptInvite = lazyPage(() => import('./pages/auth/AcceptInvite.js'), 'AcceptInvitePage'); + +// Redirect helpers for legacy URLs +function AdminToLessons() { return ; } +function AdminLessonRedirect() { + const { id } = useParams(); + return ; +} + +export const router = createBrowserRouter([ + { + path: '/', + element: , + children: [ + // Public auth routes + { path: 'login', element: }, + { path: 'register', element: }, + { path: 'verify-email', element: }, + { path: 'forgot-password', element: }, + { path: 'reset-password', element: }, + { path: 'accept-invite', element: }, + + // Authenticated routes + { + element: , + children: [ + { index: true, element: }, + { path: 'lessons', element: }, + { path: 'lessons/:id', element: }, + + // Legacy URL redirects + { path: 'admin', element: }, + { path: 'admin/lessons/:id', element: }, + + { path: 'practice/:lessonId/setup', element: }, + { path: 'practice/:lessonId', element: }, + { path: 'practice/:lessonId/done', element: }, + { path: 'stats', element: }, + { path: 'stats/lessons/:id', element: }, + { path: 'stats/cards/:id', element: }, + { path: 'settings', element: }, + { path: 'profile', element: }, + { path: 'marketplace', element: }, + { + element: , + children: [ + { path: 'admin/users', element: }, + ], + }, + { path: '*', element: }, + ], + }, + ], + }, +]); +``` + +This removes the old `Admin.tsx` and `AdminLesson.tsx` references. Those source files can stay on disk (referenced nowhere now) — Task 19 cleans them up. + +- [ ] **Step 2: Typecheck + build + commit** + +```bash +npm -w @flashcard/frontend run typecheck +npm -w @flashcard/frontend run build 2>&1 | tail -3 +git add packages/frontend/src/router.tsx +git -c commit.gpgsign=false -c user.email=bert@hausmans.nl -c user.name="Bert Hausmans" commit -m "feat(frontend): router restructure /admin → /lessons with redirects" +``` + +--- + +## Task 19: Cleanup — remove obsolete pages + +**Files:** +- Delete: `packages/frontend/src/pages/Admin.tsx` +- Delete: `packages/frontend/src/pages/AdminLesson.tsx` + +- [ ] **Step 1: Confirm no imports remain** + +```bash +cd /Users/berthausmans/Documents/Development/flashcard +grep -rn "from .*pages/Admin\.js\|from .*pages/AdminLesson\.js" packages/frontend/src || echo "no imports — safe to delete" +``` + +Expected output: `no imports — safe to delete`. + +- [ ] **Step 2: Delete files** + +```bash +rm packages/frontend/src/pages/Admin.tsx packages/frontend/src/pages/AdminLesson.tsx +``` + +- [ ] **Step 3: Verify build** + +```bash +npm -w @flashcard/frontend run typecheck +npm -w @flashcard/frontend run build 2>&1 | tail -3 +``` + +- [ ] **Step 4: Commit** + +```bash +git add -A packages/frontend/src/pages +git -c commit.gpgsign=false -c user.email=bert@hausmans.nl -c user.name="Bert Hausmans" commit -m "chore(frontend): remove obsolete Admin/AdminLesson pages" +``` + +--- + +## Task 20: E2E — search + detail + stats + drag-drop smoke + +**Files:** +- Create: `e2e/ux.spec.ts` + +- [ ] **Step 1: Create the spec** + +```ts +import { test, expect } from '@playwright/test'; + +async function fetchVerifyLink(email: string): Promise { + for (let i = 0; i < 30; i++) { + const res = await fetch('http://localhost:8025/api/v1/messages?limit=30'); + const data = await res.json() as { messages: { ID: string; To: { Address: string }[] }[] }; + const msg = data.messages.find((m) => m.To.some((t) => t.Address === email)); + if (msg) { + const body = await (await fetch(`http://localhost:8025/api/v1/message/${msg.ID}`)).json() as { Text: string }; + const m = body.Text.match(/https?:\/\/[^\s]+verify-email\?token=[^\s]+/); + if (m) return m[0]; + } + await new Promise((r) => setTimeout(r, 250)); + } + throw new Error('no verify link for ' + email); +} + +async function registerVerifyLogin(page: import('@playwright/test').Page, name: string, email: string, password: string) { + await page.goto('/register'); + await page.getByLabel(/Naam/).fill(name); + await page.getByLabel(/E-mailadres/).fill(email); + await page.getByLabel(/Wachtwoord/).fill(password); + await page.getByRole('button', { name: /Account aanmaken/ }).click(); + await expect(page.getByText(/bevestigingsmail/i)).toBeVisible({ timeout: 10_000 }); + const link = await fetchVerifyLink(email); + await page.goto(link); + await expect(page.getByRole('link', { name: 'Naar inloggen' })).toBeVisible({ timeout: 10_000 }); + await page.goto('/login'); + await page.getByLabel(/E-mailadres/).fill(email); + await page.getByLabel(/Wachtwoord/).fill(password); + await page.getByRole('button', { name: 'Inloggen' }).click(); + await expect(page.getByRole('button', { name: 'Account menu' })).toBeVisible({ timeout: 15_000 }); +} + +test('search opens with ⌘K, finds a lesson, navigates to detail', async ({ page }) => { + const email = `search+${Date.now()}@example.com`; + await registerVerifyLogin(page, 'SearchUser', email, 'secretpass'); + await page.goto('/lessons'); + await page.getByPlaceholder(/Nieuwe wortel-les/).fill('Aardrijkskunde'); + await page.getByRole('button', { name: /Toevoegen/ }).first().click(); + await expect(page.getByRole('link', { name: /Aardrijkskunde/ }).first()).toBeVisible(); + + // Open search palette via ⌘K + await page.keyboard.press('Meta+K'); + await expect(page.getByPlaceholder(/Zoek lessen en kaarten/)).toBeVisible(); + await page.getByPlaceholder(/Zoek lessen en kaarten/).fill('aardrijk'); + await expect(page.getByText(/Aardrijkskunde/).first()).toBeVisible({ timeout: 5_000 }); + await page.keyboard.press('Enter'); + await expect(page).toHaveURL(/\/lessons\/\d+/); + await expect(page.getByRole('heading', { name: /Aardrijkskunde/ })).toBeVisible(); +}); + +test('lesson detail page shows stats panel and start practice', async ({ page }) => { + const email = `detail+${Date.now()}@example.com`; + await registerVerifyLogin(page, 'DetailUser', email, 'secretpass'); + await page.goto('/lessons'); + await page.getByPlaceholder(/Nieuwe wortel-les/).fill('Wiskunde-test'); + await page.getByRole('button', { name: /Toevoegen/ }).first().click(); + await page.getByRole('link', { name: /Wiskunde-test/ }).first().click(); + await expect(page.getByRole('heading', { name: /Wiskunde-test/ })).toBeVisible(); + await expect(page.getByText(/Kaarten/).first()).toBeVisible(); + await expect(page.getByRole('link', { name: /Start oefenen/ })).toBeVisible(); +}); + +test('stats page renders three sections', async ({ page }) => { + const email = `stats+${Date.now()}@example.com`; + await registerVerifyLogin(page, 'StatsUser', email, 'secretpass'); + await page.goto('/stats'); + await expect(page.getByRole('heading', { name: 'Statistieken' })).toBeVisible(); + await expect(page.getByText(/Te reviewen/)).toBeVisible(); + await expect(page.getByText(/Voortgang per les/)).toBeVisible(); + await expect(page.getByText(/Activiteit/)).toBeVisible(); +}); + +test('legacy /admin redirects to /lessons', async ({ page }) => { + const email = `legacy+${Date.now()}@example.com`; + await registerVerifyLogin(page, 'Legacy', email, 'secretpass'); + await page.goto('/admin'); + await expect(page).toHaveURL(/\/lessons$/); +}); +``` + +- [ ] **Step 2: Run E2E** + +```bash +cd /Users/berthausmans/Documents/Development/flashcard +docker compose up -d mailpit 2>&1 || true +lsof -ti tcp:3000 tcp:5173 2>/dev/null | xargs kill -9 2>/dev/null +rm -f packages/backend/data/e2e.db data/e2e.db +sleep 2 +npm run e2e 2>&1 | tail -15 +``` + +Expected: existing tests (auth, ownership, smoke) still pass + 4 new ux tests pass. Total: 7 e2e tests. + +- [ ] **Step 3: Commit** + +```bash +git add e2e/ux.spec.ts +git -c commit.gpgsign=false -c user.email=bert@hausmans.nl -c user.name="Bert Hausmans" commit -m "test(e2e): search palette + lesson detail + stats + legacy redirect" +``` + +--- + +## Self-review + +**Spec coverage:** + +| Spec section | Implemented in task | +|---|---| +| 3.1 Les-detailpagina (`/lessons/:id`) | 12, 13 | +| 3.2 App-brede search | 1, 2, 7, 16, 17 | +| 3.3 Stats-overhaul (heatmap + progress + due) | 3, 5, 8, 9, 10, 11 | +| 3.4 Admin polish (filter + drag-drop) | 14, 15 | +| Datamodel (geen wijzigingen) | — | +| API: search, lessons-progress, due, sessions/due, heatmap default | 1, 2, 3, 4, 5 | +| Routes-restructure + legacy redirects | 18, 20 | +| UI-componenten (lijst sectie 7 spec) | 8–17 | +| Tests (unit + integration + E2E) | 1, 3, 4, 6, 20 | +| Migratie (geen) | — | + +All spec requirements covered. + +**Placeholder scan:** geen TBDs, geen "implement later", elke step heeft volledige code. + +**Type consistency:** `LessonProgressRow`, `DueOverview`, `SearchResult` consistent gedefinieerd in zowel backend service als frontend api/types. `LessonStats` hergebruikt uit bestaande stats.ts. + +--- + +## Execution Handoff + +**Plan complete and saved to `docs/superpowers/plans/2026-05-21-ux-extensions.md`. Two execution options:** + +**1. Subagent-Driven (recommended)** — fresh subagent per task, review between tasks, fast iteration + +**2. Inline Execution** — execute tasks in this session using executing-plans, batch execution with checkpoints + +**Which approach?**