# 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?**