Files
flashcards/docs/superpowers/plans/2026-05-20-flashcard-app.md
Bert Hausmans 011291cf39 docs: add flashcard implementation plan
24 bite-sized TDD tasks covering monorepo setup, backend (lessons,
cards, sessions, stats, excel), frontend (admin, practice, dashboard,
stats), and E2E smoke tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 20:27:32 +02:00

130 KiB

Flashcard Webapplicatie — Implementation Plan

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: Build a single-user local flashcard webapp with hierarchical lessons, Leitner-based spaced repetition, Excel import/export, statistics, and animated practice UI.

Architecture: npm-workspaces monorepo. Express+TS backend with SQLite via Drizzle ORM. React+Vite+TS frontend with Tailwind, Zustand, Framer Motion. Shared Zod schemas in a shared package. Backend serves the built frontend in production.

Tech Stack: Node 20+, TypeScript (ESM), Express 4, Drizzle ORM, better-sqlite3, Zod, SheetJS (xlsx), React 18, Vite 7, Tailwind 3, React Router 6, Zustand, Framer Motion, Vitest, Playwright.

Spec: docs/superpowers/specs/2026-05-20-flashcard-app-design.md


File Structure (overview)

flashcard/
├── package.json                              # workspaces + scripts
├── tsconfig.base.json                        # shared TS config
├── .gitignore
├── packages/
│   ├── shared/
│   │   ├── package.json
│   │   ├── tsconfig.json
│   │   └── src/
│   │       ├── index.ts                     # re-exports
│   │       ├── types.ts                     # domain types
│   │       └── schemas.ts                   # Zod schemas
│   ├── backend/
│   │   ├── package.json
│   │   ├── tsconfig.json
│   │   ├── drizzle.config.ts
│   │   ├── vitest.config.ts
│   │   ├── src/
│   │   │   ├── index.ts                     # server entry
│   │   │   ├── app.ts                       # express app factory
│   │   │   ├── db/
│   │   │   │   ├── client.ts                # drizzle client
│   │   │   │   ├── schema.ts                # tables
│   │   │   │   └── migrate.ts               # migration runner
│   │   │   ├── routes/
│   │   │   │   ├── lessons.ts
│   │   │   │   ├── cards.ts
│   │   │   │   ├── sessions.ts
│   │   │   │   └── stats.ts
│   │   │   ├── services/
│   │   │   │   ├── leitner.ts               # pure algorithm
│   │   │   │   ├── sessions.ts              # session engine
│   │   │   │   ├── stats.ts                 # aggregations
│   │   │   │   └── import.ts                # excel parsing
│   │   │   ├── lib/
│   │   │   │   ├── errors.ts                # error helpers
│   │   │   │   └── excel.ts                 # SheetJS wrappers
│   │   │   └── tests/                       # *.test.ts
│   │   └── drizzle/                         # generated migrations
│   └── frontend/
│       ├── package.json
│       ├── tsconfig.json
│       ├── vite.config.ts
│       ├── tailwind.config.ts
│       ├── postcss.config.js
│       ├── index.html
│       └── src/
│           ├── main.tsx
│           ├── App.tsx
│           ├── router.tsx
│           ├── styles.css
│           ├── api/
│           │   ├── client.ts
│           │   ├── lessons.ts
│           │   ├── cards.ts
│           │   ├── sessions.ts
│           │   └── stats.ts
│           ├── stores/
│           │   ├── sessionStore.ts
│           │   ├── lessonsStore.ts
│           │   └── settingsStore.ts
│           ├── pages/
│           │   ├── Dashboard.tsx
│           │   ├── Admin.tsx
│           │   ├── AdminLesson.tsx
│           │   ├── PracticeSetup.tsx
│           │   ├── Practice.tsx
│           │   ├── PracticeDone.tsx
│           │   ├── Stats.tsx
│           │   ├── StatsLesson.tsx
│           │   ├── StatsCard.tsx
│           │   └── Settings.tsx
│           ├── components/
│           │   ├── Layout.tsx
│           │   ├── LessonTree.tsx
│           │   ├── CardTable.tsx
│           │   ├── Flashcard.tsx
│           │   ├── ImportDialog.tsx
│           │   ├── Confetti.tsx
│           │   └── ...
│           └── lib/
│               ├── format.ts
│               └── streak.ts
└── data/
    └── templates/import-template.xlsx       # generated/committed sample

Task 1: Monorepo bootstrap

Files:

  • Create: package.json

  • Create: tsconfig.base.json

  • Create: .gitignore (already exists, verify)

  • Create: packages/shared/package.json

  • Create: packages/shared/tsconfig.json

  • Create: packages/shared/src/index.ts

  • Step 1: Create root package.json

{
  "name": "flashcard",
  "private": true,
  "version": "0.1.0",
  "type": "module",
  "workspaces": ["packages/*"],
  "scripts": {
    "dev": "concurrently -k -n be,fe -c blue,green \"npm:dev:be\" \"npm:dev:fe\"",
    "dev:be": "npm -w @flashcard/backend run dev",
    "dev:fe": "npm -w @flashcard/frontend run dev",
    "build": "npm -w @flashcard/shared run build && npm -w @flashcard/backend run build && npm -w @flashcard/frontend run build",
    "start": "node packages/backend/dist/index.js",
    "test": "npm -w @flashcard/backend run test && npm -w @flashcard/frontend run test",
    "db:migrate": "npm -w @flashcard/backend run db:migrate",
    "db:seed": "npm -w @flashcard/backend run db:seed",
    "typecheck": "npm -ws run typecheck --if-present"
  },
  "devDependencies": {
    "concurrently": "^9.0.0",
    "typescript": "^5.5.0"
  }
}
  • Step 2: Create tsconfig.base.json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "Bundler",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "resolveJsonModule": true,
    "isolatedModules": true,
    "noUncheckedIndexedAccess": true,
    "noImplicitOverride": true,
    "verbatimModuleSyntax": false
  }
}
  • *Step 3: Verify .gitignore includes node_modules, dist, .db, .env

Open .gitignore and confirm it matches the file already committed. If missing entries, add them.

  • Step 4: Create packages/shared/package.json
{
  "name": "@flashcard/shared",
  "version": "0.1.0",
  "private": true,
  "type": "module",
  "main": "./src/index.ts",
  "types": "./src/index.ts",
  "exports": {
    ".": "./src/index.ts"
  },
  "scripts": {
    "typecheck": "tsc --noEmit",
    "build": "tsc --noEmit"
  },
  "dependencies": {
    "zod": "^3.23.0"
  },
  "devDependencies": {
    "typescript": "^5.5.0"
  }
}
  • Step 5: Create packages/shared/tsconfig.json
{
  "extends": "../../tsconfig.base.json",
  "compilerOptions": {
    "outDir": "dist",
    "rootDir": "src"
  },
  "include": ["src/**/*"]
}
  • Step 6: Create packages/shared/src/index.ts (placeholder)
export {};
  • Step 7: Install root deps

Run: npm install Expected: workspaces resolved, no errors.

  • Step 8: Commit
git add -A
git commit -m "chore: bootstrap monorepo with shared package"

Task 2: Shared types & Zod schemas

Files:

  • Create: packages/shared/src/types.ts

  • Create: packages/shared/src/schemas.ts

  • Modify: packages/shared/src/index.ts

  • Step 1: Write packages/shared/src/types.ts

export type Direction = 'forward' | 'backward';
export type AttemptResult = 'correct' | 'incorrect';
export type SessionStatus = 'active' | 'completed' | 'abandoned';

export interface Lesson {
  id: number;
  parentId: number | null;
  name: string;
  description: string | null;
  position: number;
  bidirectional: boolean;
  createdAt: number;
  updatedAt: number;
}

export interface LessonTreeNode extends Lesson {
  children: LessonTreeNode[];
  cardCount: number;
}

export interface Card {
  id: number;
  lessonId: number;
  question: string;
  answer: string;
  hint: string | null;
  position: number;
  createdAt: number;
  updatedAt: number;
}

export interface CardProgress {
  cardId: number;
  direction: Direction;
  box: number;            // 1..5
  correctCount: number;
  incorrectCount: number;
  lastShownAt: number | null;
  nextDueAt: number;
}

export interface SessionRow {
  id: number;
  lessonId: number;
  startedAt: number;
  endedAt: number | null;
  durationSeconds: number | null;
  cardsShown: number;
  cardsCorrect: number;
  cardsIncorrect: number;
  status: SessionStatus;
}

export interface Attempt {
  id: number;
  sessionId: number;
  cardId: number;
  direction: Direction;
  shownAt: number;
  result: AttemptResult;
  timeToAnswerMs: number | null;
}

export interface QueueItem {
  cardId: number;
  direction: Direction;
}

export interface SessionSettings {
  maxCards: number | null;     // null = all available
  shuffle: boolean;
  direction: 'forward' | 'backward' | 'both';
}
  • Step 2: Write packages/shared/src/schemas.ts
import { z } from 'zod';

export const lessonCreateSchema = z.object({
  parentId: z.number().int().nullable().optional(),
  name: z.string().min(1).max(200),
  description: z.string().max(2000).optional().nullable(),
  bidirectional: z.boolean().optional(),
});

export const lessonUpdateSchema = lessonCreateSchema.partial();

export const lessonMoveSchema = z.object({
  parentId: z.number().int().nullable(),
  position: z.number().int().min(0),
});

export const cardCreateSchema = z.object({
  question: z.string().min(1).max(2000),
  answer: z.string().min(1).max(2000),
  hint: z.string().max(2000).optional().nullable(),
});

export const cardUpdateSchema = cardCreateSchema.partial();

export const sessionStartSchema = z.object({
  lessonId: z.number().int().positive(),
  maxCards: z.number().int().min(1).max(500).nullable().optional(),
  shuffle: z.boolean().optional(),
  direction: z.enum(['forward', 'backward', 'both']).optional(),
});

export const attemptCreateSchema = z.object({
  cardId: z.number().int().positive(),
  direction: z.enum(['forward', 'backward']),
  result: z.enum(['correct', 'incorrect']),
  timeToAnswerMs: z.number().int().min(0).nullable().optional(),
});

export type LessonCreateInput = z.infer<typeof lessonCreateSchema>;
export type LessonUpdateInput = z.infer<typeof lessonUpdateSchema>;
export type LessonMoveInput = z.infer<typeof lessonMoveSchema>;
export type CardCreateInput = z.infer<typeof cardCreateSchema>;
export type CardUpdateInput = z.infer<typeof cardUpdateSchema>;
export type SessionStartInput = z.infer<typeof sessionStartSchema>;
export type AttemptCreateInput = z.infer<typeof attemptCreateSchema>;
  • Step 3: Update packages/shared/src/index.ts
export * from './types.js';
export * from './schemas.js';
  • Step 4: Typecheck

Run: npm -w @flashcard/shared run typecheck Expected: no errors.

  • Step 5: Commit
git add -A
git commit -m "feat(shared): add domain types and zod schemas"

Task 3: Backend bootstrap

Files:

  • Create: packages/backend/package.json

  • Create: packages/backend/tsconfig.json

  • Create: packages/backend/vitest.config.ts

  • Create: packages/backend/src/index.ts

  • Create: packages/backend/src/app.ts

  • Create: packages/backend/src/lib/errors.ts

  • Step 1: Create packages/backend/package.json

{
  "name": "@flashcard/backend",
  "version": "0.1.0",
  "private": true,
  "type": "module",
  "main": "dist/index.js",
  "scripts": {
    "dev": "tsx watch src/index.ts",
    "build": "tsc -p tsconfig.json",
    "start": "node dist/index.js",
    "typecheck": "tsc --noEmit",
    "test": "vitest run",
    "test:watch": "vitest",
    "db:generate": "drizzle-kit generate",
    "db:migrate": "tsx src/db/migrate.ts",
    "db:seed": "tsx src/db/seed.ts"
  },
  "dependencies": {
    "@flashcard/shared": "*",
    "better-sqlite3": "^11.0.0",
    "drizzle-orm": "^0.33.0",
    "express": "^4.19.0",
    "multer": "^1.4.5-lts.1",
    "xlsx": "^0.18.5",
    "zod": "^3.23.0"
  },
  "devDependencies": {
    "@types/better-sqlite3": "^7.6.0",
    "@types/express": "^4.17.0",
    "@types/multer": "^1.4.0",
    "@types/node": "^20.0.0",
    "@types/supertest": "^6.0.0",
    "drizzle-kit": "^0.24.0",
    "supertest": "^7.0.0",
    "tsx": "^4.16.0",
    "typescript": "^5.5.0",
    "vitest": "^2.0.0"
  }
}
  • Step 2: Create packages/backend/tsconfig.json
{
  "extends": "../../tsconfig.base.json",
  "compilerOptions": {
    "outDir": "dist",
    "rootDir": "src",
    "module": "NodeNext",
    "moduleResolution": "NodeNext"
  },
  "include": ["src/**/*"]
}
  • Step 3: Create packages/backend/vitest.config.ts
import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    environment: 'node',
    include: ['src/**/*.test.ts'],
    pool: 'forks',
  },
});
  • Step 4: Create packages/backend/src/lib/errors.ts
export class ApiError extends Error {
  constructor(
    public status: number,
    public code: string,
    message: string,
    public details?: unknown
  ) {
    super(message);
  }

  static notFound(what = 'Resource') {
    return new ApiError(404, 'NOT_FOUND', `${what} not found`);
  }
  static validation(message: string, details?: unknown) {
    return new ApiError(400, 'VALIDATION_ERROR', message, details);
  }
  static conflict(message: string) {
    return new ApiError(409, 'CONFLICT', message);
  }
}
  • Step 5: Create packages/backend/src/app.ts
import express, { type Express, type NextFunction, type Request, type Response } from 'express';
import { ZodError } from 'zod';
import { ApiError } from './lib/errors.js';

export function createApp(): Express {
  const app = express();
  app.use(express.json({ limit: '5mb' }));

  app.get('/api/health', (_req, res) => {
    res.json({ ok: true });
  });

  // Routes mounted in later tasks.

  app.use((err: unknown, _req: Request, res: Response, _next: NextFunction) => {
    if (err instanceof ZodError) {
      res.status(400).json({
        error: { code: 'VALIDATION_ERROR', message: 'Invalid input', details: err.flatten() },
      });
      return;
    }
    if (err instanceof ApiError) {
      res.status(err.status).json({
        error: { code: err.code, message: err.message, details: err.details },
      });
      return;
    }
    console.error(err);
    res.status(500).json({ error: { code: 'INTERNAL', message: 'Internal server error' } });
  });

  return app;
}
  • Step 6: Create packages/backend/src/index.ts
import { createApp } from './app.js';

const PORT = Number(process.env.PORT ?? 3000);
const app = createApp();
app.listen(PORT, () => {
  console.log(`Backend listening on http://localhost:${PORT}`);
});
  • Step 7: Install deps

Run: npm install Expected: deps resolved.

  • Step 8: Smoke test the server

