diff --git a/docs/superpowers/plans/2026-05-20-flashcard-app.md b/docs/superpowers/plans/2026-05-20-flashcard-app.md new file mode 100644 index 0000000..83330bb --- /dev/null +++ b/docs/superpowers/plans/2026-05-20-flashcard-app.md @@ -0,0 +1,3967 @@ +# 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`** + +```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`** + +```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`** + +```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`** + +```json +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src/**/*"] +} +``` + +- [ ] **Step 6: Create `packages/shared/src/index.ts` (placeholder)** + +```ts +export {}; +``` + +- [ ] **Step 7: Install root deps** + +Run: `npm install` +Expected: workspaces resolved, no errors. + +- [ ] **Step 8: Commit** + +```bash +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`** + +```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`** + +```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; +export type LessonUpdateInput = z.infer; +export type LessonMoveInput = z.infer; +export type CardCreateInput = z.infer; +export type CardUpdateInput = z.infer; +export type SessionStartInput = z.infer; +export type AttemptCreateInput = z.infer; +``` + +- [ ] **Step 3: Update `packages/shared/src/index.ts`** + +```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** + +```bash +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`** + +```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`** + +```json +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "module": "NodeNext", + "moduleResolution": "NodeNext" + }, + "include": ["src/**/*"] +} +``` + +- [ ] **Step 3: Create `packages/backend/vitest.config.ts`** + +```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`** + +```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`** + +```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`** + +```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** + +```bash +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`** + +```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`** + +```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`** + +```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; + +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`** + +```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`** + +```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** + +```bash +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** + +```ts +// 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`** + +```ts +import type { AttemptResult } from '@flashcard/shared'; + +export const MAX_BOX = 5; +// index 1..5; index 0 unused +export const BOX_INTERVALS_SEC: Record = { + 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** + +```bash +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`** + +```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** + +```bash +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** + +```ts +// 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; +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`** + +```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 { + 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 { + 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 { + 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 { + 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 { + 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 { + const all = db.select().from(lessons).orderBy(lessons.position).all(); + const counts = db + .select({ lessonId: cards.lessonId, count: sql`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(); + 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 { + const all = db.select({ id: lessons.id, parentId: lessons.parentId }).from(lessons).all(); + const byParent = new Map(); + 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`** + +```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`: + +```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`** + +```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** + +```bash +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** + +```ts +// 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; +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`** + +```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 { + 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 { + 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 { + 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 { + 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 { + 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`** + +```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));`: +```ts +import { cardsRouter } from './routes/cards.js'; +// ... +app.use('/api', cardsRouter(db)); +``` + +- [ ] **Step 7: Commit** + +```bash +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** + +```ts +// 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; +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`** + +```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(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 { + 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(); + 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 { + 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 { + 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 { + 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 { + 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 { + 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** + +```bash +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`** + +```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** + +```bash +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** + +```ts +// 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; +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`** + +```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 { + 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 { + 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(); + 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 { + 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`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 { + 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(); + 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`** + +```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** + +```bash +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** + +```ts +// 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; +beforeEach(() => { env = makeTestDb(); }); + +function buildXlsx(rows: Record[]): 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`** + +```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(sheet, { defval: '' }); +} + +function nowSec() { return Math.floor(Date.now() / 1000); } + +async function resolveLesson(db: Db, defaultLessonId: number, lessonPath: string | undefined, createMissing: boolean): Promise { + 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 { + 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 { + 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 { + const byId = new Map(allLessons.map((l) => [l.id, l])); + const cache = new Map(); + 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;`: + +```ts +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`: +```ts +import multer from 'multer'; +import { ApiError } from '../lib/errors.js'; +import { exportCardsToBuffer, importCardsFromBuffer } from '../services/import.js'; +``` + +- [ ] **Step 6: Commit** + +```bash +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): + +```ts +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** + +```bash +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`** + +```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`** + +```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`** + +```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`** + +```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`** + +```js +export default { plugins: { tailwindcss: {}, autoprefixer: {} } }; +``` + +- [ ] **Step 6: Create `index.html`** + +```html + + + + + + Flashcards + + +
+ + + +``` + +- [ ] **Step 7: Create `src/styles.css`** + +```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`** + +```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( + + + +); +``` + +- [ ] **Step 9: Create `src/router.tsx`** (skeleton; pages added in later tasks) + +```tsx +import { createBrowserRouter, Navigate } from 'react-router-dom'; +import { Layout } from './components/Layout.js'; + +export const router = createBrowserRouter([ + { + path: '/', + element: , + children: [ + { index: true, element:
Dashboard placeholder
}, + { path: 'admin', element:
Admin placeholder
}, + { path: '*', element: }, + ], + }, +]); +``` + +- [ ] **Step 10: Create `src/components/Layout.tsx`** + +```tsx +import { Link, Outlet } from 'react-router-dom'; + +export function Layout() { + return ( +
+
+ +
+
+
+ ); +} +``` + +- [ ] **Step 11: Create `src/test-setup.ts`** + +```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** + +```bash +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`** + +```ts +export class ApiClientError extends Error { + constructor(public status: number, public code: string, message: string, public details?: unknown) { + super(message); + } +} + +async function request(method: string, path: string, body?: unknown, opts?: { isFormData?: boolean }): Promise { + const headers: Record = {}; + 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: (path: string) => request('GET', path), + post: (path: string, body?: unknown) => request('POST', path, body), + postForm: (path: string, form: FormData) => request('POST', path, form, { isFormData: true }), + patch: (path: string, body: unknown) => request('PATCH', path, body), + delete: (path: string) => request('DELETE', path), + getBlob: async (path: string): Promise => { + 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`** + +```ts +import type { Lesson, LessonCreateInput, LessonMoveInput, LessonTreeNode, LessonUpdateInput } from '@flashcard/shared'; +import { api } from './client.js'; + +export const lessonsApi = { + tree: () => api.get('/lessons/tree'), + create: (input: LessonCreateInput) => api.post('/lessons', input), + update: (id: number, input: LessonUpdateInput) => api.patch(`/lessons/${id}`, input), + remove: (id: number) => api.delete(`/lessons/${id}`), + move: (id: number, input: LessonMoveInput) => api.post(`/lessons/${id}/move`, input), +}; +``` + +- [ ] **Step 3: Create `api/cards.ts`** + +```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(`/lessons/${lessonId}/cards`), + create: (lessonId: number, input: CardCreateInput) => api.post(`/lessons/${lessonId}/cards`, input), + update: (id: number, input: CardUpdateInput) => api.patch(`/cards/${id}`, input), + remove: (id: number) => api.delete(`/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(`/lessons/${lessonId}/cards/import`, fd); + }, + exportUrl: (lessonId: number, includeDescendants: boolean) => + `/api/lessons/${lessonId}/cards/export?include_descendants=${includeDescendants}`, +}; +``` + +- [ ] **Step 4: Create `api/sessions.ts`** + +```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('/sessions', input), + active: () => api.get('/sessions/active'), + state: (id: number) => api.get(`/sessions/${id}`), + next: (id: number) => api.get<{ done: true } | { done: false; item: QueueItem }>(`/sessions/${id}/next`), + attempt: (id: number, input: AttemptCreateInput) => api.post(`/sessions/${id}/attempts`, input), + end: (id: number) => api.post(`/sessions/${id}/end`), + abandon: (id: number) => api.post(`/sessions/${id}/abandon`), +}; +``` + +- [ ] **Step 5: Create `api/stats.ts`** + +```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('/stats/overview'), + lesson: (id: number) => api.get(`/stats/lessons/${id}`), + card: (id: number) => api.get(`/stats/cards/${id}`), + heatmap: (weeks = 12) => api.get<{ day: string; sessions: number; attempts: number }[]>(`/stats/heatmap?weeks=${weeks}`), +}; +``` + +- [ ] **Step 6: Commit** + +```bash +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`** + +```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((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`: + +```tsx +import { useSettings } from './stores/settingsStore.js'; +useSettings.getState().hydrate(); +``` + +(Add immediately before `createRoot`.) + +- [ ] **Step 3: Create `stores/lessonsStore.ts`** + +```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; +} + +export const useLessons = create((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`** + +```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; + reveal: () => void; + answer: (result: 'correct' | 'incorrect') => Promise; + end: () => Promise; + abandon: () => Promise; + reset: () => void; +} + +export const useSession = create((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** + +```bash +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`** + +```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(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 ( +
    + {nodes.map((n) => ( +
  • +
    + + {n.name} ({n.cardCount}) + + + + +
    + {addingTo === n.id && ( +
    + setName(e.target.value)} placeholder="Naam" /> + + +
    + )} + {n.children.length > 0 && } +
  • + ))} +
+ ); +} +``` + +- [ ] **Step 2: Create `pages/Admin.tsx`** + +```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 ( +
+

Lessen beheer

+
+ setNewRoot(e.target.value)} /> + +
+ {loading ?

Laden...

: } +
+ ); +} +``` + +- [ ] **Step 3: Mount in router** + +Replace `/admin` route in `router.tsx`: +```tsx +{ path: 'admin', element: }, +``` +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** + +```bash +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`** + +```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({ 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 ( + + + + + + {cards.map((c) => ( + + + + + + + ))} + + + + + + + +
VraagAntwoordHint
update(c, 'question', e.target.value)} /> update(c, 'answer', e.target.value)} /> update(c, 'hint', e.target.value)} />
setDraft({ ...draft, question: e.target.value })} /> setDraft({ ...draft, answer: e.target.value })} /> setDraft({ ...draft, hint: e.target.value })} />
+ ); +} +``` + +- [ ] **Step 2: Create `components/ImportDialog.tsx`** + +```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(null); + const [updateExisting, setUpdateExisting] = useState(true); + const [createMissing, setCreateMissing] = useState(false); + const [result, setResult] = useState(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 ( +
+
+

Excel import

+

Kolommen: question, answer, hint (optioneel), lesson_path (optioneel, bv. "Spaans/Begroetingen").

+ setFile(e.target.files?.[0] ?? null)} /> + + + {result && ( +
+
Toegevoegd: {result.inserted}
+
Bijgewerkt: {result.updated}
+
Overgeslagen: {result.skipped}
+ {result.errors.length > 0 && (
Fouten: {result.errors.length}
    {result.errors.map((e, i) =>
  • rij {e.row}: {e.message}
  • )}
)} +
+ )} +
+ + +
+
+
+ ); +} +``` + +- [ ] **Step 3: Create `pages/AdminLesson.tsx`** + +```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([]); + const [showImport, setShowImport] = useState(false); + + async function refresh() { setCards(await cardsApi.list(lessonId)); } + useEffect(() => { refresh(); }, [lessonId]); + + return ( +
+ ← Lessen +

Kaarten

+
+ + Exporteer Excel + Exporteer + sublessen + Start oefenen → +
+ + {showImport && setShowImport(false)} onDone={refresh} />} +
+ ); +} +``` + +- [ ] **Step 4: Mount in router** + +Add to children: +```tsx +{ path: 'admin/lessons/:id', element: }, +``` +And import. + +- [ ] **Step 5: Smoke test** + +Add a few cards. Try import with a small xlsx. Click export — file downloads. + +- [ ] **Step 6: Commit** + +```bash +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`** + +```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 ( +
+ + +
+ {showAnswer ? answer : question} +
+ {!showAnswer && hint && ( +
💡 {hint}
+ )} +
+ {!showAnswer ? ( + + ) : ( + <> + onAnswer('incorrect')}> + Fout + + onAnswer('correct')}> + Goed + + + )} +
+
+
+
+ ); +} +``` + +- [ ] **Step 2: Create `components/Confetti.tsx`** + +```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`** + +```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(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 ( +
+

Sessie starten

+ + + + +
+ ); +} +``` + +- [ ] **Step 4: Create `pages/Practice.tsx`** + +```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(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
Laden...
; + + const isReverse = current.direction === 'backward'; + return ( +
+
+
{session?.cardsShown ?? 0} kaarten behandeld
+ answer(r)} + /> +
+
+ ); +} +``` + +> 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`: +```ts +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: +```ts +get: (id: number) => api.get(`/cards/${id}`), +``` + +In `Practice.tsx` replace the card-fetching effect with: +```tsx +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`** + +```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
Geen sessie gegevens. Terug
; + const total = session.cardsShown || 1; + const pct = Math.round((session.cardsCorrect / total) * 100); + return ( +
+ = 80} /> +

Sessie klaar!

+

{pct}%

+

{session.cardsCorrect} goed, {session.cardsIncorrect} fout van {session.cardsShown}

+

Duur: {session.durationSeconds ?? 0}s

+
+ Opnieuw + Dashboard +
+
+ ); +} +``` + +- [ ] **Step 7: Mount routes** + +In `router.tsx`, add: +```tsx +{ path: 'practice/:lessonId/setup', element: }, +{ path: 'practice/:lessonId', element: }, +{ path: 'practice/:lessonId/done', element: }, +``` +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** + +```bash +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`** + +```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`** + +```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(null); + + useEffect(() => { + refresh(); + statsApi.overview().then(setOv); + }, [refresh]); + + return ( +
+

Dashboard

+
+ + + +
+ +

Lessen

+
    + {tree.map((n) => ( +
  • + {n.name} ({n.cardCount} kaarten) + Oefenen +
  • + ))} +
+ +

Recente sessies

+
    + {ov?.recentSessions.map((s) => ( +
  • + {formatDate(s.startedAt)} — {s.cardsCorrect}/{s.cardsShown} goed · {formatDuration(s.durationSeconds ?? 0)} +
  • + ))} +
+
+ ); +} + +function Stat({ label, value }: { label: string; value: string }) { + return ( +
+
{label}
+
{value}
+
+ ); +} +``` + +- [ ] **Step 3: Create `pages/Stats.tsx`** (overview + heatmap) + +```tsx +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 ( +
+

Statistieken

+
+ {heatmap.map((d) => ( +
+ ))} +
+
+ ); +} +``` + +- [ ] **Step 4: Create `pages/StatsLesson.tsx`** + +```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(null); + useEffect(() => { statsApi.lesson(Number(id)).then(setS); }, [id]); + if (!s) return
Laden...
; + return ( +
+

Les statistiek

+
    +
  • Score: {formatPct(s.score)}
  • +
  • Beheerst: {s.mastered} / {s.totalCards}
  • +
  • Sessies: {s.sessions}
  • +
  • Totale tijd: {formatDuration(s.totalDurationSeconds)}
  • +
  • Pogingen: {s.correct}/{s.attempts} goed
  • +
+
+ ); +} +``` + +- [ ] **Step 5: Create `pages/StatsCard.tsx`** + +```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(null); + useEffect(() => { statsApi.card(Number(id)).then(setS); }, [id]); + if (!s) return
Laden...
; + return ( +
+

Kaart statistiek

+

Doos: {s.box.forward}{s.box.backward != null && ` / ${s.box.backward} (achterwaarts)`}

+

Pogingen: {s.correct}/{s.attempts} goed

+

Volgende due: {s.nextDueAt ? formatDate(s.nextDueAt) : '—'}

+

Geschiedenis

+
    + {s.history.map((h, i) => ( +
  • {formatDate(h.shownAt)} — {h.direction} — {h.result === 'correct' ? '✅' : '❌'}
  • + ))} +
+
+ ); +} +``` + +- [ ] **Step 6: Replace dashboard route + mount stats** + +In `router.tsx` set the index route to ``, and add: +```tsx +{ path: 'stats', element: }, +{ path: 'stats/lessons/:id', element: }, +{ path: 'stats/cards/:id', element: }, +``` +Import all pages. + +- [ ] **Step 7: Commit** + +```bash +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`** + +```tsx +import { useSettings } from '../stores/settingsStore.js'; + +export function SettingsPage() { + const { theme, defaultMaxCards, toggleTheme, setDefaultMaxCards } = useSettings(); + return ( +
+

Instellingen

+ + +
+ ); +} +``` + +- [ ] **Step 2: Update `Layout.tsx`** with settings link & dark toggle + +```tsx +import { Link, Outlet } from 'react-router-dom'; +import { useSettings } from '../stores/settingsStore.js'; + +export function Layout() { + const { theme, toggleTheme } = useSettings(); + return ( +
+
+ +
+
+
+ ); +} +``` + +- [ ] **Step 3: Mount route** + +Add `{ path: 'settings', element: }` and import. + +- [ ] **Step 4: Commit** + +```bash +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`: + +```tsx +import { sessionsApi } from '../api/sessions.js'; +import type { SessionRow } from '@flashcard/shared'; +import { useNavigate } from 'react-router-dom'; +// ...inside component: +const [active, setActive] = useState(null); +const navigate = useNavigate(); +useEffect(() => { + sessionsApi.active().then(setActive).catch(() => {}); +}, []); + +// ...in JSX, above the lessons list: +{active && ( +
+ Je hebt een open sessie ({active.cardsShown} kaarten behandeld). +
+ + +
+
+)} +``` + +- [ ] **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: + +```tsx +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** + +```bash +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`** + +```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`** + +```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: +```json +"e2e": "playwright test --config packages/frontend/playwright.config.ts" +``` + +- [ ] **Step 5: Run** + +Run: `npm run e2e` +Expected: smoke test passes. + +- [ ] **Step 6: Commit** + +```bash +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`** + +```markdown +# 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) + +```bash +npm run build +npm start # serveert frontend + backend op :3000 +``` + +## Tests + +```bash +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?**