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