Run: npm -w @flashcard/backend run dev (let it start, then in another shell: curl http://localhost:3000/api/health) Expected: {"ok":true}. Stop the server (Ctrl+C).

  • Step 9: Commit
git add -A
git commit -m "feat(backend): bootstrap express app with error handling"

Task 4: Database schema + migrations

Files:

  • Create: packages/backend/drizzle.config.ts

  • Create: packages/backend/src/db/schema.ts

  • Create: packages/backend/src/db/client.ts

  • Create: packages/backend/src/db/migrate.ts

  • Create: packages/backend/src/db/seed.ts

  • Step 1: Create packages/backend/drizzle.config.ts

import { defineConfig } from 'drizzle-kit';

export default defineConfig({
  schema: './src/db/schema.ts',
  out: './drizzle',
  dialect: 'sqlite',
  dbCredentials: {
    url: process.env.DB_PATH ?? '../../data/flashcard.db',
  },
});
  • Step 2: Create packages/backend/src/db/schema.ts
import { sql } from 'drizzle-orm';
import { integer, sqliteTable, text, index } from 'drizzle-orm/sqlite-core';

export const lessons = sqliteTable('lessons', {
  id: integer('id').primaryKey({ autoIncrement: true }),
  parentId: integer('parent_id'),
  name: text('name').notNull(),
  description: text('description'),
  position: integer('position').notNull().default(0),
  bidirectional: integer('bidirectional', { mode: 'boolean' }).notNull().default(false),
  createdAt: integer('created_at').notNull().default(sql`(unixepoch())`),
  updatedAt: integer('updated_at').notNull().default(sql`(unixepoch())`),
});

export const cards = sqliteTable(
  'cards',
  {
    id: integer('id').primaryKey({ autoIncrement: true }),
    lessonId: integer('lesson_id').notNull().references(() => lessons.id, { onDelete: 'cascade' }),
    question: text('question').notNull(),
    answer: text('answer').notNull(),
    hint: text('hint'),
    position: integer('position').notNull().default(0),
    createdAt: integer('created_at').notNull().default(sql`(unixepoch())`),
    updatedAt: integer('updated_at').notNull().default(sql`(unixepoch())`),
  },
  (t) => ({ lessonIdx: index('cards_lesson_idx').on(t.lessonId) })
);

export const cardProgress = sqliteTable(
  'card_progress',
  {
    cardId: integer('card_id').notNull().references(() => cards.id, { onDelete: 'cascade' }),
    direction: text('direction', { enum: ['forward', 'backward'] }).notNull(),
    box: integer('box').notNull().default(1),
    correctCount: integer('correct_count').notNull().default(0),
    incorrectCount: integer('incorrect_count').notNull().default(0),
    lastShownAt: integer('last_shown_at'),
    nextDueAt: integer('next_due_at').notNull().default(0),
  },
  (t) => ({
    pk: index('card_progress_pk').on(t.cardId, t.direction),
    dueIdx: index('card_progress_due_idx').on(t.nextDueAt),
  })
);

export const sessions = sqliteTable(
  'sessions',
  {
    id: integer('id').primaryKey({ autoIncrement: true }),
    lessonId: integer('lesson_id').notNull().references(() => lessons.id, { onDelete: 'cascade' }),
    startedAt: integer('started_at').notNull().default(sql`(unixepoch())`),
    endedAt: integer('ended_at'),
    durationSeconds: integer('duration_seconds'),
    cardsShown: integer('cards_shown').notNull().default(0),
    cardsCorrect: integer('cards_correct').notNull().default(0),
    cardsIncorrect: integer('cards_incorrect').notNull().default(0),
    status: text('status', { enum: ['active', 'completed', 'abandoned'] }).notNull().default('active'),
    queueSnapshot: text('queue_snapshot'),
  },
  (t) => ({ statusIdx: index('sessions_status_idx').on(t.status) })
);

export const attempts = sqliteTable(
  'attempts',
  {
    id: integer('id').primaryKey({ autoIncrement: true }),
    sessionId: integer('session_id').notNull().references(() => sessions.id, { onDelete: 'cascade' }),
    cardId: integer('card_id').notNull().references(() => cards.id, { onDelete: 'cascade' }),
    direction: text('direction', { enum: ['forward', 'backward'] }).notNull(),
    shownAt: integer('shown_at').notNull().default(sql`(unixepoch())`),
    result: text('result', { enum: ['correct', 'incorrect'] }).notNull(),
    timeToAnswerMs: integer('time_to_answer_ms'),
  },
  (t) => ({
    sessionIdx: index('attempts_session_idx').on(t.sessionId),
    cardIdx: index('attempts_card_idx').on(t.cardId),
  })
);

export type LessonRow = typeof lessons.$inferSelect;
export type CardRow = typeof cards.$inferSelect;
export type CardProgressRow = typeof cardProgress.$inferSelect;
export type SessionRow = typeof sessions.$inferSelect;
export type AttemptRow = typeof attempts.$inferSelect;
  • Step 3: Create packages/backend/src/db/client.ts
import Database from 'better-sqlite3';
import { drizzle, type BetterSQLite3Database } from 'drizzle-orm/better-sqlite3';
import { mkdirSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import * as schema from './schema.js';

export type Db = BetterSQLite3Database<typeof schema>;

export function createDb(dbPath?: string): { db: Db; sqlite: Database.Database } {
  const path = dbPath ?? process.env.DB_PATH ?? resolve(process.cwd(), '../../data/flashcard.db');
  if (path !== ':memory:') {
    mkdirSync(dirname(path), { recursive: true });
  }
  const sqlite = new Database(path);
  sqlite.pragma('journal_mode = WAL');
  sqlite.pragma('foreign_keys = ON');
  const db = drizzle(sqlite, { schema });
  return { db, sqlite };
}
  • Step 4: Generate initial migration

Run: npm -w @flashcard/backend run db:generate Expected: a SQL file appears in packages/backend/drizzle/0000_*.sql.

  • Step 5: Create packages/backend/src/db/migrate.ts
import { migrate } from 'drizzle-orm/better-sqlite3/migrator';
import { resolve } from 'node:path';
import { createDb } from './client.js';

const { db, sqlite } = createDb();
migrate(db, { migrationsFolder: resolve(import.meta.dirname, '../../drizzle') });
sqlite.close();
console.log('Migrations applied.');
  • Step 6: Run migrations

Run: npm run db:migrate Expected: data/flashcard.db is created and Migrations applied. is logged.

  • Step 7: Create packages/backend/src/db/seed.ts
import { createDb } from './client.js';
import { cards, lessons } from './schema.js';

const { db, sqlite } = createDb();
const [root] = db.insert(lessons).values({ name: 'Demo: Spaans', position: 0 }).returning().all();
const [sub] = db.insert(lessons).values({ name: 'Begroetingen', parentId: root.id, position: 0 }).returning().all();
db.insert(cards).values([
  { lessonId: sub.id, question: 'Hallo', answer: 'Hola', position: 0 },
  { lessonId: sub.id, question: 'Goedemorgen', answer: 'Buenos días', position: 1 },
  { lessonId: sub.id, question: 'Tot ziens', answer: 'Adiós', position: 2 },
]).run();
sqlite.close();
console.log('Seed inserted.');
  • Step 8: Commit
git add -A
git commit -m "feat(db): drizzle schema, migrations, and seed"

Task 5: Leitner algorithm (pure, TDD)

Files:

  • Create: packages/backend/src/services/leitner.ts

  • Create: packages/backend/src/services/leitner.test.ts

  • Step 1: Write the failing tests

// leitner.test.ts
import { describe, it, expect } from 'vitest';
import { applyResult, BOX_INTERVALS_SEC, MAX_BOX } from './leitner.js';

describe('leitner.applyResult', () => {
  const now = 1_000_000;

  it('moves correct answer to next box and schedules due', () => {
    const next = applyResult({ box: 1, correctCount: 0, incorrectCount: 0 }, 'correct', now);
    expect(next.box).toBe(2);
    expect(next.nextDueAt).toBe(now + BOX_INTERVALS_SEC[2]);
    expect(next.correctCount).toBe(1);
  });

  it('caps box at MAX_BOX on correct answer', () => {
    const next = applyResult({ box: MAX_BOX, correctCount: 9, incorrectCount: 0 }, 'correct', now);
    expect(next.box).toBe(MAX_BOX);
    expect(next.nextDueAt).toBe(now + BOX_INTERVALS_SEC[MAX_BOX]);
  });

  it('resets to box 1 on incorrect', () => {
    const next = applyResult({ box: 4, correctCount: 3, incorrectCount: 1 }, 'incorrect', now);
    expect(next.box).toBe(1);
    expect(next.nextDueAt).toBe(now + BOX_INTERVALS_SEC[1]);
    expect(next.incorrectCount).toBe(2);
  });
});
  • Step 2: Run tests — they fail

Run: npm -w @flashcard/backend test Expected: failure (module not found).

  • Step 3: Implement leitner.ts
import type { AttemptResult } from '@flashcard/shared';

export const MAX_BOX = 5;
// index 1..5; index 0 unused
export const BOX_INTERVALS_SEC: Record<number, number> = {
  1: 0,
  2: 1 * 24 * 60 * 60,
  3: 3 * 24 * 60 * 60,
  4: 7 * 24 * 60 * 60,
  5: 14 * 24 * 60 * 60,
};

export interface ProgressLike {
  box: number;
  correctCount: number;
  incorrectCount: number;
}

export interface ProgressDelta {
  box: number;
  correctCount: number;
  incorrectCount: number;
  nextDueAt: number;
  lastShownAt: number;
}

export function applyResult(
  current: ProgressLike,
  result: AttemptResult,
  nowSec: number
): ProgressDelta {
  let box = current.box;
  let correctCount = current.correctCount;
  let incorrectCount = current.incorrectCount;
  if (result === 'correct') {
    box = Math.min(MAX_BOX, current.box + 1);
    correctCount += 1;
  } else {
    box = 1;
    incorrectCount += 1;
  }
  return {
    box,
    correctCount,
    incorrectCount,
    nextDueAt: nowSec + BOX_INTERVALS_SEC[box],
    lastShownAt: nowSec,
  };
}
  • Step 4: Run tests — they pass

Run: npm -w @flashcard/backend test Expected: 3 tests pass.

  • Step 5: Commit
git add -A
git commit -m "feat(backend): leitner algorithm with tests"

Task 6: Test helpers — in-memory db

Files:

  • Create: packages/backend/src/tests/dbHelper.ts

  • Step 1: Create dbHelper.ts

import { migrate } from 'drizzle-orm/better-sqlite3/migrator';
import { resolve } from 'node:path';
import { createDb, type Db } from '../db/client.js';

export function makeTestDb(): { db: Db; close: () => void } {
  const { db, sqlite } = createDb(':memory:');
  migrate(db, { migrationsFolder: resolve(import.meta.dirname, '../../drizzle') });
  return { db, close: () => sqlite.close() };
}
  • Step 2: Commit
git add -A
git commit -m "test(backend): in-memory db helper"

Task 7: Lessons CRUD service + routes

Files:

  • Create: packages/backend/src/services/lessons.ts

  • Create: packages/backend/src/services/lessons.test.ts

  • Create: packages/backend/src/routes/lessons.ts

  • Modify: packages/backend/src/app.ts

  • Step 1: Write failing tests for lessons.ts service

// lessons.test.ts
import { describe, it, expect, beforeEach } from 'vitest';
import { makeTestDb } from '../tests/dbHelper.js';
import { createLesson, getLessonTree, updateLesson, deleteLesson, moveLesson } from './lessons.js';

let env: ReturnType<typeof makeTestDb>;
beforeEach(() => {
  env = makeTestDb();
});

describe('lessons service', () => {
  it('creates a root lesson', async () => {
    const lesson = await createLesson(env.db, { name: 'Spaans' });
    expect(lesson.id).toBeGreaterThan(0);
    expect(lesson.parentId).toBeNull();
    expect(lesson.bidirectional).toBe(false);
  });

  it('builds a tree with children and card counts', async () => {
    const root = await createLesson(env.db, { name: 'A' });
    const child = await createLesson(env.db, { name: 'B', parentId: root.id });
    const tree = await getLessonTree(env.db);
    expect(tree).toHaveLength(1);
    expect(tree[0].id).toBe(root.id);
    expect(tree[0].children).toHaveLength(1);
    expect(tree[0].children[0].id).toBe(child.id);
    expect(tree[0].cardCount).toBe(0);
  });

  it('updates name and bidirectional flag', async () => {
    const l = await createLesson(env.db, { name: 'X' });
    const updated = await updateLesson(env.db, l.id, { name: 'Y', bidirectional: true });
    expect(updated.name).toBe('Y');
    expect(updated.bidirectional).toBe(true);
  });

  it('moves a lesson to a new parent and position', async () => {
    const a = await createLesson(env.db, { name: 'A' });
    const b = await createLesson(env.db, { name: 'B' });
    const c = await createLesson(env.db, { name: 'C', parentId: a.id });
    await moveLesson(env.db, c.id, { parentId: b.id, position: 0 });
    const tree = await getLessonTree(env.db);
    const bNode = tree.find((n) => n.id === b.id)!;
    expect(bNode.children.map((c) => c.id)).toEqual([c.id]);
  });

  it('deletes a lesson and cascades to children', async () => {
    const a = await createLesson(env.db, { name: 'A' });
    await createLesson(env.db, { name: 'B', parentId: a.id });
    await deleteLesson(env.db, a.id);
    const tree = await getLessonTree(env.db);
    expect(tree).toHaveLength(0);
  });
});
  • Step 2: Run tests — fail

Run: npm -w @flashcard/backend test Expected: module not found.

  • Step 3: Implement services/lessons.ts
import { and, eq, isNull, sql } from 'drizzle-orm';
import type { Db } from '../db/client.js';
import { cards, lessons } from '../db/schema.js';
import { ApiError } from '../lib/errors.js';
import type {
  Lesson,
  LessonTreeNode,
  LessonCreateInput,
  LessonUpdateInput,
  LessonMoveInput,
} from '@flashcard/shared';

function rowToLesson(r: typeof lessons.$inferSelect): Lesson {
  return {
    id: r.id,
    parentId: r.parentId ?? null,
    name: r.name,
    description: r.description ?? null,
    position: r.position,
    bidirectional: r.bidirectional,
    createdAt: r.createdAt,
    updatedAt: r.updatedAt,
  };
}

async function nextPosition(db: Db, parentId: number | null): Promise<number> {
  const rows = parentId == null
    ? db.select({ pos: lessons.position }).from(lessons).where(isNull(lessons.parentId)).all()
    : db.select({ pos: lessons.position }).from(lessons).where(eq(lessons.parentId, parentId)).all();
  return rows.length === 0 ? 0 : Math.max(...rows.map((r) => r.pos)) + 1;
}

export async function createLesson(db: Db, input: LessonCreateInput): Promise<Lesson> {
  const parentId = input.parentId ?? null;
  if (parentId !== null) {
    const exists = db.select().from(lessons).where(eq(lessons.id, parentId)).get();
    if (!exists) throw ApiError.notFound('Parent lesson');
  }
  const position = await nextPosition(db, parentId);
  const [row] = db.insert(lessons).values({
    name: input.name,
    parentId,
    description: input.description ?? null,
    bidirectional: input.bidirectional ?? false,
    position,
  }).returning().all();
  return rowToLesson(row);
}

export async function updateLesson(db: Db, id: number, input: LessonUpdateInput): Promise<Lesson> {
  const existing = db.select().from(lessons).where(eq(lessons.id, id)).get();
  if (!existing) throw ApiError.notFound('Lesson');
  const [row] = db.update(lessons).set({
    ...(input.name !== undefined && { name: input.name }),
    ...(input.description !== undefined && { description: input.description }),
    ...(input.bidirectional !== undefined && { bidirectional: input.bidirectional }),
    updatedAt: Math.floor(Date.now() / 1000),
  }).where(eq(lessons.id, id)).returning().all();
  return rowToLesson(row);
}

export async function deleteLesson(db: Db, id: number): Promise<void> {
  const r = db.delete(lessons).where(eq(lessons.id, id)).run();
  if (r.changes === 0) throw ApiError.notFound('Lesson');
}

export async function moveLesson(db: Db, id: number, input: LessonMoveInput): Promise<Lesson> {
  const existing = db.select().from(lessons).where(eq(lessons.id, id)).get();
  if (!existing) throw ApiError.notFound('Lesson');
  if (input.parentId !== null) {
    const p = db.select().from(lessons).where(eq(lessons.id, input.parentId)).get();
    if (!p) throw ApiError.notFound('Parent lesson');
    // Prevent cycles
    let cursor: number | null = input.parentId;
    while (cursor !== null) {
      if (cursor === id) throw ApiError.validation('Cannot move lesson into its own descendant');
      const row = db.select({ parentId: lessons.parentId }).from(lessons).where(eq(lessons.id, cursor)).get();
      cursor = row?.parentId ?? null;
    }
  }
  const [row] = db.update(lessons).set({
    parentId: input.parentId,
    position: input.position,
    updatedAt: Math.floor(Date.now() / 1000),
  }).where(eq(lessons.id, id)).returning().all();
  return rowToLesson(row);
}

export async function getLessonTree(db: Db): Promise<LessonTreeNode[]> {
  const all = db.select().from(lessons).orderBy(lessons.position).all();
  const counts = db
    .select({ lessonId: cards.lessonId, count: sql<number>`count(*)`.as('count') })
    .from(cards)
    .groupBy(cards.lessonId)
    .all();
  const countMap = new Map(counts.map((c) => [c.lessonId, Number(c.count)]));
  const nodes = new Map<number, LessonTreeNode>();
  for (const r of all) {
    nodes.set(r.id, { ...rowToLesson(r), children: [], cardCount: countMap.get(r.id) ?? 0 });
  }
  const roots: LessonTreeNode[] = [];
  for (const n of nodes.values()) {
    if (n.parentId === null) roots.push(n);
    else nodes.get(n.parentId)?.children.push(n);
  }
  return roots;
}

export async function getDescendantLessonIds(db: Db, rootId: number): Promise<number[]> {
  const all = db.select({ id: lessons.id, parentId: lessons.parentId }).from(lessons).all();
  const byParent = new Map<number | null, number[]>();
  for (const r of all) {
    const key = r.parentId ?? null;
    if (!byParent.has(key)) byParent.set(key, []);
    byParent.get(key)!.push(r.id);
  }
  const result: number[] = [rootId];
  const stack = [rootId];
  while (stack.length) {
    const cur = stack.pop()!;
    for (const child of byParent.get(cur) ?? []) {
      result.push(child);
      stack.push(child);
    }
  }
  return result;
}
  • Step 4: Run service tests — pass

Run: npm -w @flashcard/backend test Expected: all lesson tests pass.

  • Step 5: Create routes/lessons.ts
import { Router } from 'express';
import { lessonCreateSchema, lessonMoveSchema, lessonUpdateSchema } from '@flashcard/shared';
import type { Db } from '../db/client.js';
import {
  createLesson, deleteLesson, getLessonTree, moveLesson, updateLesson,
} from '../services/lessons.js';

export function lessonsRouter(db: Db): Router {
  const r = Router();

  r.get('/tree', async (_req, res, next) => {
    try { res.json(await getLessonTree(db)); } catch (e) { next(e); }
  });

  r.post('/', async (req, res, next) => {
    try {
      const input = lessonCreateSchema.parse(req.body);
      res.status(201).json(await createLesson(db, input));
    } catch (e) { next(e); }
  });

  r.patch('/:id', async (req, res, next) => {
    try {
      const id = Number(req.params.id);
      const input = lessonUpdateSchema.parse(req.body);
      res.json(await updateLesson(db, id, input));
    } catch (e) { next(e); }
  });

  r.delete('/:id', async (req, res, next) => {
    try {
      await deleteLesson(db, Number(req.params.id));
      res.status(204).end();
    } catch (e) { next(e); }
  });

  r.post('/:id/move', async (req, res, next) => {
    try {
      const input = lessonMoveSchema.parse(req.body);
      res.json(await moveLesson(db, Number(req.params.id), input));
    } catch (e) { next(e); }
  });

  return r;
}
  • Step 6: Wire router in app.ts

Modify createApp() to accept a Db parameter and mount the router. Update app.ts:

import express, { type Express, type NextFunction, type Request, type Response } from 'express';
import { ZodError } from 'zod';
import type { Db } from './db/client.js';
import { ApiError } from './lib/errors.js';
import { lessonsRouter } from './routes/lessons.js';

export function createApp(db: Db): Express {
  const app = express();
  app.use(express.json({ limit: '5mb' }));

  app.get('/api/health', (_req, res) => res.json({ ok: true }));
  app.use('/api/lessons', lessonsRouter(db));

  app.use((err: unknown, _req: Request, res: Response, _next: NextFunction) => {
    if (err instanceof ZodError) {
      res.status(400).json({ error: { code: 'VALIDATION_ERROR', message: 'Invalid input', details: err.flatten() } });
      return;
    }
    if (err instanceof ApiError) {
      res.status(err.status).json({ error: { code: err.code, message: err.message, details: err.details } });
      return;
    }
    console.error(err);
    res.status(500).json({ error: { code: 'INTERNAL', message: 'Internal server error' } });
  });

  return app;
}
  • Step 7: Update index.ts
import { createApp } from './app.js';
import { createDb } from './db/client.js';

const PORT = Number(process.env.PORT ?? 3000);
const { db } = createDb();
const app = createApp(db);
app.listen(PORT, () => {
  console.log(`Backend listening on http://localhost:${PORT}`);
});
  • Step 8: Commit
git add -A
git commit -m "feat(backend): lessons CRUD service and routes"

Task 8: Cards CRUD service + routes

Files:

  • Create: packages/backend/src/services/cards.ts

  • Create: packages/backend/src/services/cards.test.ts

  • Create: packages/backend/src/routes/cards.ts

  • Modify: packages/backend/src/app.ts

  • Step 1: Write failing tests

// cards.test.ts
import { describe, it, expect, beforeEach } from 'vitest';
import { makeTestDb } from '../tests/dbHelper.js';
import { createLesson } from './lessons.js';
import { createCard, listCardsByLesson, updateCard, deleteCard } from './cards.js';

let env: ReturnType<typeof makeTestDb>;
beforeEach(() => { env = makeTestDb(); });

describe('cards service', () => {
  it('creates a card and initializes forward progress', async () => {
    const lesson = await createLesson(env.db, { name: 'L' });
    const card = await createCard(env.db, lesson.id, { question: 'Q', answer: 'A' });
    expect(card.lessonId).toBe(lesson.id);
    expect(card.position).toBe(0);
    const list = await listCardsByLesson(env.db, lesson.id);
    expect(list).toHaveLength(1);
  });

  it('creates two progress rows when lesson is bidirectional', async () => {
    const lesson = await createLesson(env.db, { name: 'L', bidirectional: true });
    const card = await createCard(env.db, lesson.id, { question: 'Q', answer: 'A' });
    // verified indirectly via lessons-bidirectional task; here check no error
    expect(card.id).toBeGreaterThan(0);
  });

  it('updates a card', async () => {
    const l = await createLesson(env.db, { name: 'L' });
    const c = await createCard(env.db, l.id, { question: 'Q', answer: 'A' });
    const u = await updateCard(env.db, c.id, { hint: 'tip' });
    expect(u.hint).toBe('tip');
  });

  it('deletes a card', async () => {
    const l = await createLesson(env.db, { name: 'L' });
    const c = await createCard(env.db, l.id, { question: 'Q', answer: 'A' });
    await deleteCard(env.db, c.id);
    expect(await listCardsByLesson(env.db, l.id)).toHaveLength(0);
  });
});
  • Step 2: Run — fail

Run: npm -w @flashcard/backend test Expected: module not found.

  • Step 3: Implement services/cards.ts
import { eq, sql } from 'drizzle-orm';
import type { Db } from '../db/client.js';
import { cardProgress, cards, lessons } from '../db/schema.js';
import { ApiError } from '../lib/errors.js';
import type { Card, CardCreateInput, CardUpdateInput, Direction } from '@flashcard/shared';

function rowToCard(r: typeof cards.$inferSelect): Card {
  return {
    id: r.id,
    lessonId: r.lessonId,
    question: r.question,
    answer: r.answer,
    hint: r.hint ?? null,
    position: r.position,
    createdAt: r.createdAt,
    updatedAt: r.updatedAt,
  };
}

function ensureProgress(db: Db, cardId: number, direction: Direction) {
  const existing = db
    .select()
    .from(cardProgress)
    .where(sql`${cardProgress.cardId} = ${cardId} AND ${cardProgress.direction} = ${direction}`)
    .get();
  if (!existing) {
    db.insert(cardProgress).values({ cardId, direction, box: 1, nextDueAt: 0 }).run();
  }
}

export async function createCard(db: Db, lessonId: number, input: CardCreateInput): Promise<Card> {
  const lesson = db.select().from(lessons).where(eq(lessons.id, lessonId)).get();
  if (!lesson) throw ApiError.notFound('Lesson');
  const positions = db.select({ pos: cards.position }).from(cards).where(eq(cards.lessonId, lessonId)).all();
  const position = positions.length === 0 ? 0 : Math.max(...positions.map((p) => p.pos)) + 1;
  const [row] = db.insert(cards).values({
    lessonId,
    question: input.question,
    answer: input.answer,
    hint: input.hint ?? null,
    position,
  }).returning().all();
  ensureProgress(db, row.id, 'forward');
  if (lesson.bidirectional) ensureProgress(db, row.id, 'backward');
  return rowToCard(row);
}

export async function listCardsByLesson(db: Db, lessonId: number): Promise<Card[]> {
  return db.select().from(cards).where(eq(cards.lessonId, lessonId)).orderBy(cards.position).all().map(rowToCard);
}

export async function getCard(db: Db, id: number): Promise<Card> {
  const row = db.select().from(cards).where(eq(cards.id, id)).get();
  if (!row) throw ApiError.notFound('Card');
  return rowToCard(row);
}

export async function updateCard(db: Db, id: number, input: CardUpdateInput): Promise<Card> {
  const existing = db.select().from(cards).where(eq(cards.id, id)).get();
  if (!existing) throw ApiError.notFound('Card');
  const [row] = db.update(cards).set({
    ...(input.question !== undefined && { question: input.question }),
    ...(input.answer !== undefined && { answer: input.answer }),
    ...(input.hint !== undefined && { hint: input.hint }),
    updatedAt: Math.floor(Date.now() / 1000),
  }).where(eq(cards.id, id)).returning().all();
  return rowToCard(row);
}

export async function deleteCard(db: Db, id: number): Promise<void> {
  const r = db.delete(cards).where(eq(cards.id, id)).run();
  if (r.changes === 0) throw ApiError.notFound('Card');
}
  • Step 4: Run — pass

Run: npm -w @flashcard/backend test Expected: cards tests pass.

  • Step 5: Create routes/cards.ts
import { Router } from 'express';
import { cardCreateSchema, cardUpdateSchema } from '@flashcard/shared';
import type { Db } from '../db/client.js';
import { createCard, deleteCard, listCardsByLesson, updateCard } from '../services/cards.js';

export function cardsRouter(db: Db): Router {
  const r = Router({ mergeParams: true });

  r.get('/lessons/:lessonId/cards', async (req, res, next) => {
    try { res.json(await listCardsByLesson(db, Number(req.params.lessonId))); } catch (e) { next(e); }
  });

  r.post('/lessons/:lessonId/cards', async (req, res, next) => {
    try {
      const input = cardCreateSchema.parse(req.body);
      res.status(201).json(await createCard(db, Number(req.params.lessonId), input));
    } catch (e) { next(e); }
  });

  r.patch('/cards/:id', async (req, res, next) => {
    try {
      const input = cardUpdateSchema.parse(req.body);
      res.json(await updateCard(db, Number(req.params.id), input));
    } catch (e) { next(e); }
  });

  r.delete('/cards/:id', async (req, res, next) => {
    try { await deleteCard(db, Number(req.params.id)); res.status(204).end(); } catch (e) { next(e); }
  });

  return r;
}
  • Step 6: Mount router in app.ts

Add after app.use('/api/lessons', lessonsRouter(db));:

import { cardsRouter } from './routes/cards.js';
// ...
app.use('/api', cardsRouter(db));
  • Step 7: Commit
git add -A
git commit -m "feat(backend): cards CRUD service and routes"

Task 9: Session engine + Leitner integration (TDD)

Files:

  • Create: packages/backend/src/services/sessions.ts

  • Create: packages/backend/src/services/sessions.test.ts

  • Step 1: Write failing tests covering queue build, next, record attempt, end

// sessions.test.ts
import { describe, it, expect, beforeEach } from 'vitest';
import { makeTestDb } from '../tests/dbHelper.js';
import { createLesson } from './lessons.js';
import { createCard } from './cards.js';
import { startSession, getNextItem, recordAttempt, endSession, getActiveSession } from './sessions.js';

let env: ReturnType<typeof makeTestDb>;
beforeEach(() => { env = makeTestDb(); });

async function seedLesson(name = 'L', cards = 3, bidi = false) {
  const lesson = await createLesson(env.db, { name, bidirectional: bidi });
  for (let i = 0; i < cards; i++) {
    await createCard(env.db, lesson.id, { question: `q${i}`, answer: `a${i}` });
  }
  return lesson;
}

describe('session engine', () => {
  it('starts a session and queues all cards from lesson + descendants', async () => {
    const root = await createLesson(env.db, { name: 'R' });
    const child = await createLesson(env.db, { name: 'C', parentId: root.id });
    await createCard(env.db, root.id, { question: 'r1', answer: 'a' });
    await createCard(env.db, child.id, { question: 'c1', answer: 'a' });
    const s = await startSession(env.db, { lessonId: root.id, shuffle: false });
    expect(s.queue).toHaveLength(2);
  });

  it('returns items in queue order and marks done when exhausted', async () => {
    const l = await seedLesson('L', 2);
    const s = await startSession(env.db, { lessonId: l.id, shuffle: false });
    const a = await getNextItem(env.db, s.session.id);
    expect(a).not.toBeNull();
    await recordAttempt(env.db, s.session.id, { cardId: a!.cardId, direction: 'forward', result: 'correct' });
    const b = await getNextItem(env.db, s.session.id);
    expect(b).not.toBeNull();
    await recordAttempt(env.db, s.session.id, { cardId: b!.cardId, direction: 'forward', result: 'correct' });
    const done = await getNextItem(env.db, s.session.id);
    expect(done).toBeNull();
  });

  it('reinserts an incorrect card later in the same session', async () => {
    const l = await seedLesson('L', 4);
    const s = await startSession(env.db, { lessonId: l.id, shuffle: false });
    const first = await getNextItem(env.db, s.session.id);
    await recordAttempt(env.db, s.session.id, { cardId: first!.cardId, direction: 'forward', result: 'incorrect' });
    // Burn through 3 more; the wrong card should reappear
    const seen: number[] = [first!.cardId];
    for (let i = 0; i < 4; i++) {
      const nx = await getNextItem(env.db, s.session.id);
      if (!nx) break;
      seen.push(nx.cardId);
      await recordAttempt(env.db, s.session.id, { cardId: nx.cardId, direction: 'forward', result: 'correct' });
    }
    expect(seen.filter((c) => c === first!.cardId).length).toBeGreaterThanOrEqual(2);
  });

  it('respects maxCards limit', async () => {
    const l = await seedLesson('L', 10);
    const s = await startSession(env.db, { lessonId: l.id, shuffle: false, maxCards: 3 });
    expect(s.queue).toHaveLength(3);
  });

  it('tracks counters on session and ends with duration', async () => {
    const l = await seedLesson('L', 2);
    const s = await startSession(env.db, { lessonId: l.id, shuffle: false });
    const a = await getNextItem(env.db, s.session.id);
    await recordAttempt(env.db, s.session.id, { cardId: a!.cardId, direction: 'forward', result: 'correct' });
    const b = await getNextItem(env.db, s.session.id);
    await recordAttempt(env.db, s.session.id, { cardId: b!.cardId, direction: 'forward', result: 'incorrect' });
    const ended = await endSession(env.db, s.session.id);
    expect(ended.cardsCorrect).toBe(1);
    expect(ended.cardsIncorrect).toBe(1);
    expect(ended.cardsShown).toBe(2);
    expect(ended.status).toBe('completed');
    expect(ended.durationSeconds).not.toBeNull();
  });

  it('returns active session if one exists', async () => {
    const l = await seedLesson('L', 1);
    const s = await startSession(env.db, { lessonId: l.id });
    const active = await getActiveSession(env.db);
    expect(active?.id).toBe(s.session.id);
    await endSession(env.db, s.session.id);
    const after = await getActiveSession(env.db);
    expect(after).toBeNull();
  });
});
  • Step 2: Run — fail

Run: npm -w @flashcard/backend test Expected: module not found.

  • Step 3: Implement services/sessions.ts
import { and, eq, inArray, sql } from 'drizzle-orm';
import type { Db } from '../db/client.js';
import { attempts, cardProgress, cards, lessons, sessions } from '../db/schema.js';
import { ApiError } from '../lib/errors.js';
import type {
  AttemptCreateInput, Direction, QueueItem, SessionRow, SessionStartInput,
} from '@flashcard/shared';
import { applyResult } from './leitner.js';
import { getDescendantLessonIds } from './lessons.js';

const REINSERT_OFFSET = 3;

function nowSec() { return Math.floor(Date.now() / 1000); }

function shuffleInPlace<T>(arr: T[]): T[] {
  for (let i = arr.length - 1; i > 0; i--) {
    const j = Math.floor(Math.random() * (i + 1));
    [arr[i], arr[j]] = [arr[j]!, arr[i]!];
  }
  return arr;
}

export interface StartedSession {
  session: SessionRow;
  queue: QueueItem[];
}

export async function startSession(db: Db, input: SessionStartInput): Promise<StartedSession> {
  const lesson = db.select().from(lessons).where(eq(lessons.id, input.lessonId)).get();
  if (!lesson) throw ApiError.notFound('Lesson');

  const lessonIds = await getDescendantLessonIds(db, input.lessonId);
  const lessonRows = db.select().from(lessons).where(inArray(lessons.id, lessonIds)).all();
  const bidirById = new Map(lessonRows.map((l) => [l.id, l.bidirectional]));

  const allCards = db.select().from(cards).where(inArray(cards.lessonId, lessonIds)).all();
  const direction = input.direction ?? 'forward';

  // Build candidate queue items
  const candidates: QueueItem[] = [];
  for (const c of allCards) {
    const isBidi = bidirById.get(c.lessonId) === true;
    if (direction === 'forward' || direction === 'both') {
      candidates.push({ cardId: c.id, direction: 'forward' });
    }
    if ((direction === 'backward' || direction === 'both') && isBidi) {
      candidates.push({ cardId: c.id, direction: 'backward' });
    }
  }

  // Ensure progress rows exist & fetch boxes
  for (const item of candidates) {
    const existing = db.select().from(cardProgress)
      .where(sql`${cardProgress.cardId} = ${item.cardId} AND ${cardProgress.direction} = ${item.direction}`)
      .get();
    if (!existing) {
      db.insert(cardProgress).values({
        cardId: item.cardId, direction: item.direction, box: 1, nextDueAt: 0,
      }).run();
    }
  }
  const progressRows = db.select().from(cardProgress)
    .where(inArray(cardProgress.cardId, allCards.map((c) => c.id)))
    .all();
  const progByKey = new Map<string, typeof progressRows[number]>();
  for (const p of progressRows) progByKey.set(`${p.cardId}:${p.direction}`, p);

  const now = nowSec();
  // Partition: due first (box ascending), then non-due (box ascending)
  const due: QueueItem[] = [];
  const future: QueueItem[] = [];
  for (const item of candidates) {
    const p = progByKey.get(`${item.cardId}:${item.direction}`)!;
    (p.nextDueAt <= now ? due : future).push(item);
  }

  const shuffle = input.shuffle ?? true;
  const sortByBox = (a: QueueItem, b: QueueItem) => {
    const pa = progByKey.get(`${a.cardId}:${a.direction}`)!;
    const pb = progByKey.get(`${b.cardId}:${b.direction}`)!;
    return pa.box - pb.box;
  };
  if (shuffle) {
    shuffleInPlace(due);
    shuffleInPlace(future);
  }
  due.sort(sortByBox);
  future.sort(sortByBox);

  let queue: QueueItem[] = [...due, ...future];
  const max = input.maxCards ?? null;
  if (max !== null) queue = queue.slice(0, max);

  const [row] = db.insert(sessions).values({
    lessonId: input.lessonId,
    queueSnapshot: JSON.stringify({ remaining: queue, index: 0 }),
  }).returning().all();

  return { session: rowToSession(row), queue };
}

function rowToSession(r: typeof sessions.$inferSelect): SessionRow {
  return {
    id: r.id,
    lessonId: r.lessonId,
    startedAt: r.startedAt,
    endedAt: r.endedAt ?? null,
    durationSeconds: r.durationSeconds ?? null,
    cardsShown: r.cardsShown,
    cardsCorrect: r.cardsCorrect,
    cardsIncorrect: r.cardsIncorrect,
    status: r.status,
  };
}

interface QueueState { remaining: QueueItem[]; index: number; }
function readQueue(snapshot: string | null | undefined): QueueState {
  if (!snapshot) return { remaining: [], index: 0 };
  return JSON.parse(snapshot) as QueueState;
}

export async function getNextItem(db: Db, sessionId: number): Promise<QueueItem | null> {
  const row = db.select().from(sessions).where(eq(sessions.id, sessionId)).get();
  if (!row) throw ApiError.notFound('Session');
  if (row.status !== 'active') return null;
  const state = readQueue(row.queueSnapshot);
  return state.remaining[state.index] ?? null;
}

export async function recordAttempt(
  db: Db,
  sessionId: number,
  input: AttemptCreateInput
): Promise<void> {
  const sess = db.select().from(sessions).where(eq(sessions.id, sessionId)).get();
  if (!sess) throw ApiError.notFound('Session');
  if (sess.status !== 'active') throw ApiError.validation('Session is not active');

  const now = nowSec();
  db.insert(attempts).values({
    sessionId,
    cardId: input.cardId,
    direction: input.direction,
    result: input.result,
    timeToAnswerMs: input.timeToAnswerMs ?? null,
  }).run();

  const prog = db.select().from(cardProgress).where(
    sql`${cardProgress.cardId} = ${input.cardId} AND ${cardProgress.direction} = ${input.direction}`
  ).get();
  if (prog) {
    const delta = applyResult(
      { box: prog.box, correctCount: prog.correctCount, incorrectCount: prog.incorrectCount },
      input.result,
      now
    );
    db.update(cardProgress).set({
      box: delta.box,
      correctCount: delta.correctCount,
      incorrectCount: delta.incorrectCount,
      nextDueAt: delta.nextDueAt,
      lastShownAt: delta.lastShownAt,
    }).where(
      sql`${cardProgress.cardId} = ${input.cardId} AND ${cardProgress.direction} = ${input.direction}`
    ).run();
  }

  // Update queue state
  const state = readQueue(sess.queueSnapshot);
  state.index += 1;
  if (input.result === 'incorrect') {
    const insertAt = Math.min(state.remaining.length, state.index + REINSERT_OFFSET);
    state.remaining.splice(insertAt, 0, { cardId: input.cardId, direction: input.direction });
  }

  db.update(sessions).set({
    queueSnapshot: JSON.stringify(state),
    cardsShown: sess.cardsShown + 1,
    cardsCorrect: sess.cardsCorrect + (input.result === 'correct' ? 1 : 0),
    cardsIncorrect: sess.cardsIncorrect + (input.result === 'incorrect' ? 1 : 0),
  }).where(eq(sessions.id, sessionId)).run();
}

export async function endSession(db: Db, sessionId: number): Promise<SessionRow> {
  const sess = db.select().from(sessions).where(eq(sessions.id, sessionId)).get();
  if (!sess) throw ApiError.notFound('Session');
  const endedAt = nowSec();
  const duration = endedAt - sess.startedAt;
  const [row] = db.update(sessions).set({
    status: 'completed',
    endedAt,
    durationSeconds: duration,
    queueSnapshot: null,
  }).where(eq(sessions.id, sessionId)).returning().all();
  return rowToSession(row);
}

export async function abandonSession(db: Db, sessionId: number): Promise<SessionRow> {
  const sess = db.select().from(sessions).where(eq(sessions.id, sessionId)).get();
  if (!sess) throw ApiError.notFound('Session');
  const endedAt = nowSec();
  const duration = endedAt - sess.startedAt;
  const [row] = db.update(sessions).set({
    status: 'abandoned', endedAt, durationSeconds: duration,
  }).where(eq(sessions.id, sessionId)).returning().all();
  return rowToSession(row);
}

export async function getActiveSession(db: Db): Promise<SessionRow | null> {
  const row = db.select().from(sessions).where(eq(sessions.status, 'active')).orderBy(sql`${sessions.startedAt} DESC`).get();
  return row ? rowToSession(row) : null;
}

export async function getSessionState(db: Db, sessionId: number): Promise<{ session: SessionRow; queue: QueueItem[]; index: number } | null> {
  const row = db.select().from(sessions).where(eq(sessions.id, sessionId)).get();
  if (!row) return null;
  const state = readQueue(row.queueSnapshot);
  return { session: rowToSession(row), queue: state.remaining, index: state.index };
}
  • Step 4: Run tests — pass

Run: npm -w @flashcard/backend test Expected: all session tests pass. Iterate if any fail (most likely cause: queue index off-by-one or empty queue handling).

  • Step 5: Commit
git add -A
git commit -m "feat(backend): session engine with Leitner integration"

Task 10: Sessions routes

Files:

  • Create: packages/backend/src/routes/sessions.ts

  • Modify: packages/backend/src/app.ts

  • Step 1: Create routes/sessions.ts

import { Router } from 'express';
import { attemptCreateSchema, sessionStartSchema } from '@flashcard/shared';
import type { Db } from '../db/client.js';
import {
  abandonSession, endSession, getActiveSession, getNextItem, getSessionState,
  recordAttempt, startSession,
} from '../services/sessions.js';
import { ApiError } from '../lib/errors.js';

export function sessionsRouter(db: Db): Router {
  const r = Router();

  r.post('/', async (req, res, next) => {
    try {
      const input = sessionStartSchema.parse(req.body);
      res.status(201).json(await startSession(db, input));
    } catch (e) { next(e); }
  });

  r.get('/active', async (_req, res, next) => {
    try { res.json(await getActiveSession(db)); } catch (e) { next(e); }
  });

  r.get('/:id', async (req, res, next) => {
    try {
      const state = await getSessionState(db, Number(req.params.id));
      if (!state) throw ApiError.notFound('Session');
      res.json(state);
    } catch (e) { next(e); }
  });

  r.get('/:id/next', async (req, res, next) => {
    try {
      const item = await getNextItem(db, Number(req.params.id));
      if (!item) { res.json({ done: true }); return; }
      res.json({ done: false, item });
    } catch (e) { next(e); }
  });

  r.post('/:id/attempts', async (req, res, next) => {
    try {
      const input = attemptCreateSchema.parse(req.body);
      await recordAttempt(db, Number(req.params.id), input);
      res.status(204).end();
    } catch (e) { next(e); }
  });

  r.post('/:id/end', async (req, res, next) => {
    try { res.json(await endSession(db, Number(req.params.id))); } catch (e) { next(e); }
  });

  r.post('/:id/abandon', async (req, res, next) => {
    try { res.json(await abandonSession(db, Number(req.params.id))); } catch (e) { next(e); }
  });

  return r;
}
  • Step 2: Mount in app.ts

Add import { sessionsRouter } from './routes/sessions.js'; and app.use('/api/sessions', sessionsRouter(db));.

  • Step 3: Commit
git add -A
git commit -m "feat(backend): sessions routes"

Task 11: Statistics service + routes

Files:

  • Create: packages/backend/src/services/stats.ts

  • Create: packages/backend/src/services/stats.test.ts

  • Create: packages/backend/src/routes/stats.ts

  • Modify: packages/backend/src/app.ts

  • Step 1: Write failing tests

// stats.test.ts
import { describe, it, expect, beforeEach } from 'vitest';
import { makeTestDb } from '../tests/dbHelper.js';
import { createLesson } from './lessons.js';
import { createCard } from './cards.js';
import { startSession, recordAttempt, getNextItem, endSession } from './sessions.js';
import { getCardStats, getLessonStats, getOverview } from './stats.js';

let env: ReturnType<typeof makeTestDb>;
beforeEach(() => { env = makeTestDb(); });

describe('stats', () => {
  it('computes per-card attempts and box', async () => {
    const l = await createLesson(env.db, { name: 'L' });
    const c = await createCard(env.db, l.id, { question: 'q', answer: 'a' });
    const s = await startSession(env.db, { lessonId: l.id, shuffle: false });
    const item = await getNextItem(env.db, s.session.id);
    await recordAttempt(env.db, s.session.id, { cardId: item!.cardId, direction: 'forward', result: 'correct' });
    await endSession(env.db, s.session.id);
    const stats = await getCardStats(env.db, c.id);
    expect(stats.attempts).toBe(1);
    expect(stats.correct).toBe(1);
    expect(stats.box.forward).toBe(2);
  });

  it('aggregates lesson stats with descendants', async () => {
    const root = await createLesson(env.db, { name: 'R' });
    const child = await createLesson(env.db, { name: 'C', parentId: root.id });
    await createCard(env.db, child.id, { question: 'q', answer: 'a' });
    const s = await startSession(env.db, { lessonId: root.id, shuffle: false });
    const it = await getNextItem(env.db, s.session.id);
    await recordAttempt(env.db, s.session.id, { cardId: it!.cardId, direction: 'forward', result: 'correct' });
    await endSession(env.db, s.session.id);
    const ls = await getLessonStats(env.db, root.id);
    expect(ls.totalCards).toBe(1);
    expect(ls.sessions).toBe(1);
    expect(ls.totalDurationSeconds).toBeGreaterThanOrEqual(0);
  });

  it('overview returns streak ≥ 1 after a session today', async () => {
    const l = await createLesson(env.db, { name: 'L' });
    await createCard(env.db, l.id, { question: 'q', answer: 'a' });
    const s = await startSession(env.db, { lessonId: l.id });
    const it = await getNextItem(env.db, s.session.id);
    await recordAttempt(env.db, s.session.id, { cardId: it!.cardId, direction: 'forward', result: 'correct' });
    await endSession(env.db, s.session.id);
    const ov = await getOverview(env.db);
    expect(ov.totalSessions).toBe(1);
    expect(ov.streakDays).toBeGreaterThanOrEqual(1);
  });
});
  • Step 2: Run — fail

Run: npm -w @flashcard/backend test

  • Step 3: Implement services/stats.ts
import { and, desc, eq, inArray, sql } from 'drizzle-orm';
import type { Db } from '../db/client.js';
import { attempts, cardProgress, cards, lessons, sessions } from '../db/schema.js';
import { ApiError } from '../lib/errors.js';
import { getDescendantLessonIds } from './lessons.js';

const MIN_ATTEMPTS_FOR_SCORE = 3;

export interface CardStats {
  cardId: number;
  attempts: number;
  correct: number;
  incorrect: number;
  box: { forward: number; backward: number | null };
  lastShownAt: number | null;
  nextDueAt: number;
  history: { shownAt: number; result: 'correct' | 'incorrect'; direction: 'forward' | 'backward' }[];
}

export async function getCardStats(db: Db, cardId: number): Promise<CardStats> {
  const card = db.select().from(cards).where(eq(cards.id, cardId)).get();
  if (!card) throw ApiError.notFound('Card');
  const prog = db.select().from(cardProgress).where(eq(cardProgress.cardId, cardId)).all();
  const history = db.select({
    shownAt: attempts.shownAt, result: attempts.result, direction: attempts.direction,
  }).from(attempts).where(eq(attempts.cardId, cardId)).orderBy(desc(attempts.shownAt)).all();

  const forward = prog.find((p) => p.direction === 'forward');
  const backward = prog.find((p) => p.direction === 'backward');
  const correct = history.filter((h) => h.result === 'correct').length;
  return {
    cardId,
    attempts: history.length,
    correct,
    incorrect: history.length - correct,
    box: { forward: forward?.box ?? 1, backward: backward?.box ?? null },
    lastShownAt: forward?.lastShownAt ?? null,
    nextDueAt: forward?.nextDueAt ?? 0,
    history,
  };
}

export interface LessonStats {
  lessonId: number;
  totalCards: number;
  mastered: number;     // box >= 4
  score: number;        // 0..1, weighted
  sessions: number;
  totalDurationSeconds: number;
  attempts: number;
  correct: number;
  incorrect: number;
}

export async function getLessonStats(db: Db, lessonId: number): Promise<LessonStats> {
  const lesson = db.select().from(lessons).where(eq(lessons.id, lessonId)).get();
  if (!lesson) throw ApiError.notFound('Lesson');
  const ids = await getDescendantLessonIds(db, lessonId);
  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 mastered = 0;
  let attemptsTotal = 0;
  let correctTotal = 0;
  let score = 0;
  let countedForScore = 0;

  if (cardIds.length > 0) {
    const prog = db.select().from(cardProgress).where(inArray(cardProgress.cardId, cardIds)).all();
    const att = db.select().from(attempts).where(inArray(attempts.cardId, cardIds)).all();
    attemptsTotal = att.length;
    correctTotal = att.filter((a) => a.result === 'correct').length;
    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)) mastered += 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 >= MIN_ATTEMPTS_FOR_SCORE) {
        score += correct / total;
        countedForScore += 1;
      }
    }
    score = countedForScore === 0 ? 0 : score / countedForScore;
  }

  const sessRows = db.select({
    id: sessions.id, duration: sessions.durationSeconds,
  }).from(sessions).where(and(inArray(sessions.lessonId, ids), eq(sessions.status, 'completed'))).all();
  const totalDurationSeconds = sessRows.reduce((s, r) => s + (r.duration ?? 0), 0);

  return {
    lessonId, totalCards, mastered, score,
    sessions: sessRows.length, totalDurationSeconds,
    attempts: attemptsTotal, correct: correctTotal, incorrect: attemptsTotal - correctTotal,
  };
}

