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

2670 lines
102 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 `<Navigate>` 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<typeof makeTestDb>;
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<SearchResult> {
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<number | null, typeof allLessons>();
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<number>();
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<number>`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<LessonsProgressResult> {
// 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<number, typeof prog[number][]>();
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<DueOverview> {
// 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<number | null, typeof allLessons>();
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<number>();
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<StartedSession> {
// 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<number | null, typeof allLessons>();
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<number>();
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<StartedSession> {
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<typeof createApp>, 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<typeof makeTestDb>, 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<typeof makeTestDb>;
let app: ReturnType<typeof createApp>;
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<SearchResult>(`/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<string, HeatmapPoint>();
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 (
<div className="overflow-x-auto">
<div className="inline-block">
<div className="ml-8 flex text-xs text-slate-500" style={{ height: 16 }}>
{monthLabels.map((m, i) => {
const left = m.col * 16;
return <span key={i} className="absolute" style={{ marginLeft: left }}>{m.text}</span>;
})}
</div>
<div className="flex gap-1">
<div className="flex flex-col gap-1 pt-1">
{['Ma', '', 'Wo', '', 'Vr', '', ''].map((label, i) => (
<span key={i} className="h-3 text-[10px] leading-3 text-slate-500">{label}</span>
))}
</div>
<div className="flex gap-1">
{grid.map((col, i) => (
<div key={i} className="flex flex-col gap-1">
{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 (
<div
key={j}
title={`${cell.date.toISOString().slice(0, 10)}${a} pogingen, ${cell.data?.sessions ?? 0} sessies`}
className={`h-3 w-3 rounded-sm ${colorFor(a)} ${isToday ? 'ring-1 ring-brand-600' : ''}`}
/>
);
})}
</div>
))}
</div>
</div>
</div>
</div>
);
}
```
- [ ] **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<SortKey>('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 <p className="text-sm text-slate-500">Nog geen lessen.</p>;
}
return (
<div>
<div className="mb-2 flex gap-1 text-xs">
<span className="text-slate-500">Sorteer:</span>
{(['score', 'name', 'last'] as SortKey[]).map((k) => (
<button
key={k}
onClick={() => setSortBy(k)}
className={`rounded-full px-2 py-0.5 ${
sortBy === k
? 'bg-brand-100 text-brand-700 dark:bg-brand-900/40 dark:text-brand-200'
: 'text-slate-500 hover:bg-slate-100 dark:hover:bg-slate-800'
}`}
>
{k === 'score' ? 'score' : k === 'name' ? 'naam' : 'laatst'}
</button>
))}
</div>
<ul className="surface divide-y divide-brand-100/60 dark:divide-slate-800">
{sorted.map((r) => {
const masteredFrac = r.totalCards === 0 ? 0 : r.masteredCards / r.totalCards;
return (
<li key={r.lessonId} className="flex items-center gap-3 p-3 text-sm">
<Link to={`/lessons/${r.lessonId}`} className="flex-1 truncate font-medium">
{r.name}
</Link>
<div className="h-2 w-24 overflow-hidden rounded-full bg-brand-100 dark:bg-slate-800">
<div className="h-full bg-success-500" style={{ width: `${Math.round(masteredFrac * 100)}%` }} />
</div>
<span className="w-12 text-right text-xs text-slate-500">{r.masteredCards}/{r.totalCards}</span>
<span className="w-12 text-right text-xs font-semibold text-brand-700 dark:text-brand-200">{r.scorePct}%</span>
<span className="w-20 text-right text-xs text-slate-500">{relativeTime(r.lastSessionAt)}</span>
</li>
);
})}
</ul>
</div>
);
}
```
- [ ] **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 (
<div className="surface space-y-4 p-5">
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<Badge tone="danger" label="Overdue" value={data.overdue} />
<Badge tone="brand" label="Vandaag" value={data.today} />
<Badge tone="success" label="Morgen" value={data.tomorrow} />
<Badge tone="muted" label="Deze week" value={data.thisWeek} />
</div>
<button
className="btn-primary w-full py-3"
onClick={startReview}
disabled={total === 0}
>
{total === 0
? 'Niets te reviewen — alles up-to-date 🎉'
: `Start review (${total} ${total === 1 ? 'kaart' : 'kaarten'})`}
</button>
</div>
);
}
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 (
<div className={`rounded-2xl p-3 ${cls}`}>
<div className="text-[10px] font-semibold uppercase tracking-wider opacity-80">{label}</div>
<div className="mt-1 font-display text-2xl font-bold">{value}</div>
</div>
);
}
```
- [ ] **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<LessonProgressRow[]>([]);
const [due, setDue] = useState<DueOverview | null>(null);
useEffect(() => {
statsApi.heatmap(52).then(setHeatmap).catch(() => {});
statsApi.lessonsProgress().then((r) => setProgress(r.rows)).catch(() => {});
statsApi.due().then(setDue).catch(() => {});
}, []);
return (
<div className="space-y-8">
<header>
<h1 className="font-display text-3xl font-bold">Statistieken</h1>
<p className="text-sm text-slate-500">Houd zicht op je voortgang en wat er te oefenen valt.</p>
</header>
{due && (
<section>
<h2 className="mb-3 font-display text-xl font-bold"> Te reviewen</h2>
<DueOverviewCard data={due} />
</section>
)}
<section>
<h2 className="mb-3 font-display text-xl font-bold">📊 Voortgang per les</h2>
<LessonProgressList rows={progress} />
</section>
<section>
<h2 className="mb-3 font-display text-xl font-bold">🔥 Activiteit</h2>
<div className="surface p-4">
<Heatmap points={heatmap} />
<div className="mt-3 flex items-center gap-2 text-xs text-slate-500">
<span>minder</span>
<span className="h-3 w-3 rounded-sm bg-slate-100 dark:bg-slate-800" />
<span className="h-3 w-3 rounded-sm bg-success-200" />
<span className="h-3 w-3 rounded-sm bg-success-400" />
<span className="h-3 w-3 rounded-sm bg-success-500" />
<span className="h-3 w-3 rounded-sm bg-success-700" />
<span>meer</span>
</div>
</div>
</section>
</div>
);
}
```
- [ ] **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 (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<Card label="Kaarten" value={String(stats.totalCards)} />
<Card label="Beheerst" value={`${stats.mastered}/${stats.totalCards}`} sub={`${Math.round(masteredFrac * 100)}%`} />
<Card label="Score" value={`${Math.round(stats.score * 100)}%`} />
<Card label="Laatst geoefend" value={relativeTime(lastSessionAt ?? null)} />
</div>
);
}
function Card({ label, value, sub }: { label: string; value: string; sub?: string }) {
return (
<div className="surface p-4">
<div className="text-[10px] font-semibold uppercase tracking-wider text-slate-500">{label}</div>
<div className="mt-1 font-display text-2xl font-bold">{value}</div>
{sub && <div className="text-xs text-slate-500">{sub}</div>}
</div>
);
}
```
- [ ] **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 (
<div>
<h2 className="mb-3 font-display text-xl font-bold">Sublessen</h2>
<ul className="grid gap-2 sm:grid-cols-2">
{children.map((c) => (
<li key={c.id} className="surface flex items-center justify-between p-3">
<Link to={`/lessons/${c.id}`} className="flex-1 truncate font-medium">{c.name}</Link>
<span className="rounded-full bg-brand-100 px-2 py-0.5 text-xs font-semibold text-brand-700 dark:bg-brand-900/30 dark:text-brand-200">
{c.cardCount}
</span>
</li>
))}
</ul>
</div>
);
}
```
- [ ] **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 <p className="text-sm text-slate-500">Nog geen sessies op deze les.</p>;
}
return (
<ul className="surface divide-y divide-brand-100/60 dark:divide-slate-800">
{rows.map((s) => {
const pct = s.cardsShown > 0 ? Math.round((s.cardsCorrect / s.cardsShown) * 100) : 0;
return (
<li key={s.id} className="flex items-center justify-between p-3 text-sm">
<span className="text-slate-600 dark:text-slate-300">{relativeTime(s.startedAt)}</span>
<span className="flex items-center gap-3 text-xs">
<span className="rounded-full bg-brand-100 px-2 py-0.5 font-semibold text-brand-700 dark:bg-brand-900/30 dark:text-brand-200">{pct}%</span>
<span className="text-slate-500">{s.cardsCorrect}/{s.cardsShown} · {fmtDuration(s.durationSeconds ?? 0)}</span>
</span>
</li>
);
})}
</ul>
);
}
```
- [ ] **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<Card[]>([]);
const [stats, setStats] = useState<LessonStats | null>(null);
const [recent, setRecent] = useState<RecentSessionRow[]>([]);
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 (
<div className="space-y-8">
{/* Breadcrumb */}
<nav className="text-sm text-slate-500">
<Link to="/lessons" className="hover:text-brand-700">Lessen</Link>
{path.slice(0, -1).map((p) => (
<span key={p.id}>
<span className="mx-1">/</span>
<Link to={`/lessons/${p.id}`} className="hover:text-brand-700">{p.name}</Link>
</span>
))}
{node && (
<>
<span className="mx-1">/</span>
<span className="font-semibold text-slate-700 dark:text-slate-200">{node.name}</span>
</>
)}
</nav>
{/* Header */}
<header className="surface flex flex-col gap-4 p-5 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="flex items-center gap-2 font-display text-3xl font-bold">
{node?.name ?? '…'}
<span className="rounded-full bg-slate-100 px-2 py-0.5 text-xs font-semibold text-slate-600 dark:bg-slate-800 dark:text-slate-300">
{visibilityBadge}
</span>
{!isOwner && node && (
<span className="rounded-full bg-amber-50 px-2 py-0.5 text-xs font-semibold text-amber-700 dark:bg-amber-900/30 dark:text-amber-200">📥 Geabonneerd</span>
)}
</h1>
{node?.description && <p className="mt-1 text-sm text-slate-600 dark:text-slate-300">{node.description}</p>}
</div>
<div className="flex flex-wrap gap-2">
<Link to={`/practice/${lessonId}/setup`} className="btn-success text-base">Start oefenen </Link>
{isOwner ? (
<>
<button className="btn-ghost" onClick={toggleVisibility} disabled={busy}>
{visibility === 'shared' ? '🔒 Maak privé' : '🌍 Deel publiek'}
</button>
{user?.role === 'sysadmin' && visibility === 'shared' && (
<button className="btn-ghost" onClick={toggleCurated} disabled={busy}>
{isCurated ? '☆ Verwijder curated' : '⭐ Markeer als curated'}
</button>
)}
<button className="btn-ghost" onClick={() => setShowImport(true)}>📥 Importeer</button>
<a className="btn-ghost" href={cardsApi.exportUrl(lessonId, false)}>📤 Exporteer</a>
<button className="btn-ghost text-danger-700" onClick={deleteLesson} disabled={busy}>🗑 Verwijder</button>
</>
) : (
<>
<button className="btn-ghost" onClick={forkThis} disabled={busy}>🍴 Fork</button>
<button className="btn-ghost" onClick={unsubscribeThis} disabled={busy}>Abonnement opzeggen</button>
</>
)}
</div>
</header>
{/* Stats summary */}
{stats && <LessonStatsPanel stats={stats} lastSessionAt={recent[0]?.startedAt ?? null} />}
{/* Sublessons */}
{node && <SublessonList children={node.children} parentId={lessonId} />}
{/* Cards */}
<section>
<h2 className="mb-3 font-display text-xl font-bold">Kaarten</h2>
{cards.length === 0 ? (
<div className="surface p-6 text-center text-sm text-slate-500">
{isOwner ? 'Nog geen kaarten — voeg er hieronder een toe.' : 'Deze les heeft nog geen kaarten.'}
</div>
) : (
<div className="surface overflow-hidden p-1">
<CardTable lessonId={lessonId} cards={visibleCards} onChange={refresh} readOnly={!isOwner} />
{cards.length > PREVIEW_LIMIT && !showAllCards && (
<div className="p-3 text-center">
<button className="btn-ghost" onClick={() => setShowAllCards(true)}>
Toon alle {cards.length} kaarten
</button>
</div>
)}
</div>
)}
</section>
{/* Recent sessions */}
<section>
<h2 className="mb-3 font-display text-xl font-bold">Recente sessies</h2>
<RecentSessionsList rows={recent} />
</section>
{showImport && <ImportDialog lessonId={lessonId} onClose={() => setShowImport(false)} onDone={refresh} />}
</div>
);
}
```
- [ ] **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 (
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<ul className="space-y-1">
{filtered.map((n) => <TreeRow key={n.id} n={n} depth={0} />)}
</ul>
</DndContext>
);
}
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<number | null>(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 (
<li style={{ paddingLeft: depth * 20 }} ref={setNodeRef}>
<div
style={style}
className="group flex items-center gap-2 rounded-2xl px-3 py-2 transition hover:bg-brand-50/70 dark:hover:bg-slate-800/60"
>
{isOwner && (
<span
{...attributes} {...listeners}
className="cursor-grab text-slate-400 hover:text-slate-700 active:cursor-grabbing"
title="Sleep om te herordenen"
aria-label="Drag handle"
></span>
)}
<span className={`h-2 w-2 rounded-full ${depth === 0 ? 'bg-brand-500' : 'bg-brand-300'}`} />
<Link to={`/lessons/${n.id}`} className="flex-1 truncate font-medium text-slate-800 dark:text-slate-100">
{n.name}
<span className="ml-2 rounded-full bg-brand-100 px-2 py-0.5 text-xs font-semibold text-brand-700 dark:bg-brand-900/30 dark:text-brand-200">
{n.cardCount}
</span>
<span className="ml-2 rounded-full bg-slate-100 px-2 py-0.5 text-[10px] font-semibold text-slate-600 dark:bg-slate-800 dark:text-slate-300">
{visibilityBadge}
</span>
{!isOwner && (
<span className="ml-1 rounded-full bg-amber-50 px-2 py-0.5 text-[10px] font-semibold text-amber-700 dark:bg-amber-900/30 dark:text-amber-200">
📥 Geabonneerd
</span>
)}
</Link>
{isOwner && (
<div className="flex items-center gap-1 opacity-0 transition group-hover:opacity-100">
<button className="rounded-lg px-2 py-1 text-xs font-medium text-brand-700 hover:bg-brand-100 dark:text-brand-200 dark:hover:bg-brand-900/40" onClick={() => setAddingTo(n.id)}>+ subles</button>
<button className="rounded-lg px-2 py-1 text-xs font-medium text-slate-600 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-800" onClick={rename}>rename</button>
<button className="rounded-lg px-2 py-1 text-xs font-medium text-danger-600 hover:bg-danger-50 dark:hover:bg-danger-400/10" onClick={remove}>delete</button>
</div>
)}
</div>
{addingTo === n.id && (
<div className="ml-6 mt-1 flex gap-2">
<input
autoFocus className="input-field flex-1" value={name}
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') addChild(); if (e.key === 'Escape') { setAddingTo(null); setName(''); } }}
placeholder="Naam van subles"
/>
<button className="btn-primary px-3" onClick={addChild}>Toevoegen</button>
<button className="btn-ghost px-3" onClick={() => { setAddingTo(null); setName(''); }}>Annuleren</button>
</div>
)}
{n.children.length > 0 && (
<ul className="space-y-1">
{n.children.map((c) => <TreeRow key={c.id} n={c} depth={depth + 1} />)}
</ul>
)}
</li>
);
}
```
- [ ] **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 (
<div className="mx-auto max-w-3xl space-y-6">
<header>
<h1 className="font-display text-3xl font-bold">Lessen</h1>
<p className="mt-1 text-sm text-slate-600 dark:text-slate-400">
Maak een hiërarchie van lessen en sublessen. Sleep aan om te herordenen. Klik op een les voor details.
</p>
</header>
<div className="surface flex flex-col gap-2 p-4 sm:flex-row">
<input
className="input-field"
placeholder="Nieuwe wortel-les…"
value={newRoot}
onChange={(e) => setNewRoot(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && addRoot()}
/>
<button className="btn-primary shrink-0" onClick={addRoot} disabled={!newRoot.trim()}>
+ Toevoegen
</button>
</div>
<div className="surface space-y-3 p-4">
<input
className="input-field"
placeholder="Filter lessen op naam…"
value={filter}
onChange={(e) => setFilter(e.target.value)}
/>
{loading ? (
<p className="text-sm text-slate-500">Laden</p>
) : tree.length === 0 ? (
<div className="py-8 text-center text-sm text-slate-500">
Nog geen lessen. Voeg er hierboven een toe.
</div>
) : (
<LessonTree nodes={tree} filter={filter} />
)}
</div>
</div>
);
}
```
- [ ] **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<SearchResult>({ lessons: [], cards: [] });
const [busy, setBusy] = useState(false);
const [activeIdx, setActiveIdx] = useState(0);
const inputRef = useRef<HTMLInputElement>(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<HTMLInputElement>) {
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 (
<AnimatePresence>
<motion.div
initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}
className="fixed inset-0 z-50 flex items-start justify-center bg-slate-900/40 p-4 pt-24 backdrop-blur-sm"
onClick={onClose}
>
<motion.div
initial={{ opacity: 0, scale: 0.95, y: -8 }} animate={{ opacity: 1, scale: 1, y: 0 }} exit={{ opacity: 0, scale: 0.95 }}
transition={{ type: 'spring', stiffness: 240, damping: 22 }}
className="w-full max-w-2xl overflow-hidden rounded-3xl border border-white/60 bg-white/95 shadow-2xl dark:border-slate-800 dark:bg-slate-900/95"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center gap-2 border-b border-brand-100 px-4 py-3 dark:border-slate-800">
<span className="text-slate-400">🔎</span>
<input
ref={inputRef}
value={q}
onChange={(e) => setQ(e.target.value)}
onKeyDown={onInputKey}
className="flex-1 bg-transparent text-base outline-none"
placeholder="Zoek lessen en kaarten…"
/>
{busy && <span className="text-xs text-slate-400"></span>}
<kbd className="rounded-md border border-slate-300 px-1.5 py-0.5 text-[10px] text-slate-500 dark:border-slate-700">Esc</kbd>
</div>
<div className="max-h-[60vh] overflow-y-auto">
{q.trim().length < 2 && (
<div className="p-8 text-center text-sm text-slate-500">Begin met typen om te zoeken (min. 2 tekens)</div>
)}
{q.trim().length >= 2 && flat.length === 0 && !busy && (
<div className="p-8 text-center text-sm text-slate-500">Geen resultaten</div>
)}
{result.lessons.length > 0 && <Group title="Lessen — Jouw bibliotheek" tone="brand">
{result.lessons.filter((l) => l.location === 'library').map((l) => (
<Row
key={`l-${l.id}`}
active={flat.findIndex((f) => f.kind === 'lesson' && f.item.id === l.id) === activeIdx}
onClick={() => selectAt(flat.findIndex((f) => f.kind === 'lesson' && f.item.id === l.id))}
>
<div className="font-medium">{l.name}</div>
<div className="text-xs text-slate-500">door {l.ownerDisplayName} · {l.totalCards} kaarten</div>
</Row>
))}
{result.lessons.filter((l) => l.location === 'marketplace').length > 0 && (
<div className="my-1 border-t border-brand-100 dark:border-slate-800" />
)}
{result.lessons.filter((l) => l.location === 'marketplace').map((l) => (
<Row
key={`m-${l.id}`}
active={flat.findIndex((f) => f.kind === 'lesson' && f.item.id === l.id) === activeIdx}
onClick={() => selectAt(flat.findIndex((f) => f.kind === 'lesson' && f.item.id === l.id))}
>
<div className="font-medium">{l.name} <span className="ml-1 text-xs text-slate-500"> marketplace</span></div>
<div className="text-xs text-slate-500">door {l.ownerDisplayName} · {l.totalCards} kaarten{l.isCurated ? ' · ⭐' : ''}</div>
</Row>
))}
</Group>}
{result.cards.length > 0 && <Group title="Kaarten" tone="success">
{result.cards.map((c) => (
<Row
key={c.id}
active={flat.findIndex((f) => f.kind === 'card' && f.item.id === c.id) === activeIdx}
onClick={() => selectAt(flat.findIndex((f) => f.kind === 'card' && f.item.id === c.id))}
>
<div className="font-medium">{c.question}</div>
<div className="text-xs text-slate-500">{c.lessonName} {c.snippet}</div>
</Row>
))}
</Group>}
</div>
</motion.div>
</motion.div>
</AnimatePresence>
);
}
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 (
<div>
<div className={`px-4 pt-3 pb-1 text-[10px] font-semibold uppercase tracking-wider ${toneCls}`}>{title}</div>
<ul className="pb-1">{children}</ul>
</div>
);
}
function Row({ active, onClick, children }: { active: boolean; onClick: () => void; children: React.ReactNode }) {
return (
<li
className={`cursor-pointer px-4 py-2 ${active ? 'bg-brand-50 dark:bg-brand-900/30' : 'hover:bg-brand-50/60 dark:hover:bg-slate-800/40'}`}
onClick={onClick}
>
{children}
</li>
);
}
```
- [ ] **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 (
<div className="flex h-full flex-col">
<header className="sticky top-0 z-20 border-b border-white/40 bg-white/70 backdrop-blur-xl dark:border-slate-800/60 dark:bg-slate-950/70">
<div className="mx-auto flex max-w-6xl items-center gap-2 px-4 py-3 sm:px-6">
<NavLink to="/" className="flex items-center gap-2 font-display text-lg font-bold">
<span className="grid h-8 w-8 place-items-center rounded-xl bg-brand-gradient text-white shadow-glow"></span>
<span className="bg-brand-gradient bg-clip-text text-transparent">Flashcards</span>
</NavLink>
{user && (
<nav className="ml-4 hidden gap-1 sm:flex">
{navItems.map((item) => (
<NavLink
key={item.to}
to={item.to}
end={item.end}
className={({ isActive }) =>
`rounded-xl px-3 py-1.5 text-sm font-medium transition ${
isActive
? 'bg-brand-100 text-brand-700 dark:bg-brand-900/40 dark:text-brand-200'
: 'text-slate-600 hover:bg-white/70 hover:text-slate-900 dark:text-slate-300 dark:hover:bg-slate-900/60'
}`
}
>
{item.label}
</NavLink>
))}
</nav>
)}
<div className="ml-auto flex items-center gap-2">
{user && (
<button
onClick={() => setSearchOpen(true)}
className="flex items-center gap-2 rounded-xl border border-white/60 bg-white/70 px-3 py-1.5 text-xs text-slate-600 shadow-sm hover:bg-white dark:border-slate-800 dark:bg-slate-900/70 dark:text-slate-300"
aria-label="Zoeken"
title="Zoek (⌘K)"
>
<span>🔎</span>
<span className="hidden sm:inline">Zoek</span>
<kbd className="hidden rounded-md border border-slate-300 px-1.5 py-0.5 text-[10px] sm:inline dark:border-slate-700">K</kbd>
</button>
)}
<button
onClick={toggleTheme}
className="grid h-9 w-9 place-items-center rounded-xl border border-white/60 bg-white/70 text-base shadow-sm transition hover:scale-105 dark:border-slate-800 dark:bg-slate-900/70"
aria-label="Toggle dark mode"
>
{theme === 'dark' ? '☀️' : '🌙'}
</button>
<UserMenu />
</div>
</div>
{user && (
<nav className="flex gap-1 overflow-x-auto px-4 pb-2 sm:hidden">
{navItems.map((item) => (
<NavLink
key={item.to}
to={item.to}
end={item.end}
className={({ isActive }) =>
`whitespace-nowrap rounded-full px-3 py-1 text-xs font-medium transition ${
isActive ? 'bg-brand-600 text-white' : 'bg-white/60 text-slate-700 dark:bg-slate-900/60 dark:text-slate-300'
}`
}
>
{item.label}
</NavLink>
))}
</nav>
)}
</header>
<main className="flex-1 overflow-auto">
<div className="mx-auto max-w-6xl px-4 py-6 sm:px-6">
<Outlet />
</div>
</main>
<SearchPalette open={searchOpen} onClose={() => setSearchOpen(false)} />
</div>
);
}
```
- [ ] **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 (
<div className="flex h-full items-center justify-center p-12">
<div className="h-2 w-32 animate-shimmer rounded-full bg-gradient-to-r from-brand-100 via-brand-200 to-brand-100 bg-[length:1000px_100%]" />
</div>
);
}
function lazyPage<K extends string>(
loader: () => Promise<Record<K, ComponentType>>,
name: K,
): ComponentType {
const Component = lazy(async () => {
const mod = await loader();
return { default: mod[name] as ComponentType };
});
return function LazyPage() {
return (
<Suspense fallback={<PageFallback />}>
<Component />
</Suspense>
);
};
}
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 <Navigate to="/lessons" replace />; }
function AdminLessonRedirect() {
const { id } = useParams();
return <Navigate to={`/lessons/${id ?? ''}`} replace />;
}
export const router = createBrowserRouter([
{
path: '/',
element: <Layout />,
children: [
// Public auth routes
{ path: 'login', element: <Login /> },
{ path: 'register', element: <Register /> },
{ path: 'verify-email', element: <VerifyEmail /> },
{ path: 'forgot-password', element: <ForgotPassword /> },
{ path: 'reset-password', element: <ResetPassword /> },
{ path: 'accept-invite', element: <AcceptInvite /> },
// Authenticated routes
{
element: <AuthBoundary />,
children: [
{ index: true, element: <Dashboard /> },
{ path: 'lessons', element: <Lessons /> },
{ path: 'lessons/:id', element: <LessonDetail /> },
// Legacy URL redirects
{ path: 'admin', element: <AdminToLessons /> },
{ path: 'admin/lessons/:id', element: <AdminLessonRedirect /> },
{ path: 'practice/:lessonId/setup', element: <PracticeSetup /> },
{ path: 'practice/:lessonId', element: <Practice /> },
{ path: 'practice/:lessonId/done', element: <PracticeDone /> },
{ path: 'stats', element: <Stats /> },
{ path: 'stats/lessons/:id', element: <StatsLesson /> },
{ path: 'stats/cards/:id', element: <StatsCard /> },
{ path: 'settings', element: <Settings /> },
{ path: 'profile', element: <Profile /> },
{ path: 'marketplace', element: <Marketplace /> },
{
element: <RoleGuard role="sysadmin" />,
children: [
{ path: 'admin/users', element: <AdminUsers /> },
],
},
{ path: '*', element: <Navigate to="/" replace /> },
],
},
],
},
]);
```
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<string> {
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) | 817 |
| 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?**