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

102 KiB
Raw Blame History

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

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
cd /Users/berthausmans/Documents/Development/flashcard
npm -w @flashcard/backend test -- search

Expected: module ./search.js not found.

  • Step 3: Implement services/search.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
npm -w @flashcard/backend test -- search

Expected: 7 tests pass.

  • Step 5: Commit
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

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:

import { searchRouter } from './routes/search.js';

After the app.use('/api/marketplace', ...) line, add:

app.use('/api/search', requireAuth, searchRouter(db));

(No verifyCsrf needed — GET only.)

  • Step 3: Typecheck
cd /Users/berthausmans/Documents/Development/flashcard
npm -w @flashcard/backend run typecheck

Must pass.

  • Step 4: Commit
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)

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
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:

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
npm -w @flashcard/backend test -- stats

Expected: previous stats tests + 4 new tests pass.

  • Step 5: Commit
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

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
npm -w @flashcard/backend test -- sessions

Expected: startDueSession not exported.

  • Step 3: Append to services/sessions.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
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:

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
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:

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
cd /Users/berthausmans/Documents/Development/flashcard
NODE_ENV=test npm -w @flashcard/backend test 2>&1 | tail -6

Expected: all pass.

  • Step 3: Commit
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

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
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

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:

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:

startDue: () => api.post<{ session: import('@flashcard/shared').SessionRow; queue: import('@flashcard/shared').QueueItem[] }>(`/sessions/due`),
  • Step 4: Typecheck + commit
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

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
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

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
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

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
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

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
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

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
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
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
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

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
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

cd /Users/berthausmans/Documents/Development/flashcard
npm i -w @flashcard/frontend @dnd-kit/core @dnd-kit/sortable
  • Step 2: Replace LessonTree.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
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

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
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

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
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

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
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

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
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

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
rm packages/frontend/src/pages/Admin.tsx packages/frontend/src/pages/AdminLesson.tsx
  • Step 3: Verify build
npm -w @flashcard/frontend run typecheck
npm -w @flashcard/frontend run build 2>&1 | tail -3
  • Step 4: Commit
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

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