export interface Overview {
  totalSessions: number;
  totalDurationSeconds: number;
  totalAttempts: number;
  streakDays: number;
  recentSessions: { id: number; lessonId: number; startedAt: number; durationSeconds: number | null; cardsShown: number; cardsCorrect: number }[];
}

function dayKeyUTC(unixSec: number): string {
  const d = new Date(unixSec * 1000);
  return `${d.getUTCFullYear()}-${d.getUTCMonth()}-${d.getUTCDate()}`;
}

export async function getOverview(db: Db): Promise<Overview> {
  const sessRows = db.select().from(sessions).where(eq(sessions.status, 'completed')).all();
  const totalDurationSeconds = sessRows.reduce((s, r) => s + (r.durationSeconds ?? 0), 0);
  const totalAttempts = db.select({ c: sql<number>`count(*)`.as('c') }).from(attempts).get()?.c ?? 0;

  // streak
  const days = new Set(sessRows.map((s) => dayKeyUTC(s.startedAt)));
  let streak = 0;
  let cursor = new Date();
  for (;;) {
    const k = dayKeyUTC(Math.floor(cursor.getTime() / 1000));
    if (days.has(k)) {
      streak += 1;
      cursor.setUTCDate(cursor.getUTCDate() - 1);
    } else break;
  }

  const recent = db.select({
    id: sessions.id, lessonId: sessions.lessonId, startedAt: sessions.startedAt,
    durationSeconds: sessions.durationSeconds, cardsShown: sessions.cardsShown, cardsCorrect: sessions.cardsCorrect,
  }).from(sessions).where(eq(sessions.status, 'completed')).orderBy(desc(sessions.startedAt)).limit(10).all();

  return {
    totalSessions: sessRows.length,
    totalDurationSeconds,
    totalAttempts: Number(totalAttempts),
    streakDays: streak,
    recentSessions: recent.map((r) => ({ ...r, durationSeconds: r.durationSeconds ?? null })),
  };
}

