102 KiB
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) | 8–17 |
| Tests (unit + integration + E2E) | 1, 3, 4, 6, 20 |
| Migratie (geen) | — |
All spec requirements covered.
Placeholder scan: geen TBDs, geen "implement later", elke step heeft volledige code.
Type consistency: LessonProgressRow, DueOverview, SearchResult consistent gedefinieerd in zowel backend service als frontend api/types. LessonStats hergebruikt uit bestaande stats.ts.
Execution Handoff
Plan complete and saved to docs/superpowers/plans/2026-05-21-ux-extensions.md. Two execution options:
1. Subagent-Driven (recommended) — fresh subagent per task, review between tasks, fast iteration
2. Inline Execution — execute tasks in this session using executing-plans, batch execution with checkpoints
Which approach?