export interface HeatmapPoint { day: string; sessions: number; attempts: number; }
export async function getHeatmap(db: Db, weeks: number): Promise<HeatmapPoint[]> {
  const since = Math.floor(Date.now() / 1000) - weeks * 7 * 24 * 60 * 60;
  const sessRows = db.select({ startedAt: sessions.startedAt }).from(sessions)
    .where(and(eq(sessions.status, 'completed'), sql`${sessions.startedAt} >= ${since}`)).all();
  const attRows = db.select({ shownAt: attempts.shownAt }).from(attempts)
    .where(sql`${attempts.shownAt} >= ${since}`).all();
  const map = new Map<string, { sessions: number; attempts: number }>();
  for (const s of sessRows) {
    const k = dayKeyUTC(s.startedAt);
    const m = map.get(k) ?? { sessions: 0, attempts: 0 };
    m.sessions += 1; map.set(k, m);
  }
  for (const a of attRows) {
    const k = dayKeyUTC(a.shownAt);
    const m = map.get(k) ?? { sessions: 0, attempts: 0 };
    m.attempts += 1; map.set(k, m);
  }
  return Array.from(map.entries()).map(([day, v]) => ({ day, ...v }));
}
  • Step 4: Run tests — pass

Run: npm -w @flashcard/backend test

  • Step 5: Create routes/stats.ts
import { Router } from 'express';
import type { Db } from '../db/client.js';
import { getCardStats, getHeatmap, getLessonStats, getOverview } 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)); } catch (e) { next(e); }
  });
  r.get('/lessons/:id', async (req, res, next) => {
    try { res.json(await getLessonStats(db, Number(req.params.id))); } catch (e) { next(e); }
  });
  r.get('/cards/:id', async (req, res, next) => {
    try { res.json(await getCardStats(db, 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 ?? 12)));
      res.json(await getHeatmap(db, weeks));
    } catch (e) { next(e); }
  });
  return r;
}
  • Step 6: Mount in app.ts

Add import { statsRouter } from './routes/stats.js'; and app.use('/api/stats', statsRouter(db));.

  • Step 7: Commit
git add -A
git commit -m "feat(backend): stats service and routes"

Task 12: Excel import & export

Files:

  • Create: packages/backend/src/services/import.ts

  • Create: packages/backend/src/services/import.test.ts

  • Modify: packages/backend/src/routes/cards.ts

  • Step 1: Write failing tests

// import.test.ts
import { describe, it, expect, beforeEach } from 'vitest';
import * as XLSX from 'xlsx';
import { makeTestDb } from '../tests/dbHelper.js';
import { createLesson } from './lessons.js';
import { importCardsFromBuffer, exportCardsToBuffer } from './import.js';
import { listCardsByLesson } from './cards.js';

let env: ReturnType<typeof makeTestDb>;
beforeEach(() => { env = makeTestDb(); });

function buildXlsx(rows: Record<string, string>[]): Buffer {
  const ws = XLSX.utils.json_to_sheet(rows);
  const wb = XLSX.utils.book_new();
  XLSX.utils.book_append_sheet(wb, ws, 'cards');
  return XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' }) as Buffer;
}

describe('excel import', () => {
  it('imports cards into the target lesson when no lesson_path column present', async () => {
    const l = await createLesson(env.db, { name: 'L' });
    const buf = buildXlsx([
      { question: 'q1', answer: 'a1' },
      { question: 'q2', answer: 'a2', hint: 'tip' },
    ]);
    const result = await importCardsFromBuffer(env.db, l.id, buf, { updateExisting: true, createMissingLessons: false });
    expect(result.inserted).toBe(2);
    expect(result.errors).toHaveLength(0);
    expect(await listCardsByLesson(env.db, l.id)).toHaveLength(2);
  });

  it('creates intermediate lessons from lesson_path when allowed', async () => {
    const root = await createLesson(env.db, { name: 'Spaans' });
    const buf = buildXlsx([
      { question: 'hola', answer: 'hello', lesson_path: 'Spaans/Begroetingen' },
    ]);
    const result = await importCardsFromBuffer(env.db, root.id, buf, { updateExisting: true, createMissingLessons: true });
    expect(result.inserted).toBe(1);
  });

  it('updates an existing card on duplicate question in same lesson', async () => {
    const l = await createLesson(env.db, { name: 'L' });
    await importCardsFromBuffer(env.db, l.id, buildXlsx([{ question: 'q', answer: 'a' }]), { updateExisting: true, createMissingLessons: false });
    const res = await importCardsFromBuffer(env.db, l.id, buildXlsx([{ question: 'q', answer: 'b' }]), { updateExisting: true, createMissingLessons: false });
    expect(res.updated).toBe(1);
    const cards = await listCardsByLesson(env.db, l.id);
    expect(cards[0].answer).toBe('b');
  });

  it('exports a buffer that round-trips back via import', async () => {
    const l = await createLesson(env.db, { name: 'L' });
    await importCardsFromBuffer(env.db, l.id, buildXlsx([{ question: 'q', answer: 'a' }]), { updateExisting: true, createMissingLessons: false });
    const buf = await exportCardsToBuffer(env.db, l.id, false);
    const wb = XLSX.read(buf, { type: 'buffer' });
    const rows = XLSX.utils.sheet_to_json(wb.Sheets[wb.SheetNames[0]!]!);
    expect(rows).toHaveLength(1);
  });
});
  • Step 2: Run — fail

Run: npm -w @flashcard/backend test

  • Step 3: Implement services/import.ts
import { and, eq, inArray, sql } from 'drizzle-orm';
import * as XLSX from 'xlsx';
import type { Db } from '../db/client.js';
import { cardProgress, cards, lessons } from '../db/schema.js';
import { getDescendantLessonIds } from './lessons.js';

export interface ImportRow {
  question: string;
  answer: string;
  hint?: string;
  lesson_path?: string;
}
export interface ImportOptions {
  updateExisting: boolean;
  createMissingLessons: boolean;
}
export interface ImportResult {
  inserted: number;
  updated: number;
  skipped: number;
  errors: { row: number; message: string }[];
}

function parseSheet(buf: Buffer): ImportRow[] {
  const wb = XLSX.read(buf, { type: 'buffer' });
  const sheet = wb.Sheets[wb.SheetNames[0]!];
  if (!sheet) return [];
  return XLSX.utils.sheet_to_json<ImportRow>(sheet, { defval: '' });
}

function nowSec() { return Math.floor(Date.now() / 1000); }

async function resolveLesson(db: Db, defaultLessonId: number, lessonPath: string | undefined, createMissing: boolean): Promise<number | null> {
  if (!lessonPath || lessonPath.trim() === '') return defaultLessonId;
  const parts = lessonPath.split('/').map((s) => s.trim()).filter(Boolean);
  if (parts.length === 0) return defaultLessonId;
  let parentId: number | null = null;
  for (const name of parts) {
    const found = db.select().from(lessons)
      .where(sql`${lessons.name} = ${name} AND (${lessons.parentId} IS ${parentId === null ? sql`NULL` : sql`${parentId}`})`)
      .get();
    if (found) { parentId = found.id; continue; }
    if (!createMissing) return null;
    const [row] = db.insert(lessons).values({ name, parentId, position: 0 }).returning().all();
    parentId = row.id;
  }
  return parentId;
}

export async function importCardsFromBuffer(
  db: Db, defaultLessonId: number, buffer: Buffer, opts: ImportOptions
): Promise<ImportResult> {
  const rows = parseSheet(buffer);
  const result: ImportResult = { inserted: 0, updated: 0, skipped: 0, errors: [] };
  for (let i = 0; i < rows.length; i++) {
    const row = rows[i]!;
    const rowNum = i + 2;     // header is row 1
    const q = String(row.question ?? '').trim();
    const a = String(row.answer ?? '').trim();
    if (!q || !a) {
      result.errors.push({ row: rowNum, message: 'question and answer are required' });
      continue;
    }
    const targetLessonId = await resolveLesson(db, defaultLessonId, row.lesson_path, opts.createMissingLessons);
    if (targetLessonId === null) {
      result.errors.push({ row: rowNum, message: `lesson_path not found: ${row.lesson_path}` });
      continue;
    }
    const existing = db.select().from(cards).where(and(eq(cards.lessonId, targetLessonId), eq(cards.question, q))).get();
    const hint = row.hint && String(row.hint).trim() !== '' ? String(row.hint) : null;
    if (existing) {
      if (!opts.updateExisting) { result.skipped += 1; continue; }
      db.update(cards).set({ answer: a, hint, updatedAt: nowSec() }).where(eq(cards.id, existing.id)).run();
      result.updated += 1;
    } else {
      const positions = db.select({ pos: cards.position }).from(cards).where(eq(cards.lessonId, targetLessonId)).all();
      const position = positions.length === 0 ? 0 : Math.max(...positions.map((p) => p.pos)) + 1;
      const [inserted] = db.insert(cards).values({ lessonId: targetLessonId, question: q, answer: a, hint, position }).returning().all();
      db.insert(cardProgress).values({ cardId: inserted.id, direction: 'forward', box: 1, nextDueAt: 0 }).run();
      const lesson = db.select().from(lessons).where(eq(lessons.id, targetLessonId)).get();
      if (lesson?.bidirectional) {
        db.insert(cardProgress).values({ cardId: inserted.id, direction: 'backward', box: 1, nextDueAt: 0 }).run();
      }
      result.inserted += 1;
    }
  }
  return result;
}

export async function exportCardsToBuffer(db: Db, lessonId: number, includeDescendants: boolean): Promise<Buffer> {
  const ids = includeDescendants ? await getDescendantLessonIds(db, lessonId) : [lessonId];
  const lessonRows = db.select().from(lessons).where(inArray(lessons.id, ids)).all();
  const pathById = buildPathMap(lessonRows);
  const cardRows = db.select().from(cards).where(inArray(cards.lessonId, ids)).all();
  const data = cardRows.map((c) => ({
    question: c.question,
    answer: c.answer,
    hint: c.hint ?? '',
    lesson_path: pathById.get(c.lessonId) ?? '',
  }));
  const ws = XLSX.utils.json_to_sheet(data);
  const wb = XLSX.utils.book_new();
  XLSX.utils.book_append_sheet(wb, ws, 'cards');
  return XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' }) as Buffer;
}

function buildPathMap(allLessons: { id: number; name: string; parentId: number | null }[]): Map<number, string> {
  const byId = new Map(allLessons.map((l) => [l.id, l]));
  const cache = new Map<number, string>();
  function path(id: number): string {
    if (cache.has(id)) return cache.get(id)!;
    const l = byId.get(id);
    if (!l) return '';
    const p = l.parentId === null ? l.name : `${path(l.parentId)}/${l.name}`;
    cache.set(id, p);
    return p;
  }
  for (const l of allLessons) path(l.id);
  return cache;
}
  • Step 4: Run tests — pass

Run: npm -w @flashcard/backend test

  • Step 5: Add routes to routes/cards.ts

Append inside cardsRouter(db) before return r;:

import multer from 'multer';
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 10 * 1024 * 1024 } });

r.post('/lessons/:lessonId/cards/import', upload.single('file'), async (req, res, next) => {
  try {
    if (!req.file) throw ApiError.validation('file is required');
    const updateExisting = req.body.updateExisting !== 'false';
    const createMissingLessons = req.body.createMissingLessons === 'true';
    const result = await importCardsFromBuffer(
      db, Number(req.params.lessonId), req.file.buffer, { updateExisting, createMissingLessons }
    );
    res.json(result);
  } catch (e) { next(e); }
});

r.get('/lessons/:lessonId/cards/export', async (req, res, next) => {
  try {
    const includeDescendants = req.query.include_descendants === 'true';
    const buf = await exportCardsToBuffer(db, Number(req.params.lessonId), includeDescendants);
    res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
    res.setHeader('Content-Disposition', `attachment; filename="cards-lesson-${req.params.lessonId}.xlsx"`);
    res.send(buf);
  } catch (e) { next(e); }
});

Add imports at top of routes/cards.ts:

import multer from 'multer';
import { ApiError } from '../lib/errors.js';
import { exportCardsToBuffer, importCardsFromBuffer } from '../services/import.js';
  • Step 6: Commit
git add -A
git commit -m "feat(backend): excel import and export"

Task 13: Production static frontend serving

Files:

  • Modify: packages/backend/src/app.ts

  • Step 1: Serve built frontend when present

Add at the bottom of createApp (before the error handler):

import { existsSync } from 'node:fs';
import { resolve } from 'node:path';
import express from 'express';
// ...
const frontendDist = resolve(import.meta.dirname, '../../frontend/dist');
if (existsSync(frontendDist)) {
  app.use(express.static(frontendDist));
  app.get('*', (_req, res) => res.sendFile(resolve(frontendDist, 'index.html')));
}
  • Step 2: Commit
git add -A
git commit -m "feat(backend): serve built frontend in production"

Task 14: Frontend bootstrap

Files:

  • Create: packages/frontend/package.json

  • Create: packages/frontend/tsconfig.json

  • Create: packages/frontend/vite.config.ts

  • Create: packages/frontend/tailwind.config.ts

  • Create: packages/frontend/postcss.config.js

  • Create: packages/frontend/index.html

  • Create: packages/frontend/src/main.tsx

  • Create: packages/frontend/src/App.tsx

  • Create: packages/frontend/src/router.tsx

  • Create: packages/frontend/src/styles.css

  • Step 1: Create packages/frontend/package.json

{
  "name": "@flashcard/frontend",
  "version": "0.1.0",
  "private": true,
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "tsc --noEmit && vite build",
    "preview": "vite preview",
    "typecheck": "tsc --noEmit",
    "test": "vitest run"
  },
  "dependencies": {
    "@flashcard/shared": "*",
    "canvas-confetti": "^1.9.0",
    "framer-motion": "^11.0.0",
    "react": "^18.3.0",
    "react-dom": "^18.3.0",
    "react-router-dom": "^6.26.0",
    "zustand": "^4.5.0"
  },
  "devDependencies": {
    "@testing-library/jest-dom": "^6.5.0",
    "@testing-library/react": "^16.0.0",
    "@types/canvas-confetti": "^1.6.0",
    "@types/react": "^18.3.0",
    "@types/react-dom": "^18.3.0",
    "@vitejs/plugin-react": "^4.3.0",
    "autoprefixer": "^10.4.0",
    "jsdom": "^25.0.0",
    "postcss": "^8.4.0",
    "tailwindcss": "^3.4.0",
    "typescript": "^5.5.0",
    "vite": "^7.0.0",
    "vitest": "^2.0.0"
  }
}
  • Step 2: Create tsconfig.json
{
  "extends": "../../tsconfig.base.json",
  "compilerOptions": {
    "jsx": "react-jsx",
    "lib": ["ES2022", "DOM", "DOM.Iterable"],
    "moduleResolution": "Bundler"
  },
  "include": ["src/**/*", "index.html"]
}
  • Step 3: Create vite.config.ts
import react from '@vitejs/plugin-react';
import { defineConfig } from 'vite';

export default defineConfig({
  plugins: [react()],
  server: {
    port: 5173,
    proxy: { '/api': 'http://localhost:3000' },
  },
  test: { environment: 'jsdom', setupFiles: ['./src/test-setup.ts'] },
});
  • Step 4: Create tailwind.config.ts
import type { Config } from 'tailwindcss';
export default {
  content: ['./index.html', './src/**/*.{ts,tsx}'],
  darkMode: 'class',
  theme: {
    extend: {
      animation: {
        'flip': 'flip 0.4s ease-out forwards',
      },
      keyframes: {
        flip: {
          '0%': { transform: 'rotateY(0)' },
          '100%': { transform: 'rotateY(180deg)' },
        },
      },
    },
  },
  plugins: [],
} satisfies Config;
  • Step 5: Create postcss.config.js
export default { plugins: { tailwindcss: {}, autoprefixer: {} } };
  • Step 6: Create index.html
<!doctype html>
<html lang="nl" class="h-full">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Flashcards</title>
  </head>
  <body class="h-full bg-slate-50 text-slate-900 dark:bg-slate-950 dark:text-slate-100">
    <div id="root" class="h-full"></div>
    <script type="module" src="/src/main.tsx"></script>
  </body>
</html>
  • Step 7: Create src/styles.css
@tailwind base;
@tailwind components;
@tailwind utilities;

html, body, #root { height: 100%; }
.card-perspective { perspective: 1000px; }
.card-face { backface-visibility: hidden; }
  • Step 8: Create src/main.tsx
import React from 'react';
import { createRoot } from 'react-dom/client';
import { RouterProvider } from 'react-router-dom';
import { router } from './router.js';
import './styles.css';

const root = createRoot(document.getElementById('root')!);
root.render(
  <React.StrictMode>
    <RouterProvider router={router} />
  </React.StrictMode>
);
  • Step 9: Create src/router.tsx (skeleton; pages added in later tasks)
import { createBrowserRouter, Navigate } from 'react-router-dom';
import { Layout } from './components/Layout.js';

export const router = createBrowserRouter([
  {
    path: '/',
    element: <Layout />,
    children: [
      { index: true, element: <div className="p-6">Dashboard placeholder</div> },
      { path: 'admin', element: <div className="p-6">Admin placeholder</div> },
      { path: '*', element: <Navigate to="/" replace /> },
    ],
  },
]);
  • Step 10: Create src/components/Layout.tsx
import { Link, Outlet } from 'react-router-dom';

export function Layout() {
  return (
    <div className="flex h-full flex-col">
      <header className="border-b border-slate-200 bg-white px-6 py-3 dark:border-slate-800 dark:bg-slate-900">
        <nav className="flex gap-4 text-sm">
          <Link to="/" className="font-semibold">Flashcards</Link>
          <Link to="/admin">Admin</Link>
          <Link to="/stats">Stats</Link>
        </nav>
      </header>
      <main className="flex-1 overflow-auto"><Outlet /></main>
    </div>
  );
}
  • Step 11: Create src/test-setup.ts
import '@testing-library/jest-dom/vitest';
  • Step 12: Install

Run: npm install

  • Step 13: Smoke test

Run: npm run dev from repo root. Open http://localhost:5173. You should see the header with Dashboard placeholder.

  • Step 14: Commit
git add -A
git commit -m "feat(frontend): bootstrap React + Vite + Tailwind + Router + Layout"

Task 15: Frontend API client

Files:

  • Create: packages/frontend/src/api/client.ts

  • Create: packages/frontend/src/api/lessons.ts

  • Create: packages/frontend/src/api/cards.ts

  • Create: packages/frontend/src/api/sessions.ts

  • Create: packages/frontend/src/api/stats.ts

  • Step 1: Create api/client.ts

export class ApiClientError extends Error {
  constructor(public status: number, public code: string, message: string, public details?: unknown) {
    super(message);
  }
}

async function request<T>(method: string, path: string, body?: unknown, opts?: { isFormData?: boolean }): Promise<T> {
  const headers: Record<string, string> = {};
  let payload: BodyInit | undefined;
  if (opts?.isFormData) {
    payload = body as FormData;
  } else if (body !== undefined) {
    headers['Content-Type'] = 'application/json';
    payload = JSON.stringify(body);
  }
  const res = await fetch(`/api${path}`, { method, headers, body: payload });
  if (res.status === 204) return undefined as T;
  const isJson = res.headers.get('content-type')?.includes('application/json');
  const data = isJson ? await res.json() : await res.blob();
  if (!res.ok) {
    const e = (data as { error?: { code: string; message: string; details?: unknown } }).error;
    throw new ApiClientError(res.status, e?.code ?? 'UNKNOWN', e?.message ?? 'Request failed', e?.details);
  }
  return data as T;
}

export const api = {
  get: <T>(path: string) => request<T>('GET', path),
  post: <T>(path: string, body?: unknown) => request<T>('POST', path, body),
  postForm: <T>(path: string, form: FormData) => request<T>('POST', path, form, { isFormData: true }),
  patch: <T>(path: string, body: unknown) => request<T>('PATCH', path, body),
  delete: <T>(path: string) => request<T>('DELETE', path),
  getBlob: async (path: string): Promise<Blob> => {
    const res = await fetch(`/api${path}`);
    if (!res.ok) throw new ApiClientError(res.status, 'UNKNOWN', 'Request failed');
    return res.blob();
  },
};
  • Step 2: Create api/lessons.ts
import type { Lesson, LessonCreateInput, LessonMoveInput, LessonTreeNode, LessonUpdateInput } from '@flashcard/shared';
import { api } from './client.js';

export const lessonsApi = {
  tree: () => api.get<LessonTreeNode[]>('/lessons/tree'),
  create: (input: LessonCreateInput) => api.post<Lesson>('/lessons', input),
  update: (id: number, input: LessonUpdateInput) => api.patch<Lesson>(`/lessons/${id}`, input),
  remove: (id: number) => api.delete<void>(`/lessons/${id}`),
  move: (id: number, input: LessonMoveInput) => api.post<Lesson>(`/lessons/${id}/move`, input),
};
  • Step 3: Create api/cards.ts
import type { Card, CardCreateInput, CardUpdateInput } from '@flashcard/shared';
import { api } from './client.js';

export interface ImportResult {
  inserted: number; updated: number; skipped: number; errors: { row: number; message: string }[];
}

export const cardsApi = {
  list: (lessonId: number) => api.get<Card[]>(`/lessons/${lessonId}/cards`),
  create: (lessonId: number, input: CardCreateInput) => api.post<Card>(`/lessons/${lessonId}/cards`, input),
  update: (id: number, input: CardUpdateInput) => api.patch<Card>(`/cards/${id}`, input),
  remove: (id: number) => api.delete<void>(`/cards/${id}`),
  importXlsx: (lessonId: number, file: File, opts: { updateExisting: boolean; createMissingLessons: boolean }) => {
    const fd = new FormData();
    fd.append('file', file);
    fd.append('updateExisting', String(opts.updateExisting));
    fd.append('createMissingLessons', String(opts.createMissingLessons));
    return api.postForm<ImportResult>(`/lessons/${lessonId}/cards/import`, fd);
  },
  exportUrl: (lessonId: number, includeDescendants: boolean) =>
    `/api/lessons/${lessonId}/cards/export?include_descendants=${includeDescendants}`,
};
  • Step 4: Create api/sessions.ts
import type { QueueItem, SessionRow, SessionStartInput, AttemptCreateInput } from '@flashcard/shared';
import { api } from './client.js';

export interface StartedSession { session: SessionRow; queue: QueueItem[]; }
export interface SessionState { session: SessionRow; queue: QueueItem[]; index: number; }

export const sessionsApi = {
  start: (input: SessionStartInput) => api.post<StartedSession>('/sessions', input),
  active: () => api.get<SessionRow | null>('/sessions/active'),
  state: (id: number) => api.get<SessionState>(`/sessions/${id}`),
  next: (id: number) => api.get<{ done: true } | { done: false; item: QueueItem }>(`/sessions/${id}/next`),
  attempt: (id: number, input: AttemptCreateInput) => api.post<void>(`/sessions/${id}/attempts`, input),
  end: (id: number) => api.post<SessionRow>(`/sessions/${id}/end`),
  abandon: (id: number) => api.post<SessionRow>(`/sessions/${id}/abandon`),
};
  • Step 5: Create api/stats.ts
import { api } from './client.js';

export interface Overview {
  totalSessions: number; totalDurationSeconds: number; totalAttempts: number; streakDays: number;
  recentSessions: { id: number; lessonId: number; startedAt: number; durationSeconds: number | null; cardsShown: number; cardsCorrect: number }[];
}
export interface LessonStats {
  lessonId: number; totalCards: number; mastered: number; score: number;
  sessions: number; totalDurationSeconds: number; attempts: number; correct: number; incorrect: number;
}
export interface CardStats {
  cardId: number; attempts: number; correct: number; incorrect: number;
  box: { forward: number; backward: number | null };
  lastShownAt: number | null; nextDueAt: number;
  history: { shownAt: number; result: 'correct' | 'incorrect'; direction: 'forward' | 'backward' }[];
}

export const statsApi = {
  overview: () => api.get<Overview>('/stats/overview'),
  lesson: (id: number) => api.get<LessonStats>(`/stats/lessons/${id}`),
  card: (id: number) => api.get<CardStats>(`/stats/cards/${id}`),
  heatmap: (weeks = 12) => api.get<{ day: string; sessions: number; attempts: number }[]>(`/stats/heatmap?weeks=${weeks}`),
};
  • Step 6: Commit
git add -A
git commit -m "feat(frontend): API client modules"

Task 16: Frontend stores (Zustand)

Files:

  • Create: packages/frontend/src/stores/lessonsStore.ts

  • Create: packages/frontend/src/stores/sessionStore.ts

  • Create: packages/frontend/src/stores/settingsStore.ts

  • Step 1: Create stores/settingsStore.ts

import { create } from 'zustand';

interface SettingsState {
  theme: 'light' | 'dark';
  defaultMaxCards: number;
  toggleTheme: () => void;
  setDefaultMaxCards: (n: number) => void;
  hydrate: () => void;
}

const KEY = 'flashcard:settings';

export const useSettings = create<SettingsState>((set, get) => ({
  theme: 'light',
  defaultMaxCards: 20,
  toggleTheme: () => {
    const theme = get().theme === 'light' ? 'dark' : 'light';
    set({ theme });
    document.documentElement.classList.toggle('dark', theme === 'dark');
    localStorage.setItem(KEY, JSON.stringify({ theme, defaultMaxCards: get().defaultMaxCards }));
  },
  setDefaultMaxCards: (n) => {
    set({ defaultMaxCards: n });
    localStorage.setItem(KEY, JSON.stringify({ theme: get().theme, defaultMaxCards: n }));
  },
  hydrate: () => {
    try {
      const raw = localStorage.getItem(KEY);
      if (!raw) return;
      const parsed = JSON.parse(raw) as { theme: 'light' | 'dark'; defaultMaxCards: number };
      set(parsed);
      document.documentElement.classList.toggle('dark', parsed.theme === 'dark');
    } catch { /* ignore */ }
  },
}));
  • Step 2: Call hydrate in main.tsx

Modify main.tsx:

import { useSettings } from './stores/settingsStore.js';
useSettings.getState().hydrate();

(Add immediately before createRoot.)

  • Step 3: Create stores/lessonsStore.ts
import { create } from 'zustand';
import type { LessonTreeNode } from '@flashcard/shared';
import { lessonsApi } from '../api/lessons.js';

interface LessonsState {
  tree: LessonTreeNode[];
  loading: boolean;
  refresh: () => Promise<void>;
}

export const useLessons = create<LessonsState>((set) => ({
  tree: [],
  loading: false,
  refresh: async () => {
    set({ loading: true });
    try { set({ tree: await lessonsApi.tree() }); }
    finally { set({ loading: false }); }
  },
}));
  • Step 4: Create stores/sessionStore.ts
import { create } from 'zustand';
import type { SessionRow, QueueItem } from '@flashcard/shared';
import { sessionsApi } from '../api/sessions.js';

interface SessionState {
  session: SessionRow | null;
  current: QueueItem | null;
  done: boolean;
  showAnswer: boolean;
  shownAt: number | null;
  start: (input: { lessonId: number; maxCards: number | null; shuffle: boolean; direction: 'forward' | 'backward' | 'both' }) => Promise<void>;
  reveal: () => void;
  answer: (result: 'correct' | 'incorrect') => Promise<void>;
  end: () => Promise<void>;
  abandon: () => Promise<void>;
  reset: () => void;
}

export const useSession = create<SessionState>((set, get) => ({
  session: null, current: null, done: false, showAnswer: false, shownAt: null,

  start: async (input) => {
    const { session } = await sessionsApi.start(input);
    const nx = await sessionsApi.next(session.id);
    set({
      session,
      done: nx.done,
      current: nx.done ? null : nx.item,
      showAnswer: false,
      shownAt: Date.now(),
    });
  },

  reveal: () => set({ showAnswer: true }),

  answer: async (result) => {
    const s = get();
    if (!s.session || !s.current) return;
    const ttm = s.shownAt ? Date.now() - s.shownAt : null;
    await sessionsApi.attempt(s.session.id, {
      cardId: s.current.cardId, direction: s.current.direction, result, timeToAnswerMs: ttm,
    });
    const nx = await sessionsApi.next(s.session.id);
    if (nx.done) {
      set({ done: true, current: null, showAnswer: false });
    } else {
      set({ current: nx.item, showAnswer: false, shownAt: Date.now() });
    }
  },

  end: async () => {
    const s = get();
    if (!s.session) return;
    const finished = await sessionsApi.end(s.session.id);
    set({ session: finished });
  },

  abandon: async () => {
    const s = get();
    if (!s.session) return;
    await sessionsApi.abandon(s.session.id);
    set({ session: null, current: null, done: false, showAnswer: false });
  },

  reset: () => set({ session: null, current: null, done: false, showAnswer: false, shownAt: null }),
}));
  • Step 5: Commit
git add -A
git commit -m "feat(frontend): zustand stores for lessons, session, settings"

Task 17: Admin — Lessons tree

Files:

  • Create: packages/frontend/src/pages/Admin.tsx

  • Create: packages/frontend/src/components/LessonTree.tsx

  • Modify: packages/frontend/src/router.tsx

  • Step 1: Create components/LessonTree.tsx

import { 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';

export function LessonTree({ nodes, depth = 0 }: { nodes: LessonTreeNode[]; depth?: number }) {
  const refresh = useLessons((s) => s.refresh);
  const [addingTo, setAddingTo] = useState<number | null>(null);
  const [name, setName] = useState('');

  async function addChild(parentId: number | null) {
    if (!name.trim()) return;
    await lessonsApi.create({ name: name.trim(), parentId });
    setName(''); setAddingTo(null);
    await refresh();
  }

  async function rename(id: number, current: string) {
    const next = prompt('Nieuwe naam', current);
    if (next && next.trim() && next !== current) {
      await lessonsApi.update(id, { name: next.trim() });
      await refresh();
    }
  }

  async function remove(id: number) {
    if (!confirm('Verwijder les en alle onderliggende lessen en kaarten?')) return;
    await lessonsApi.remove(id);
    await refresh();
  }

  return (
    <ul className="space-y-1">
      {nodes.map((n) => (
        <li key={n.id} style={{ paddingLeft: depth * 16 }}>
          <div className="group flex items-center gap-2 rounded px-2 py-1 hover:bg-slate-100 dark:hover:bg-slate-800">
            <Link to={`/admin/lessons/${n.id}`} className="flex-1">
              {n.name} <span className="text-xs text-slate-500">({n.cardCount})</span>
            </Link>
            <button className="text-xs opacity-0 group-hover:opacity-100" onClick={() => setAddingTo(n.id)}>+ subles</button>
            <button className="text-xs opacity-0 group-hover:opacity-100" onClick={() => rename(n.id, n.name)}>rename</button>
            <button className="text-xs text-red-600 opacity-0 group-hover:opacity-100" onClick={() => remove(n.id)}>delete</button>
          </div>
          {addingTo === n.id && (
            <div className="ml-6 flex gap-1 py-1">
              <input className="rounded border px-2 py-1 text-sm dark:bg-slate-900" value={name} onChange={(e) => setName(e.target.value)} placeholder="Naam" />
              <button className="rounded bg-blue-600 px-2 py-1 text-sm text-white" onClick={() => addChild(n.id)}>Toevoegen</button>
              <button className="text-sm" onClick={() => setAddingTo(null)}>Annuleren</button>
            </div>
          )}
          {n.children.length > 0 && <LessonTree nodes={n.children} depth={depth + 1} />}
        </li>
      ))}
    </ul>
  );
}
  • Step 2: Create pages/Admin.tsx
import { useEffect, useState } from 'react';
import { lessonsApi } from '../api/lessons.js';
import { useLessons } from '../stores/lessonsStore.js';
import { LessonTree } from '../components/LessonTree.js';

export function AdminPage() {
  const { tree, refresh, loading } = useLessons();
  const [newRoot, setNewRoot] = 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 p-6">
      <h1 className="mb-4 text-2xl font-semibold">Lessen beheer</h1>
      <div className="mb-4 flex gap-2">
        <input className="flex-1 rounded border px-3 py-2 dark:bg-slate-900" placeholder="Nieuwe wortel-les..." value={newRoot} onChange={(e) => setNewRoot(e.target.value)} />
        <button className="rounded bg-blue-600 px-4 py-2 text-white" onClick={addRoot}>Toevoegen</button>
      </div>
      {loading ? <p>Laden...</p> : <LessonTree nodes={tree} />}
    </div>
  );
}
  • Step 3: Mount in router

Replace /admin route in router.tsx:

{ path: 'admin', element: <AdminPage /> },

And import: import { AdminPage } from './pages/Admin.js';

  • Step 4: Manual smoke

Run dev server. Navigate to /admin. Add a root lesson, then a sub-lesson, rename one, delete one. All operations should refresh the tree.

  • Step 5: Commit
git add -A
git commit -m "feat(frontend): admin lesson tree CRUD"

Task 18: Admin — Card management + Import/Export

Files:

  • Create: packages/frontend/src/pages/AdminLesson.tsx

  • Create: packages/frontend/src/components/CardTable.tsx

  • Create: packages/frontend/src/components/ImportDialog.tsx

  • Modify: packages/frontend/src/router.tsx

  • Step 1: Create components/CardTable.tsx

import { useState } from 'react';
import type { Card, CardCreateInput } from '@flashcard/shared';
import { cardsApi } from '../api/cards.js';

export function CardTable({ lessonId, cards, onChange }: { lessonId: number; cards: Card[]; onChange: () => void }) {
  const [draft, setDraft] = useState<CardCreateInput>({ question: '', answer: '', hint: '' });

  async function add() {
    if (!draft.question.trim() || !draft.answer.trim()) return;
    await cardsApi.create(lessonId, { question: draft.question.trim(), answer: draft.answer.trim(), hint: draft.hint?.trim() || null });
    setDraft({ question: '', answer: '', hint: '' });
    onChange();
  }
  async function update(c: Card, field: 'question' | 'answer' | 'hint', value: string) {
    await cardsApi.update(c.id, { [field]: value || null });
    onChange();
  }
  async function remove(id: number) {
    if (!confirm('Verwijder kaart?')) return;
    await cardsApi.remove(id);
    onChange();
  }

  return (
    <table className="w-full text-sm">
      <thead><tr className="text-left text-slate-500">
        <th className="py-2">Vraag</th><th>Antwoord</th><th>Hint</th><th></th>
      </tr></thead>
      <tbody>
        {cards.map((c) => (
          <tr key={c.id} className="border-t border-slate-200 dark:border-slate-800">
            <td><input className="w-full bg-transparent py-1" defaultValue={c.question} onBlur={(e) => update(c, 'question', e.target.value)} /></td>
            <td><input className="w-full bg-transparent py-1" defaultValue={c.answer} onBlur={(e) => update(c, 'answer', e.target.value)} /></td>
            <td><input className="w-full bg-transparent py-1" defaultValue={c.hint ?? ''} onBlur={(e) => update(c, 'hint', e.target.value)} /></td>
            <td><button className="text-xs text-red-600" onClick={() => remove(c.id)}>x</button></td>
          </tr>
        ))}
        <tr className="border-t border-slate-200 dark:border-slate-800">
          <td><input className="w-full bg-transparent py-1" placeholder="Nieuwe vraag" value={draft.question} onChange={(e) => setDraft({ ...draft, question: e.target.value })} /></td>
          <td><input className="w-full bg-transparent py-1" placeholder="Antwoord" value={draft.answer} onChange={(e) => setDraft({ ...draft, answer: e.target.value })} /></td>
          <td><input className="w-full bg-transparent py-1" placeholder="Hint (optioneel)" value={draft.hint ?? ''} onChange={(e) => setDraft({ ...draft, hint: e.target.value })} /></td>
          <td><button className="rounded bg-blue-600 px-2 py-1 text-xs text-white" onClick={add}>+</button></td>
        </tr>
      </tbody>
    </table>
  );
}
  • Step 2: Create components/ImportDialog.tsx
import { useState } from 'react';
import { cardsApi, type ImportResult } from '../api/cards.js';

export function ImportDialog({ lessonId, onClose, onDone }: { lessonId: number; onClose: () => void; onDone: () => void }) {
  const [file, setFile] = useState<File | null>(null);
  const [updateExisting, setUpdateExisting] = useState(true);
  const [createMissing, setCreateMissing] = useState(false);
  const [result, setResult] = useState<ImportResult | null>(null);
  const [busy, setBusy] = useState(false);

  async function run() {
    if (!file) return;
    setBusy(true);
    try {
      const r = await cardsApi.importXlsx(lessonId, file, { updateExisting, createMissingLessons: createMissing });
      setResult(r);
      onDone();
    } finally { setBusy(false); }
  }

  return (
    <div className="fixed inset-0 flex items-center justify-center bg-black/40">
      <div className="w-full max-w-md rounded bg-white p-6 dark:bg-slate-900">
        <h2 className="mb-3 text-lg font-semibold">Excel import</h2>
        <p className="mb-3 text-xs text-slate-500">Kolommen: <code>question</code>, <code>answer</code>, <code>hint</code> (optioneel), <code>lesson_path</code> (optioneel, bv. "Spaans/Begroetingen").</p>
        <input type="file" accept=".xlsx" onChange={(e) => setFile(e.target.files?.[0] ?? null)} />
        <label className="mt-3 flex items-center gap-2 text-sm"><input type="checkbox" checked={updateExisting} onChange={(e) => setUpdateExisting(e.target.checked)} /> Bestaande kaarten bijwerken</label>
        <label className="mt-1 flex items-center gap-2 text-sm"><input type="checkbox" checked={createMissing} onChange={(e) => setCreateMissing(e.target.checked)} /> Onbekende lessen aanmaken</label>
        {result && (
          <div className="mt-3 rounded bg-slate-100 p-2 text-xs dark:bg-slate-800">
            <div>Toegevoegd: {result.inserted}</div>
            <div>Bijgewerkt: {result.updated}</div>
            <div>Overgeslagen: {result.skipped}</div>
            {result.errors.length > 0 && (<div className="text-red-600">Fouten: {result.errors.length}<ul>{result.errors.map((e, i) => <li key={i}>rij {e.row}: {e.message}</li>)}</ul></div>)}
          </div>
        )}
        <div className="mt-4 flex justify-end gap-2">
          <button className="px-3 py-1" onClick={onClose}>Sluiten</button>
          <button className="rounded bg-blue-600 px-3 py-1 text-white disabled:opacity-50" onClick={run} disabled={!file || busy}>Importeer</button>
        </div>
      </div>
    </div>
  );
}
  • Step 3: Create pages/AdminLesson.tsx
import { useEffect, useState } from 'react';
import { Link, useParams } from 'react-router-dom';
import type { Card } from '@flashcard/shared';
import { cardsApi } from '../api/cards.js';
import { CardTable } from '../components/CardTable.js';
import { ImportDialog } from '../components/ImportDialog.js';

export function AdminLessonPage() {
  const { id } = useParams();
  const lessonId = Number(id);
  const [cards, setCards] = useState<Card[]>([]);
  const [showImport, setShowImport] = useState(false);

  async function refresh() { setCards(await cardsApi.list(lessonId)); }
  useEffect(() => { refresh(); }, [lessonId]);

  return (
    <div className="mx-auto max-w-4xl p-6">
      <Link to="/admin" className="text-sm text-blue-600"> Lessen</Link>
      <h1 className="my-3 text-2xl font-semibold">Kaarten</h1>
      <div className="mb-4 flex gap-2">
        <button className="rounded bg-slate-200 px-3 py-1 dark:bg-slate-800" onClick={() => setShowImport(true)}>Importeer Excel</button>
        <a className="rounded bg-slate-200 px-3 py-1 dark:bg-slate-800" href={cardsApi.exportUrl(lessonId, false)}>Exporteer Excel</a>
        <a className="rounded bg-slate-200 px-3 py-1 dark:bg-slate-800" href={cardsApi.exportUrl(lessonId, true)}>Exporteer + sublessen</a>
        <Link to={`/practice/${lessonId}/setup`} className="ml-auto rounded bg-green-600 px-3 py-1 text-white">Start oefenen </Link>
      </div>
      <CardTable lessonId={lessonId} cards={cards} onChange={refresh} />
      {showImport && <ImportDialog lessonId={lessonId} onClose={() => setShowImport(false)} onDone={refresh} />}
    </div>
  );
}
  • Step 4: Mount in router

Add to children:

{ path: 'admin/lessons/:id', element: <AdminLessonPage /> },

And import.

  • Step 5: Smoke test

Add a few cards. Try import with a small xlsx. Click export — file downloads.

  • Step 6: Commit
git add -A
git commit -m "feat(frontend): admin card management with excel import/export"

Task 19: Practice flow — setup, session, done

Files:

  • Create: packages/frontend/src/pages/PracticeSetup.tsx

  • Create: packages/frontend/src/pages/Practice.tsx

  • Create: packages/frontend/src/pages/PracticeDone.tsx

  • Create: packages/frontend/src/components/Flashcard.tsx

  • Create: packages/frontend/src/components/Confetti.tsx

  • Modify: packages/frontend/src/router.tsx

  • Step 1: Create components/Flashcard.tsx

import { AnimatePresence, motion } from 'framer-motion';

export function Flashcard({
  question, answer, hint, showAnswer, onReveal, onAnswer,
}: {
  question: string; answer: string; hint: string | null;
  showAnswer: boolean;
  onReveal: () => void;
  onAnswer: (r: 'correct' | 'incorrect') => void;
}) {
  return (
    <div className="card-perspective mx-auto w-full max-w-2xl">
      <AnimatePresence mode="wait">
        <motion.div
          key={showAnswer ? 'a' : 'q'}
          initial={{ opacity: 0, x: 40 }}
          animate={{ opacity: 1, x: 0 }}
          exit={{ opacity: 0, x: -40 }}
          transition={{ duration: 0.2 }}
          className="card-face rounded-2xl bg-white p-12 shadow-xl dark:bg-slate-900"
        >
          <div className="min-h-[160px] text-center text-3xl font-medium">
            {showAnswer ? answer : question}
          </div>
          {!showAnswer && hint && (
            <div className="mt-4 text-center text-sm text-slate-500">💡 {hint}</div>
          )}
          <div className="mt-8 flex justify-center gap-3">
            {!showAnswer ? (
              <button className="rounded-lg bg-blue-600 px-6 py-3 font-medium text-white hover:bg-blue-700" onClick={onReveal}>
                Toon antwoord
              </button>
            ) : (
              <>
                <motion.button whileTap={{ scale: 0.95 }} className="rounded-lg bg-red-600 px-6 py-3 font-medium text-white hover:bg-red-700" onClick={() => onAnswer('incorrect')}>
                  Fout
                </motion.button>
                <motion.button whileTap={{ scale: 0.95 }} className="rounded-lg bg-green-600 px-6 py-3 font-medium text-white hover:bg-green-700" onClick={() => onAnswer('correct')}>
                  Goed
                </motion.button>
              </>
            )}
          </div>
        </motion.div>
      </AnimatePresence>
    </div>
  );
}
  • Step 2: Create components/Confetti.tsx
import confetti from 'canvas-confetti';
import { useEffect } from 'react';

export function Confetti({ trigger }: { trigger: boolean }) {
  useEffect(() => {
    if (!trigger) return;
    const end = Date.now() + 1200;
    (function frame() {
      confetti({ particleCount: 4, angle: 60, spread: 55, origin: { x: 0 } });
      confetti({ particleCount: 4, angle: 120, spread: 55, origin: { x: 1 } });
      if (Date.now() < end) requestAnimationFrame(frame);
    })();
  }, [trigger]);
  return null;
}
  • Step 3: Create pages/PracticeSetup.tsx
import { useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useSettings } from '../stores/settingsStore.js';
import { useSession } from '../stores/sessionStore.js';

export function PracticeSetupPage() {
  const { lessonId } = useParams();
  const id = Number(lessonId);
  const defaultMax = useSettings((s) => s.defaultMaxCards);
  const start = useSession((s) => s.start);
  const navigate = useNavigate();

  const [maxCards, setMaxCards] = useState<number | 'all'>(defaultMax);
  const [shuffle, setShuffle] = useState(true);
  const [direction, setDirection] = useState<'forward' | 'backward' | 'both'>('forward');
  const [busy, setBusy] = useState(false);

  async function begin() {
    setBusy(true);
    await start({ lessonId: id, maxCards: maxCards === 'all' ? null : maxCards, shuffle, direction });
    navigate(`/practice/${id}`);
  }

  return (
    <div className="mx-auto max-w-md p-6">
      <h1 className="mb-4 text-2xl font-semibold">Sessie starten</h1>
      <label className="mb-2 block text-sm">Max. aantal kaarten
        <select className="ml-2 rounded border px-2 py-1 dark:bg-slate-900" value={String(maxCards)} onChange={(e) => setMaxCards(e.target.value === 'all' ? 'all' : Number(e.target.value))}>
          {[10, 20, 30, 50].map((n) => <option key={n} value={n}>{n}</option>)}
          <option value="all">Alle</option>
        </select>
      </label>
      <label className="mb-2 flex items-center gap-2 text-sm"><input type="checkbox" checked={shuffle} onChange={(e) => setShuffle(e.target.checked)} /> Shuffle</label>
      <label className="mb-4 block text-sm">Richting
        <select className="ml-2 rounded border px-2 py-1 dark:bg-slate-900" value={direction} onChange={(e) => setDirection(e.target.value as typeof direction)}>
          <option value="forward">Vraag  antwoord</option>
          <option value="backward">Antwoord  vraag</option>
          <option value="both">Beide</option>
        </select>
      </label>
      <button className="w-full rounded bg-green-600 py-3 font-medium text-white" onClick={begin} disabled={busy}>
        Start
      </button>
    </div>
  );
}
  • Step 4: Create pages/Practice.tsx
import { useEffect, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import type { Card } from '@flashcard/shared';
import { cardsApi } from '../api/cards.js';
import { useSession } from '../stores/sessionStore.js';
import { Flashcard } from '../components/Flashcard.js';

export function PracticePage() {
  const { lessonId } = useParams();
  const { session, current, done, showAnswer, reveal, answer, end } = useSession();
  const navigate = useNavigate();
  const [card, setCard] = useState<Card | null>(null);

  useEffect(() => {
    if (!session) { navigate(`/practice/${lessonId}/setup`); return; }
  }, [session, lessonId, navigate]);

  useEffect(() => {
    let cancel = false;
    (async () => {
      if (!current) { setCard(null); return; }
      // simple cache-bust: fetch list for the card's lesson? Simpler: fetch card via stats endpoint not ideal; use existing list endpoint cached by lesson.
      const cards = await cardsApi.list(session!.lessonId);
      if (cancel) return;
      // Card may belong to a descendant — fall back: fetch full set via /stats/cards/:id is not the card content. We need a /api/cards/:id endpoint.
      const found = cards.find((c) => c.id === current.cardId);
      setCard(found ?? null);
    })();
    return () => { cancel = true; };
  }, [current, session]);

  useEffect(() => {
    if (done && session) { (async () => { await end(); navigate(`/practice/${lessonId}/done`); })(); }
  }, [done, session, end, navigate, lessonId]);

  if (!current || !card) return <div className="p-6 text-center">Laden...</div>;

  const isReverse = current.direction === 'backward';
  return (
    <div className="flex h-full flex-col">
      <div className="mx-auto w-full max-w-2xl p-6">
        <div className="mb-2 text-xs text-slate-500">{session?.cardsShown ?? 0} kaarten behandeld</div>
        <Flashcard
          question={isReverse ? card.answer : card.question}
          answer={isReverse ? card.question : card.answer}
          hint={card.hint}
          showAnswer={showAnswer}
          onReveal={reveal}
          onAnswer={(r) => answer(r)}
        />
      </div>
    </div>
  );
}

Note: This page currently fetches all cards in the session's root lesson and assumes the active card is there. For sessions including descendants, that may not work. Therefore add a backend endpoint as part of this task — see next step.

  • Step 5: Add GET /api/cards/:id backend endpoint

In packages/backend/src/routes/cards.ts, add inside cardsRouter:

r.get('/cards/:id', async (req, res, next) => {
  try { res.json(await getCard(db, Number(req.params.id))); } catch (e) { next(e); }
});

Import: import { getCard } from '../services/cards.js';

In packages/frontend/src/api/cards.ts add:

get: (id: number) => api.get<Card>(`/cards/${id}`),

In Practice.tsx replace the card-fetching effect with:

useEffect(() => {
  let cancel = false;
  (async () => {
    if (!current) { setCard(null); return; }
    const c = await cardsApi.get(current.cardId);
    if (!cancel) setCard(c);
  })();
  return () => { cancel = true; };
}, [current]);
  • Step 6: Create pages/PracticeDone.tsx
import { Link, useParams } from 'react-router-dom';
import { useSession } from '../stores/sessionStore.js';
import { Confetti } from '../components/Confetti.js';

export function PracticeDonePage() {
  const { lessonId } = useParams();
  const session = useSession((s) => s.session);
  const reset = useSession((s) => s.reset);
  if (!session) return <div className="p-6">Geen sessie gegevens. <Link to="/" className="text-blue-600">Terug</Link></div>;
  const total = session.cardsShown || 1;
  const pct = Math.round((session.cardsCorrect / total) * 100);
  return (
    <div className="mx-auto max-w-md p-6 text-center">
      <Confetti trigger={pct >= 80} />
      <h1 className="text-3xl font-semibold">Sessie klaar!</h1>
      <p className="mt-4 text-5xl font-bold">{pct}%</p>
      <p className="mt-2 text-slate-500">{session.cardsCorrect} goed, {session.cardsIncorrect} fout van {session.cardsShown}</p>
      <p className="mt-1 text-sm text-slate-500">Duur: {session.durationSeconds ?? 0}s</p>
      <div className="mt-6 flex justify-center gap-3">
        <Link to={`/practice/${lessonId}/setup`} className="rounded bg-green-600 px-4 py-2 text-white" onClick={reset}>Opnieuw</Link>
        <Link to="/" className="rounded bg-slate-200 px-4 py-2 dark:bg-slate-800" onClick={reset}>Dashboard</Link>
      </div>
    </div>
  );
}
  • Step 7: Mount routes

In router.tsx, add:

{ path: 'practice/:lessonId/setup', element: <PracticeSetupPage /> },
{ path: 'practice/:lessonId', element: <PracticePage /> },
{ path: 'practice/:lessonId/done', element: <PracticeDonePage /> },

And import the pages.

  • Step 8: Smoke test

Start a practice session with a lesson that has cards. Reveal answer, click Goed/Fout, ensure session progresses and ends with the done page.

  • Step 9: Commit
git add -A
git commit -m "feat(frontend): practice setup, session and done flow"

Task 20: Dashboard + Stats pages

Files:

  • Create: packages/frontend/src/pages/Dashboard.tsx

  • Create: packages/frontend/src/pages/Stats.tsx

  • Create: packages/frontend/src/pages/StatsLesson.tsx

  • Create: packages/frontend/src/pages/StatsCard.tsx

  • Create: packages/frontend/src/lib/format.ts

  • Modify: packages/frontend/src/router.tsx

  • Step 1: Create lib/format.ts

export function formatDuration(seconds: number): string {
  const h = Math.floor(seconds / 3600);
  const m = Math.floor((seconds % 3600) / 60);
  const s = seconds % 60;
  if (h > 0) return `${h}u ${m}m`;
  if (m > 0) return `${m}m ${s}s`;
  return `${s}s`;
}
export function formatPct(n: number): string { return `${Math.round(n * 100)}%`; }
export function formatDate(unixSec: number): string {
  return new Date(unixSec * 1000).toLocaleString();
}
  • Step 2: Create pages/Dashboard.tsx
import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { statsApi, type Overview } from '../api/stats.js';
import { useLessons } from '../stores/lessonsStore.js';
import { formatDuration, formatDate } from '../lib/format.js';

export function DashboardPage() {
  const { tree, refresh } = useLessons();
  const [ov, setOv] = useState<Overview | null>(null);

  useEffect(() => {
    refresh();
    statsApi.overview().then(setOv);
  }, [refresh]);

  return (
    <div className="mx-auto max-w-4xl p-6">
      <h1 className="mb-6 text-3xl font-semibold">Dashboard</h1>
      <div className="mb-6 grid grid-cols-3 gap-4">
        <Stat label="🔥 Streak" value={`${ov?.streakDays ?? 0} dagen`} />
        <Stat label="Sessies" value={String(ov?.totalSessions ?? 0)} />
        <Stat label="Totale tijd" value={ov ? formatDuration(ov.totalDurationSeconds) : '—'} />
      </div>

      <h2 className="mb-2 text-lg font-medium">Lessen</h2>
      <ul className="mb-6 space-y-1">
        {tree.map((n) => (
          <li key={n.id} className="flex items-center justify-between rounded p-2 hover:bg-slate-100 dark:hover:bg-slate-800">
            <span>{n.name} <span className="text-xs text-slate-500">({n.cardCount} kaarten)</span></span>
            <Link to={`/practice/${n.id}/setup`} className="rounded bg-green-600 px-3 py-1 text-sm text-white">Oefenen</Link>
          </li>
        ))}
      </ul>

      <h2 className="mb-2 text-lg font-medium">Recente sessies</h2>
      <ul className="space-y-1 text-sm">
        {ov?.recentSessions.map((s) => (
          <li key={s.id} className="rounded p-2 hover:bg-slate-100 dark:hover:bg-slate-800">
            {formatDate(s.startedAt)}  {s.cardsCorrect}/{s.cardsShown} goed · {formatDuration(s.durationSeconds ?? 0)}
          </li>
        ))}
      </ul>
    </div>
  );
}

function Stat({ label, value }: { label: string; value: string }) {
  return (
    <div className="rounded-xl bg-white p-4 shadow dark:bg-slate-900">
      <div className="text-xs uppercase tracking-wide text-slate-500">{label}</div>
      <div className="mt-1 text-2xl font-semibold">{value}</div>
    </div>
  );
}
  • Step 3: Create pages/Stats.tsx (overview + heatmap)
import { useEffect, useState } from 'react';
import { statsApi } from '../api/stats.js';

export function StatsPage() {
  const [heatmap, setHeatmap] = useState<{ day: string; sessions: number; attempts: number }[]>([]);
  useEffect(() => { statsApi.heatmap(12).then(setHeatmap); }, []);

  const max = Math.max(1, ...heatmap.map((d) => d.attempts));
  return (
    <div className="mx-auto max-w-4xl p-6">
      <h1 className="mb-4 text-2xl font-semibold">Statistieken</h1>
      <div className="flex flex-wrap gap-1">
        {heatmap.map((d) => (
          <div key={d.day} title={`${d.day}: ${d.attempts} pogingen`} className="h-4 w-4 rounded"
               style={{ backgroundColor: `rgba(34,197,94,${0.15 + 0.85 * (d.attempts / max)})` }} />
        ))}
      </div>
    </div>
  );
}
  • Step 4: Create pages/StatsLesson.tsx
import { useEffect, useState } from 'react';
import { useParams } from 'react-router-dom';
import { statsApi, type LessonStats } from '../api/stats.js';
import { formatDuration, formatPct } from '../lib/format.js';

export function StatsLessonPage() {
  const { id } = useParams();
  const [s, setS] = useState<LessonStats | null>(null);
  useEffect(() => { statsApi.lesson(Number(id)).then(setS); }, [id]);
  if (!s) return <div className="p-6">Laden...</div>;
  return (
    <div className="mx-auto max-w-2xl p-6">
      <h1 className="mb-4 text-2xl font-semibold">Les statistiek</h1>
      <ul className="space-y-1 text-sm">
        <li>Score: <b>{formatPct(s.score)}</b></li>
        <li>Beheerst: {s.mastered} / {s.totalCards}</li>
        <li>Sessies: {s.sessions}</li>
        <li>Totale tijd: {formatDuration(s.totalDurationSeconds)}</li>
        <li>Pogingen: {s.correct}/{s.attempts} goed</li>
      </ul>
    </div>
  );
}
  • Step 5: Create pages/StatsCard.tsx
import { useEffect, useState } from 'react';
import { useParams } from 'react-router-dom';
import { statsApi, type CardStats } from '../api/stats.js';
import { formatDate } from '../lib/format.js';

export function StatsCardPage() {
  const { id } = useParams();
  const [s, setS] = useState<CardStats | null>(null);
  useEffect(() => { statsApi.card(Number(id)).then(setS); }, [id]);
  if (!s) return <div className="p-6">Laden...</div>;
  return (
    <div className="mx-auto max-w-2xl p-6">
      <h1 className="mb-4 text-2xl font-semibold">Kaart statistiek</h1>
      <p>Doos: {s.box.forward}{s.box.backward != null && ` / ${s.box.backward} (achterwaarts)`}</p>
      <p>Pogingen: {s.correct}/{s.attempts} goed</p>
      <p>Volgende due: {s.nextDueAt ? formatDate(s.nextDueAt) : '—'}</p>
      <h2 className="mt-4 font-medium">Geschiedenis</h2>
      <ul className="text-sm">
        {s.history.map((h, i) => (
          <li key={i}>{formatDate(h.shownAt)}  {h.direction}  {h.result === 'correct' ? '✅' : '❌'}</li>
        ))}
      </ul>
    </div>
  );
}
  • Step 6: Replace dashboard route + mount stats

In router.tsx set the index route to <DashboardPage />, and add:

{ path: 'stats', element: <StatsPage /> },
{ path: 'stats/lessons/:id', element: <StatsLessonPage /> },
{ path: 'stats/cards/:id', element: <StatsCardPage /> },

Import all pages.

  • Step 7: Commit
git add -A
git commit -m "feat(frontend): dashboard and stats pages"

Task 21: Settings & dark mode toggle

Files:

  • Create: packages/frontend/src/pages/Settings.tsx

  • Modify: packages/frontend/src/components/Layout.tsx

  • Modify: packages/frontend/src/router.tsx

  • Step 1: Create pages/Settings.tsx

import { useSettings } from '../stores/settingsStore.js';

export function SettingsPage() {
  const { theme, defaultMaxCards, toggleTheme, setDefaultMaxCards } = useSettings();
  return (
    <div className="mx-auto max-w-md p-6">
      <h1 className="mb-4 text-2xl font-semibold">Instellingen</h1>
      <label className="mb-3 flex items-center gap-2 text-sm">
        <input type="checkbox" checked={theme === 'dark'} onChange={toggleTheme} /> Dark mode
      </label>
      <label className="block text-sm">Standaard max kaarten per sessie
        <input type="number" min={1} max={500} className="ml-2 w-20 rounded border px-2 py-1 dark:bg-slate-900" value={defaultMaxCards} onChange={(e) => setDefaultMaxCards(Math.max(1, Number(e.target.value)))} />
      </label>
    </div>
  );
}
  • Step 2: Update Layout.tsx with settings link & dark toggle
import { Link, Outlet } from 'react-router-dom';
import { useSettings } from '../stores/settingsStore.js';

export function Layout() {
  const { theme, toggleTheme } = useSettings();
  return (
    <div className="flex h-full flex-col">
      <header className="border-b border-slate-200 bg-white px-6 py-3 dark:border-slate-800 dark:bg-slate-900">
        <nav className="flex items-center gap-4 text-sm">
          <Link to="/" className="font-semibold">Flashcards</Link>
          <Link to="/admin">Admin</Link>
          <Link to="/stats">Stats</Link>
          <Link to="/settings" className="ml-auto">Instellingen</Link>
          <button onClick={toggleTheme} className="rounded border px-2 py-0.5 text-xs">
            {theme === 'dark' ? '☀️' : '🌙'}
          </button>
        </nav>
      </header>
      <main className="flex-1 overflow-auto"><Outlet /></main>
    </div>
  );
}
  • Step 3: Mount route

Add { path: 'settings', element: <SettingsPage /> } and import.

  • Step 4: Commit
git add -A
git commit -m "feat(frontend): settings page with dark mode and defaults"

Task 22: Resume active session prompt

Files:

  • Modify: packages/frontend/src/pages/Dashboard.tsx

  • Step 1: On mount, check for active session

Add to DashboardPage:

import { sessionsApi } from '../api/sessions.js';
import type { SessionRow } from '@flashcard/shared';
import { useNavigate } from 'react-router-dom';
// ...inside component:
const [active, setActive] = useState<SessionRow | null>(null);
const navigate = useNavigate();
useEffect(() => {
  sessionsApi.active().then(setActive).catch(() => {});
}, []);

// ...in JSX, above the lessons list:
{active && (
  <div className="mb-4 flex items-center justify-between rounded bg-amber-100 p-3 text-sm dark:bg-amber-900/30">
    <span>Je hebt een open sessie ({active.cardsShown} kaarten behandeld).</span>
    <div className="flex gap-2">
      <button className="rounded bg-amber-600 px-3 py-1 text-white" onClick={() => navigate(`/practice/${active.lessonId}`)}>Hervatten</button>
      <button className="rounded bg-slate-200 px-3 py-1 dark:bg-slate-800" onClick={async () => { await sessionsApi.abandon(active.id); setActive(null); }}>Afsluiten</button>
    </div>
  </div>
)}
  • Step 2: Handle resume in Practice.tsx

Add an effect that, when session is null but the URL has a lesson, tries to load active session:

import { sessionsApi } from '../api/sessions.js';
import { useSession } from '../stores/sessionStore.js';
// inside PracticePage, before the existing "if no session redirect" effect:
useEffect(() => {
  if (useSession.getState().session) return;
  (async () => {
    const active = await sessionsApi.active();
    if (!active || String(active.lessonId) !== lessonId) return;
    const state = await sessionsApi.state(active.id);
    const nx = await sessionsApi.next(active.id);
    useSession.setState({
      session: active,
      current: nx.done ? null : nx.item,
      done: nx.done,
      showAnswer: false,
      shownAt: Date.now(),
    });
  })();
}, [lessonId]);
  • Step 3: Commit
git add -A
git commit -m "feat(frontend): resume active session prompt"

Task 23: Playwright E2E smoke

Files:

  • Create: packages/frontend/playwright.config.ts

  • Create: e2e/smoke.spec.ts

  • Modify: root package.json (add e2e script)

  • Step 1: Install Playwright

Run: npm i -D -w @flashcard/frontend @playwright/test Run: npx -w @flashcard/frontend playwright install chromium

  • Step 2: Create playwright.config.ts
import { defineConfig } from '@playwright/test';

export default defineConfig({
  testDir: '../../e2e',
  webServer: [
    { command: 'npm run dev', cwd: '../..', port: 3000, reuseExistingServer: true, env: { DB_PATH: '../../data/e2e.db' } },
    { command: 'npm run dev:fe', cwd: '../..', port: 5173, reuseExistingServer: true },
  ],
  use: { baseURL: 'http://localhost:5173' },
});
  • Step 3: Create e2e/smoke.spec.ts
import { test, expect } from '@playwright/test';

test('create lesson, add card, practice once', async ({ page }) => {
  await page.goto('/admin');
  await page.getByPlaceholder('Nieuwe wortel-les...').fill('E2E les');
  await page.getByRole('button', { name: 'Toevoegen' }).click();
  await page.getByText('E2E les').click();
  await page.getByPlaceholder('Nieuwe vraag').fill('q1');
  await page.getByPlaceholder('Antwoord').fill('a1');
  await page.getByRole('button', { name: '+' }).click();
  await page.getByRole('link', { name: /Start oefenen/ }).click();
  await page.getByRole('button', { name: 'Start' }).click();
  await page.getByRole('button', { name: 'Toon antwoord' }).click();
  await page.getByRole('button', { name: 'Goed' }).click();
  await expect(page.getByText(/Sessie klaar!/)).toBeVisible();
});
  • Step 4: Add root script

Add to root package.json scripts:

"e2e": "playwright test --config packages/frontend/playwright.config.ts"
  • Step 5: Run

Run: npm run e2e Expected: smoke test passes.

  • Step 6: Commit
git add -A
git commit -m "test(e2e): playwright smoke for create→practice flow"

Task 24: README & developer docs

Files:

  • Create: README.md

  • Step 1: Write README.md

# Flashcard

Single-user lokale flashcard webapp met hiërarchische lessen, spaced repetition (Leitner), Excel import/export en statistiek.

## Snelstart

```bash
npm install
npm run db:migrate
npm run db:seed       # optioneel, voegt demo data toe
npm run dev           # backend op :3000, frontend op :5173

Open http://localhost:5173.

Build (productie)

npm run build
npm start             # serveert frontend + backend op :3000

Tests

npm test              # unit (backend + frontend)
npm run e2e           # playwright smoke

Excel-formaat

Eén werkblad met header-rij. Kolommen:

  • question (verplicht)
  • answer (verplicht)
  • hint (optioneel)
  • lesson_path (optioneel, bv. Spaans/Begroetingen)

- [ ] **Step 2: Commit**

```bash
git add -A
git commit -m "docs: readme with quickstart"

Spec coverage check

Spec section Implemented in task
3.1 Lessenstructuur (CRUD, move, bidirectional, cascade) 7
3.2 Flashcards CRUD + position 8
3.3 Oefensessie incl. descendants, settings, flip/animations 9, 10, 19
3.4 Leitner + intra-sessie reinsert 5, 9
3.5 Statistieken per kaart/les/sessie/globaal 11, 20
3.6 Admin + Excel import/export 17, 18, 12
3.7 Sessie hervatten 22
3.8 Daily streak 11, 20
Tech stack 1-4, 14
Foutafhandeling (Zod, ApiError, toasts) 3 (errors), 15 (client)
Test-strategie (TDD core + smoke E2E) 5, 7, 8, 9, 11, 12, 23
Deployment scripts 1, 13, 24

Execution Handoff

Plan complete and saved to docs/superpowers/plans/2026-05-20-flashcard-app.md. Two execution options:

1. Subagent-Driven (recommended) - I dispatch a 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?