24 bite-sized TDD tasks covering monorepo setup, backend (lessons, cards, sessions, stats, excel), frontend (admin, practice, dashboard, stats), and E2E smoke tests. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
130 KiB
Flashcard Webapplicatie — Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Build a single-user local flashcard webapp with hierarchical lessons, Leitner-based spaced repetition, Excel import/export, statistics, and animated practice UI.
Architecture: npm-workspaces monorepo. Express+TS backend with SQLite via Drizzle ORM. React+Vite+TS frontend with Tailwind, Zustand, Framer Motion. Shared Zod schemas in a shared package. Backend serves the built frontend in production.
Tech Stack: Node 20+, TypeScript (ESM), Express 4, Drizzle ORM, better-sqlite3, Zod, SheetJS (xlsx), React 18, Vite 7, Tailwind 3, React Router 6, Zustand, Framer Motion, Vitest, Playwright.
Spec: docs/superpowers/specs/2026-05-20-flashcard-app-design.md
File Structure (overview)
flashcard/
├── package.json # workspaces + scripts
├── tsconfig.base.json # shared TS config
├── .gitignore
├── packages/
│ ├── shared/
│ │ ├── package.json
│ │ ├── tsconfig.json
│ │ └── src/
│ │ ├── index.ts # re-exports
│ │ ├── types.ts # domain types
│ │ └── schemas.ts # Zod schemas
│ ├── backend/
│ │ ├── package.json
│ │ ├── tsconfig.json
│ │ ├── drizzle.config.ts
│ │ ├── vitest.config.ts
│ │ ├── src/
│ │ │ ├── index.ts # server entry
│ │ │ ├── app.ts # express app factory
│ │ │ ├── db/
│ │ │ │ ├── client.ts # drizzle client
│ │ │ │ ├── schema.ts # tables
│ │ │ │ └── migrate.ts # migration runner
│ │ │ ├── routes/
│ │ │ │ ├── lessons.ts
│ │ │ │ ├── cards.ts
│ │ │ │ ├── sessions.ts
│ │ │ │ └── stats.ts
│ │ │ ├── services/
│ │ │ │ ├── leitner.ts # pure algorithm
│ │ │ │ ├── sessions.ts # session engine
│ │ │ │ ├── stats.ts # aggregations
│ │ │ │ └── import.ts # excel parsing
│ │ │ ├── lib/
│ │ │ │ ├── errors.ts # error helpers
│ │ │ │ └── excel.ts # SheetJS wrappers
│ │ │ └── tests/ # *.test.ts
│ │ └── drizzle/ # generated migrations
│ └── frontend/
│ ├── package.json
│ ├── tsconfig.json
│ ├── vite.config.ts
│ ├── tailwind.config.ts
│ ├── postcss.config.js
│ ├── index.html
│ └── src/
│ ├── main.tsx
│ ├── App.tsx
│ ├── router.tsx
│ ├── styles.css
│ ├── api/
│ │ ├── client.ts
│ │ ├── lessons.ts
│ │ ├── cards.ts
│ │ ├── sessions.ts
│ │ └── stats.ts
│ ├── stores/
│ │ ├── sessionStore.ts
│ │ ├── lessonsStore.ts
│ │ └── settingsStore.ts
│ ├── pages/
│ │ ├── Dashboard.tsx
│ │ ├── Admin.tsx
│ │ ├── AdminLesson.tsx
│ │ ├── PracticeSetup.tsx
│ │ ├── Practice.tsx
│ │ ├── PracticeDone.tsx
│ │ ├── Stats.tsx
│ │ ├── StatsLesson.tsx
│ │ ├── StatsCard.tsx
│ │ └── Settings.tsx
│ ├── components/
│ │ ├── Layout.tsx
│ │ ├── LessonTree.tsx
│ │ ├── CardTable.tsx
│ │ ├── Flashcard.tsx
│ │ ├── ImportDialog.tsx
│ │ ├── Confetti.tsx
│ │ └── ...
│ └── lib/
│ ├── format.ts
│ └── streak.ts
└── data/
└── templates/import-template.xlsx # generated/committed sample
Task 1: Monorepo bootstrap
Files:
-
Create:
package.json -
Create:
tsconfig.base.json -
Create:
.gitignore(already exists, verify) -
Create:
packages/shared/package.json -
Create:
packages/shared/tsconfig.json -
Create:
packages/shared/src/index.ts -
Step 1: Create root
package.json
{
"name": "flashcard",
"private": true,
"version": "0.1.0",
"type": "module",
"workspaces": ["packages/*"],
"scripts": {
"dev": "concurrently -k -n be,fe -c blue,green \"npm:dev:be\" \"npm:dev:fe\"",
"dev:be": "npm -w @flashcard/backend run dev",
"dev:fe": "npm -w @flashcard/frontend run dev",
"build": "npm -w @flashcard/shared run build && npm -w @flashcard/backend run build && npm -w @flashcard/frontend run build",
"start": "node packages/backend/dist/index.js",
"test": "npm -w @flashcard/backend run test && npm -w @flashcard/frontend run test",
"db:migrate": "npm -w @flashcard/backend run db:migrate",
"db:seed": "npm -w @flashcard/backend run db:seed",
"typecheck": "npm -ws run typecheck --if-present"
},
"devDependencies": {
"concurrently": "^9.0.0",
"typescript": "^5.5.0"
}
}
- Step 2: Create
tsconfig.base.json
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"verbatimModuleSyntax": false
}
}
- *Step 3: Verify
.gitignoreincludes node_modules, dist, .db, .env
Open .gitignore and confirm it matches the file already committed. If missing entries, add them.
- Step 4: Create
packages/shared/package.json
{
"name": "@flashcard/shared",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"typecheck": "tsc --noEmit",
"build": "tsc --noEmit"
},
"dependencies": {
"zod": "^3.23.0"
},
"devDependencies": {
"typescript": "^5.5.0"
}
}
- Step 5: Create
packages/shared/tsconfig.json
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": ["src/**/*"]
}
- Step 6: Create
packages/shared/src/index.ts(placeholder)
export {};
- Step 7: Install root deps
Run: npm install
Expected: workspaces resolved, no errors.
- Step 8: Commit
git add -A
git commit -m "chore: bootstrap monorepo with shared package"
Task 2: Shared types & Zod schemas
Files:
-
Create:
packages/shared/src/types.ts -
Create:
packages/shared/src/schemas.ts -
Modify:
packages/shared/src/index.ts -
Step 1: Write
packages/shared/src/types.ts
export type Direction = 'forward' | 'backward';
export type AttemptResult = 'correct' | 'incorrect';
export type SessionStatus = 'active' | 'completed' | 'abandoned';
export interface Lesson {
id: number;
parentId: number | null;
name: string;
description: string | null;
position: number;
bidirectional: boolean;
createdAt: number;
updatedAt: number;
}
export interface LessonTreeNode extends Lesson {
children: LessonTreeNode[];
cardCount: number;
}
export interface Card {
id: number;
lessonId: number;
question: string;
answer: string;
hint: string | null;
position: number;
createdAt: number;
updatedAt: number;
}
export interface CardProgress {
cardId: number;
direction: Direction;
box: number; // 1..5
correctCount: number;
incorrectCount: number;
lastShownAt: number | null;
nextDueAt: number;
}
export interface SessionRow {
id: number;
lessonId: number;
startedAt: number;
endedAt: number | null;
durationSeconds: number | null;
cardsShown: number;
cardsCorrect: number;
cardsIncorrect: number;
status: SessionStatus;
}
export interface Attempt {
id: number;
sessionId: number;
cardId: number;
direction: Direction;
shownAt: number;
result: AttemptResult;
timeToAnswerMs: number | null;
}
export interface QueueItem {
cardId: number;
direction: Direction;
}
export interface SessionSettings {
maxCards: number | null; // null = all available
shuffle: boolean;
direction: 'forward' | 'backward' | 'both';
}
- Step 2: Write
packages/shared/src/schemas.ts
import { z } from 'zod';
export const lessonCreateSchema = z.object({
parentId: z.number().int().nullable().optional(),
name: z.string().min(1).max(200),
description: z.string().max(2000).optional().nullable(),
bidirectional: z.boolean().optional(),
});
export const lessonUpdateSchema = lessonCreateSchema.partial();
export const lessonMoveSchema = z.object({
parentId: z.number().int().nullable(),
position: z.number().int().min(0),
});
export const cardCreateSchema = z.object({
question: z.string().min(1).max(2000),
answer: z.string().min(1).max(2000),
hint: z.string().max(2000).optional().nullable(),
});
export const cardUpdateSchema = cardCreateSchema.partial();
export const sessionStartSchema = z.object({
lessonId: z.number().int().positive(),
maxCards: z.number().int().min(1).max(500).nullable().optional(),
shuffle: z.boolean().optional(),
direction: z.enum(['forward', 'backward', 'both']).optional(),
});
export const attemptCreateSchema = z.object({
cardId: z.number().int().positive(),
direction: z.enum(['forward', 'backward']),
result: z.enum(['correct', 'incorrect']),
timeToAnswerMs: z.number().int().min(0).nullable().optional(),
});
export type LessonCreateInput = z.infer<typeof lessonCreateSchema>;
export type LessonUpdateInput = z.infer<typeof lessonUpdateSchema>;
export type LessonMoveInput = z.infer<typeof lessonMoveSchema>;
export type CardCreateInput = z.infer<typeof cardCreateSchema>;
export type CardUpdateInput = z.infer<typeof cardUpdateSchema>;
export type SessionStartInput = z.infer<typeof sessionStartSchema>;
export type AttemptCreateInput = z.infer<typeof attemptCreateSchema>;
- Step 3: Update
packages/shared/src/index.ts
export * from './types.js';
export * from './schemas.js';
- Step 4: Typecheck
Run: npm -w @flashcard/shared run typecheck
Expected: no errors.
- Step 5: Commit
git add -A
git commit -m "feat(shared): add domain types and zod schemas"
Task 3: Backend bootstrap
Files:
-
Create:
packages/backend/package.json -
Create:
packages/backend/tsconfig.json -
Create:
packages/backend/vitest.config.ts -
Create:
packages/backend/src/index.ts -
Create:
packages/backend/src/app.ts -
Create:
packages/backend/src/lib/errors.ts -
Step 1: Create
packages/backend/package.json
{
"name": "@flashcard/backend",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "dist/index.js",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc -p tsconfig.json",
"start": "node dist/index.js",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest",
"db:generate": "drizzle-kit generate",
"db:migrate": "tsx src/db/migrate.ts",
"db:seed": "tsx src/db/seed.ts"
},
"dependencies": {
"@flashcard/shared": "*",
"better-sqlite3": "^11.0.0",
"drizzle-orm": "^0.33.0",
"express": "^4.19.0",
"multer": "^1.4.5-lts.1",
"xlsx": "^0.18.5",
"zod": "^3.23.0"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.0",
"@types/express": "^4.17.0",
"@types/multer": "^1.4.0",
"@types/node": "^20.0.0",
"@types/supertest": "^6.0.0",
"drizzle-kit": "^0.24.0",
"supertest": "^7.0.0",
"tsx": "^4.16.0",
"typescript": "^5.5.0",
"vitest": "^2.0.0"
}
}
- Step 2: Create
packages/backend/tsconfig.json
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"module": "NodeNext",
"moduleResolution": "NodeNext"
},
"include": ["src/**/*"]
}
- Step 3: Create
packages/backend/vitest.config.ts
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'node',
include: ['src/**/*.test.ts'],
pool: 'forks',
},
});
- Step 4: Create
packages/backend/src/lib/errors.ts
export class ApiError extends Error {
constructor(
public status: number,
public code: string,
message: string,
public details?: unknown
) {
super(message);
}
static notFound(what = 'Resource') {
return new ApiError(404, 'NOT_FOUND', `${what} not found`);
}
static validation(message: string, details?: unknown) {
return new ApiError(400, 'VALIDATION_ERROR', message, details);
}
static conflict(message: string) {
return new ApiError(409, 'CONFLICT', message);
}
}
- Step 5: Create
packages/backend/src/app.ts
import express, { type Express, type NextFunction, type Request, type Response } from 'express';
import { ZodError } from 'zod';
import { ApiError } from './lib/errors.js';
export function createApp(): Express {
const app = express();
app.use(express.json({ limit: '5mb' }));
app.get('/api/health', (_req, res) => {
res.json({ ok: true });
});
// Routes mounted in later tasks.
app.use((err: unknown, _req: Request, res: Response, _next: NextFunction) => {
if (err instanceof ZodError) {
res.status(400).json({
error: { code: 'VALIDATION_ERROR', message: 'Invalid input', details: err.flatten() },
});
return;
}
if (err instanceof ApiError) {
res.status(err.status).json({
error: { code: err.code, message: err.message, details: err.details },
});
return;
}
console.error(err);
res.status(500).json({ error: { code: 'INTERNAL', message: 'Internal server error' } });
});
return app;
}
- Step 6: Create
packages/backend/src/index.ts
import { createApp } from './app.js';
const PORT = Number(process.env.PORT ?? 3000);
const app = createApp();
app.listen(PORT, () => {
console.log(`Backend listening on http://localhost:${PORT}`);
});
- Step 7: Install deps
Run: npm install
Expected: deps resolved.
- Step 8: Smoke test the server
Run: npm -w @flashcard/backend run dev (let it start, then in another shell: curl http://localhost:3000/api/health)
Expected: {"ok":true}. Stop the server (Ctrl+C).
- Step 9: Commit
git add -A
git commit -m "feat(backend): bootstrap express app with error handling"
Task 4: Database schema + migrations
Files:
-
Create:
packages/backend/drizzle.config.ts -
Create:
packages/backend/src/db/schema.ts -
Create:
packages/backend/src/db/client.ts -
Create:
packages/backend/src/db/migrate.ts -
Create:
packages/backend/src/db/seed.ts -
Step 1: Create
packages/backend/drizzle.config.ts
import { defineConfig } from 'drizzle-kit';
export default defineConfig({
schema: './src/db/schema.ts',
out: './drizzle',
dialect: 'sqlite',
dbCredentials: {
url: process.env.DB_PATH ?? '../../data/flashcard.db',
},
});
- Step 2: Create
packages/backend/src/db/schema.ts
import { sql } from 'drizzle-orm';
import { integer, sqliteTable, text, index } from 'drizzle-orm/sqlite-core';
export const lessons = sqliteTable('lessons', {
id: integer('id').primaryKey({ autoIncrement: true }),
parentId: integer('parent_id'),
name: text('name').notNull(),
description: text('description'),
position: integer('position').notNull().default(0),
bidirectional: integer('bidirectional', { mode: 'boolean' }).notNull().default(false),
createdAt: integer('created_at').notNull().default(sql`(unixepoch())`),
updatedAt: integer('updated_at').notNull().default(sql`(unixepoch())`),
});
export const cards = sqliteTable(
'cards',
{
id: integer('id').primaryKey({ autoIncrement: true }),
lessonId: integer('lesson_id').notNull().references(() => lessons.id, { onDelete: 'cascade' }),
question: text('question').notNull(),
answer: text('answer').notNull(),
hint: text('hint'),
position: integer('position').notNull().default(0),
createdAt: integer('created_at').notNull().default(sql`(unixepoch())`),
updatedAt: integer('updated_at').notNull().default(sql`(unixepoch())`),
},
(t) => ({ lessonIdx: index('cards_lesson_idx').on(t.lessonId) })
);
export const cardProgress = sqliteTable(
'card_progress',
{
cardId: integer('card_id').notNull().references(() => cards.id, { onDelete: 'cascade' }),
direction: text('direction', { enum: ['forward', 'backward'] }).notNull(),
box: integer('box').notNull().default(1),
correctCount: integer('correct_count').notNull().default(0),
incorrectCount: integer('incorrect_count').notNull().default(0),
lastShownAt: integer('last_shown_at'),
nextDueAt: integer('next_due_at').notNull().default(0),
},
(t) => ({
pk: index('card_progress_pk').on(t.cardId, t.direction),
dueIdx: index('card_progress_due_idx').on(t.nextDueAt),
})
);
export const sessions = sqliteTable(
'sessions',
{
id: integer('id').primaryKey({ autoIncrement: true }),
lessonId: integer('lesson_id').notNull().references(() => lessons.id, { onDelete: 'cascade' }),
startedAt: integer('started_at').notNull().default(sql`(unixepoch())`),
endedAt: integer('ended_at'),
durationSeconds: integer('duration_seconds'),
cardsShown: integer('cards_shown').notNull().default(0),
cardsCorrect: integer('cards_correct').notNull().default(0),
cardsIncorrect: integer('cards_incorrect').notNull().default(0),
status: text('status', { enum: ['active', 'completed', 'abandoned'] }).notNull().default('active'),
queueSnapshot: text('queue_snapshot'),
},
(t) => ({ statusIdx: index('sessions_status_idx').on(t.status) })
);
export const attempts = sqliteTable(
'attempts',
{
id: integer('id').primaryKey({ autoIncrement: true }),
sessionId: integer('session_id').notNull().references(() => sessions.id, { onDelete: 'cascade' }),
cardId: integer('card_id').notNull().references(() => cards.id, { onDelete: 'cascade' }),
direction: text('direction', { enum: ['forward', 'backward'] }).notNull(),
shownAt: integer('shown_at').notNull().default(sql`(unixepoch())`),
result: text('result', { enum: ['correct', 'incorrect'] }).notNull(),
timeToAnswerMs: integer('time_to_answer_ms'),
},
(t) => ({
sessionIdx: index('attempts_session_idx').on(t.sessionId),
cardIdx: index('attempts_card_idx').on(t.cardId),
})
);
export type LessonRow = typeof lessons.$inferSelect;
export type CardRow = typeof cards.$inferSelect;
export type CardProgressRow = typeof cardProgress.$inferSelect;
export type SessionRow = typeof sessions.$inferSelect;
export type AttemptRow = typeof attempts.$inferSelect;
- Step 3: Create
packages/backend/src/db/client.ts
import Database from 'better-sqlite3';
import { drizzle, type BetterSQLite3Database } from 'drizzle-orm/better-sqlite3';
import { mkdirSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import * as schema from './schema.js';
export type Db = BetterSQLite3Database<typeof schema>;
export function createDb(dbPath?: string): { db: Db; sqlite: Database.Database } {
const path = dbPath ?? process.env.DB_PATH ?? resolve(process.cwd(), '../../data/flashcard.db');
if (path !== ':memory:') {
mkdirSync(dirname(path), { recursive: true });
}
const sqlite = new Database(path);
sqlite.pragma('journal_mode = WAL');
sqlite.pragma('foreign_keys = ON');
const db = drizzle(sqlite, { schema });
return { db, sqlite };
}
- Step 4: Generate initial migration
Run: npm -w @flashcard/backend run db:generate
Expected: a SQL file appears in packages/backend/drizzle/0000_*.sql.
- Step 5: Create
packages/backend/src/db/migrate.ts
import { migrate } from 'drizzle-orm/better-sqlite3/migrator';
import { resolve } from 'node:path';
import { createDb } from './client.js';
const { db, sqlite } = createDb();
migrate(db, { migrationsFolder: resolve(import.meta.dirname, '../../drizzle') });
sqlite.close();
console.log('Migrations applied.');
- Step 6: Run migrations
Run: npm run db:migrate
Expected: data/flashcard.db is created and Migrations applied. is logged.
- Step 7: Create
packages/backend/src/db/seed.ts
import { createDb } from './client.js';
import { cards, lessons } from './schema.js';
const { db, sqlite } = createDb();
const [root] = db.insert(lessons).values({ name: 'Demo: Spaans', position: 0 }).returning().all();
const [sub] = db.insert(lessons).values({ name: 'Begroetingen', parentId: root.id, position: 0 }).returning().all();
db.insert(cards).values([
{ lessonId: sub.id, question: 'Hallo', answer: 'Hola', position: 0 },
{ lessonId: sub.id, question: 'Goedemorgen', answer: 'Buenos días', position: 1 },
{ lessonId: sub.id, question: 'Tot ziens', answer: 'Adiós', position: 2 },
]).run();
sqlite.close();
console.log('Seed inserted.');
- Step 8: Commit
git add -A
git commit -m "feat(db): drizzle schema, migrations, and seed"
Task 5: Leitner algorithm (pure, TDD)
Files:
-
Create:
packages/backend/src/services/leitner.ts -
Create:
packages/backend/src/services/leitner.test.ts -
Step 1: Write the failing tests
// leitner.test.ts
import { describe, it, expect } from 'vitest';
import { applyResult, BOX_INTERVALS_SEC, MAX_BOX } from './leitner.js';
describe('leitner.applyResult', () => {
const now = 1_000_000;
it('moves correct answer to next box and schedules due', () => {
const next = applyResult({ box: 1, correctCount: 0, incorrectCount: 0 }, 'correct', now);
expect(next.box).toBe(2);
expect(next.nextDueAt).toBe(now + BOX_INTERVALS_SEC[2]);
expect(next.correctCount).toBe(1);
});
it('caps box at MAX_BOX on correct answer', () => {
const next = applyResult({ box: MAX_BOX, correctCount: 9, incorrectCount: 0 }, 'correct', now);
expect(next.box).toBe(MAX_BOX);
expect(next.nextDueAt).toBe(now + BOX_INTERVALS_SEC[MAX_BOX]);
});
it('resets to box 1 on incorrect', () => {
const next = applyResult({ box: 4, correctCount: 3, incorrectCount: 1 }, 'incorrect', now);
expect(next.box).toBe(1);
expect(next.nextDueAt).toBe(now + BOX_INTERVALS_SEC[1]);
expect(next.incorrectCount).toBe(2);
});
});
- Step 2: Run tests — they fail
Run: npm -w @flashcard/backend test
Expected: failure (module not found).
- Step 3: Implement
leitner.ts
import type { AttemptResult } from '@flashcard/shared';
export const MAX_BOX = 5;
// index 1..5; index 0 unused
export const BOX_INTERVALS_SEC: Record<number, number> = {
1: 0,
2: 1 * 24 * 60 * 60,
3: 3 * 24 * 60 * 60,
4: 7 * 24 * 60 * 60,
5: 14 * 24 * 60 * 60,
};
export interface ProgressLike {
box: number;
correctCount: number;
incorrectCount: number;
}
export interface ProgressDelta {
box: number;
correctCount: number;
incorrectCount: number;
nextDueAt: number;
lastShownAt: number;
}
export function applyResult(
current: ProgressLike,
result: AttemptResult,
nowSec: number
): ProgressDelta {
let box = current.box;
let correctCount = current.correctCount;
let incorrectCount = current.incorrectCount;
if (result === 'correct') {
box = Math.min(MAX_BOX, current.box + 1);
correctCount += 1;
} else {
box = 1;
incorrectCount += 1;
}
return {
box,
correctCount,
incorrectCount,
nextDueAt: nowSec + BOX_INTERVALS_SEC[box],
lastShownAt: nowSec,
};
}
- Step 4: Run tests — they pass
Run: npm -w @flashcard/backend test
Expected: 3 tests pass.
- Step 5: Commit
git add -A
git commit -m "feat(backend): leitner algorithm with tests"
Task 6: Test helpers — in-memory db
Files:
-
Create:
packages/backend/src/tests/dbHelper.ts -
Step 1: Create
dbHelper.ts
import { migrate } from 'drizzle-orm/better-sqlite3/migrator';
import { resolve } from 'node:path';
import { createDb, type Db } from '../db/client.js';
export function makeTestDb(): { db: Db; close: () => void } {
const { db, sqlite } = createDb(':memory:');
migrate(db, { migrationsFolder: resolve(import.meta.dirname, '../../drizzle') });
return { db, close: () => sqlite.close() };
}
- Step 2: Commit
git add -A
git commit -m "test(backend): in-memory db helper"
Task 7: Lessons CRUD service + routes
Files:
-
Create:
packages/backend/src/services/lessons.ts -
Create:
packages/backend/src/services/lessons.test.ts -
Create:
packages/backend/src/routes/lessons.ts -
Modify:
packages/backend/src/app.ts -
Step 1: Write failing tests for
lessons.tsservice
// lessons.test.ts
import { describe, it, expect, beforeEach } from 'vitest';
import { makeTestDb } from '../tests/dbHelper.js';
import { createLesson, getLessonTree, updateLesson, deleteLesson, moveLesson } from './lessons.js';
let env: ReturnType<typeof makeTestDb>;
beforeEach(() => {
env = makeTestDb();
});
describe('lessons service', () => {
it('creates a root lesson', async () => {
const lesson = await createLesson(env.db, { name: 'Spaans' });
expect(lesson.id).toBeGreaterThan(0);
expect(lesson.parentId).toBeNull();
expect(lesson.bidirectional).toBe(false);
});
it('builds a tree with children and card counts', async () => {
const root = await createLesson(env.db, { name: 'A' });
const child = await createLesson(env.db, { name: 'B', parentId: root.id });
const tree = await getLessonTree(env.db);
expect(tree).toHaveLength(1);
expect(tree[0].id).toBe(root.id);
expect(tree[0].children).toHaveLength(1);
expect(tree[0].children[0].id).toBe(child.id);
expect(tree[0].cardCount).toBe(0);
});
it('updates name and bidirectional flag', async () => {
const l = await createLesson(env.db, { name: 'X' });
const updated = await updateLesson(env.db, l.id, { name: 'Y', bidirectional: true });
expect(updated.name).toBe('Y');
expect(updated.bidirectional).toBe(true);
});
it('moves a lesson to a new parent and position', async () => {
const a = await createLesson(env.db, { name: 'A' });
const b = await createLesson(env.db, { name: 'B' });
const c = await createLesson(env.db, { name: 'C', parentId: a.id });
await moveLesson(env.db, c.id, { parentId: b.id, position: 0 });
const tree = await getLessonTree(env.db);
const bNode = tree.find((n) => n.id === b.id)!;
expect(bNode.children.map((c) => c.id)).toEqual([c.id]);
});
it('deletes a lesson and cascades to children', async () => {
const a = await createLesson(env.db, { name: 'A' });
await createLesson(env.db, { name: 'B', parentId: a.id });
await deleteLesson(env.db, a.id);
const tree = await getLessonTree(env.db);
expect(tree).toHaveLength(0);
});
});
- Step 2: Run tests — fail
Run: npm -w @flashcard/backend test
Expected: module not found.
- Step 3: Implement
services/lessons.ts
import { and, eq, isNull, sql } from 'drizzle-orm';
import type { Db } from '../db/client.js';
import { cards, lessons } from '../db/schema.js';
import { ApiError } from '../lib/errors.js';
import type {
Lesson,
LessonTreeNode,
LessonCreateInput,
LessonUpdateInput,
LessonMoveInput,
} from '@flashcard/shared';
function rowToLesson(r: typeof lessons.$inferSelect): Lesson {
return {
id: r.id,
parentId: r.parentId ?? null,
name: r.name,
description: r.description ?? null,
position: r.position,
bidirectional: r.bidirectional,
createdAt: r.createdAt,
updatedAt: r.updatedAt,
};
}
async function nextPosition(db: Db, parentId: number | null): Promise<number> {
const rows = parentId == null
? db.select({ pos: lessons.position }).from(lessons).where(isNull(lessons.parentId)).all()
: db.select({ pos: lessons.position }).from(lessons).where(eq(lessons.parentId, parentId)).all();
return rows.length === 0 ? 0 : Math.max(...rows.map((r) => r.pos)) + 1;
}
export async function createLesson(db: Db, input: LessonCreateInput): Promise<Lesson> {
const parentId = input.parentId ?? null;
if (parentId !== null) {
const exists = db.select().from(lessons).where(eq(lessons.id, parentId)).get();
if (!exists) throw ApiError.notFound('Parent lesson');
}
const position = await nextPosition(db, parentId);
const [row] = db.insert(lessons).values({
name: input.name,
parentId,
description: input.description ?? null,
bidirectional: input.bidirectional ?? false,
position,
}).returning().all();
return rowToLesson(row);
}
export async function updateLesson(db: Db, id: number, input: LessonUpdateInput): Promise<Lesson> {
const existing = db.select().from(lessons).where(eq(lessons.id, id)).get();
if (!existing) throw ApiError.notFound('Lesson');
const [row] = db.update(lessons).set({
...(input.name !== undefined && { name: input.name }),
...(input.description !== undefined && { description: input.description }),
...(input.bidirectional !== undefined && { bidirectional: input.bidirectional }),
updatedAt: Math.floor(Date.now() / 1000),
}).where(eq(lessons.id, id)).returning().all();
return rowToLesson(row);
}
export async function deleteLesson(db: Db, id: number): Promise<void> {
const r = db.delete(lessons).where(eq(lessons.id, id)).run();
if (r.changes === 0) throw ApiError.notFound('Lesson');
}
export async function moveLesson(db: Db, id: number, input: LessonMoveInput): Promise<Lesson> {
const existing = db.select().from(lessons).where(eq(lessons.id, id)).get();
if (!existing) throw ApiError.notFound('Lesson');
if (input.parentId !== null) {
const p = db.select().from(lessons).where(eq(lessons.id, input.parentId)).get();
if (!p) throw ApiError.notFound('Parent lesson');
// Prevent cycles
let cursor: number | null = input.parentId;
while (cursor !== null) {
if (cursor === id) throw ApiError.validation('Cannot move lesson into its own descendant');
const row = db.select({ parentId: lessons.parentId }).from(lessons).where(eq(lessons.id, cursor)).get();
cursor = row?.parentId ?? null;
}
}
const [row] = db.update(lessons).set({
parentId: input.parentId,
position: input.position,
updatedAt: Math.floor(Date.now() / 1000),
}).where(eq(lessons.id, id)).returning().all();
return rowToLesson(row);
}
export async function getLessonTree(db: Db): Promise<LessonTreeNode[]> {
const all = db.select().from(lessons).orderBy(lessons.position).all();
const counts = db
.select({ lessonId: cards.lessonId, count: sql<number>`count(*)`.as('count') })
.from(cards)
.groupBy(cards.lessonId)
.all();
const countMap = new Map(counts.map((c) => [c.lessonId, Number(c.count)]));
const nodes = new Map<number, LessonTreeNode>();
for (const r of all) {
nodes.set(r.id, { ...rowToLesson(r), children: [], cardCount: countMap.get(r.id) ?? 0 });
}
const roots: LessonTreeNode[] = [];
for (const n of nodes.values()) {
if (n.parentId === null) roots.push(n);
else nodes.get(n.parentId)?.children.push(n);
}
return roots;
}
export async function getDescendantLessonIds(db: Db, rootId: number): Promise<number[]> {
const all = db.select({ id: lessons.id, parentId: lessons.parentId }).from(lessons).all();
const byParent = new Map<number | null, number[]>();
for (const r of all) {
const key = r.parentId ?? null;
if (!byParent.has(key)) byParent.set(key, []);
byParent.get(key)!.push(r.id);
}
const result: number[] = [rootId];
const stack = [rootId];
while (stack.length) {
const cur = stack.pop()!;
for (const child of byParent.get(cur) ?? []) {
result.push(child);
stack.push(child);
}
}
return result;
}
- Step 4: Run service tests — pass
Run: npm -w @flashcard/backend test
Expected: all lesson tests pass.
- Step 5: Create
routes/lessons.ts
import { Router } from 'express';
import { lessonCreateSchema, lessonMoveSchema, lessonUpdateSchema } from '@flashcard/shared';
import type { Db } from '../db/client.js';
import {
createLesson, deleteLesson, getLessonTree, moveLesson, updateLesson,
} from '../services/lessons.js';
export function lessonsRouter(db: Db): Router {
const r = Router();
r.get('/tree', async (_req, res, next) => {
try { res.json(await getLessonTree(db)); } catch (e) { next(e); }
});
r.post('/', async (req, res, next) => {
try {
const input = lessonCreateSchema.parse(req.body);
res.status(201).json(await createLesson(db, input));
} catch (e) { next(e); }
});
r.patch('/:id', async (req, res, next) => {
try {
const id = Number(req.params.id);
const input = lessonUpdateSchema.parse(req.body);
res.json(await updateLesson(db, id, input));
} catch (e) { next(e); }
});
r.delete('/:id', async (req, res, next) => {
try {
await deleteLesson(db, Number(req.params.id));
res.status(204).end();
} catch (e) { next(e); }
});
r.post('/:id/move', async (req, res, next) => {
try {
const input = lessonMoveSchema.parse(req.body);
res.json(await moveLesson(db, Number(req.params.id), input));
} catch (e) { next(e); }
});
return r;
}
- Step 6: Wire router in
app.ts
Modify createApp() to accept a Db parameter and mount the router. Update app.ts:
import express, { type Express, type NextFunction, type Request, type Response } from 'express';
import { ZodError } from 'zod';
import type { Db } from './db/client.js';
import { ApiError } from './lib/errors.js';
import { lessonsRouter } from './routes/lessons.js';
export function createApp(db: Db): Express {
const app = express();
app.use(express.json({ limit: '5mb' }));
app.get('/api/health', (_req, res) => res.json({ ok: true }));
app.use('/api/lessons', lessonsRouter(db));
app.use((err: unknown, _req: Request, res: Response, _next: NextFunction) => {
if (err instanceof ZodError) {
res.status(400).json({ error: { code: 'VALIDATION_ERROR', message: 'Invalid input', details: err.flatten() } });
return;
}
if (err instanceof ApiError) {
res.status(err.status).json({ error: { code: err.code, message: err.message, details: err.details } });
return;
}
console.error(err);
res.status(500).json({ error: { code: 'INTERNAL', message: 'Internal server error' } });
});
return app;
}
- Step 7: Update
index.ts
import { createApp } from './app.js';
import { createDb } from './db/client.js';
const PORT = Number(process.env.PORT ?? 3000);
const { db } = createDb();
const app = createApp(db);
app.listen(PORT, () => {
console.log(`Backend listening on http://localhost:${PORT}`);
});
- Step 8: Commit
git add -A
git commit -m "feat(backend): lessons CRUD service and routes"
Task 8: Cards CRUD service + routes
Files:
-
Create:
packages/backend/src/services/cards.ts -
Create:
packages/backend/src/services/cards.test.ts -
Create:
packages/backend/src/routes/cards.ts -
Modify:
packages/backend/src/app.ts -
Step 1: Write failing tests
// cards.test.ts
import { describe, it, expect, beforeEach } from 'vitest';
import { makeTestDb } from '../tests/dbHelper.js';
import { createLesson } from './lessons.js';
import { createCard, listCardsByLesson, updateCard, deleteCard } from './cards.js';
let env: ReturnType<typeof makeTestDb>;
beforeEach(() => { env = makeTestDb(); });
describe('cards service', () => {
it('creates a card and initializes forward progress', async () => {
const lesson = await createLesson(env.db, { name: 'L' });
const card = await createCard(env.db, lesson.id, { question: 'Q', answer: 'A' });
expect(card.lessonId).toBe(lesson.id);
expect(card.position).toBe(0);
const list = await listCardsByLesson(env.db, lesson.id);
expect(list).toHaveLength(1);
});
it('creates two progress rows when lesson is bidirectional', async () => {
const lesson = await createLesson(env.db, { name: 'L', bidirectional: true });
const card = await createCard(env.db, lesson.id, { question: 'Q', answer: 'A' });
// verified indirectly via lessons-bidirectional task; here check no error
expect(card.id).toBeGreaterThan(0);
});
it('updates a card', async () => {
const l = await createLesson(env.db, { name: 'L' });
const c = await createCard(env.db, l.id, { question: 'Q', answer: 'A' });
const u = await updateCard(env.db, c.id, { hint: 'tip' });
expect(u.hint).toBe('tip');
});
it('deletes a card', async () => {
const l = await createLesson(env.db, { name: 'L' });
const c = await createCard(env.db, l.id, { question: 'Q', answer: 'A' });
await deleteCard(env.db, c.id);
expect(await listCardsByLesson(env.db, l.id)).toHaveLength(0);
});
});
- Step 2: Run — fail
Run: npm -w @flashcard/backend test
Expected: module not found.
- Step 3: Implement
services/cards.ts
import { eq, sql } from 'drizzle-orm';
import type { Db } from '../db/client.js';
import { cardProgress, cards, lessons } from '../db/schema.js';
import { ApiError } from '../lib/errors.js';
import type { Card, CardCreateInput, CardUpdateInput, Direction } from '@flashcard/shared';
function rowToCard(r: typeof cards.$inferSelect): Card {
return {
id: r.id,
lessonId: r.lessonId,
question: r.question,
answer: r.answer,
hint: r.hint ?? null,
position: r.position,
createdAt: r.createdAt,
updatedAt: r.updatedAt,
};
}
function ensureProgress(db: Db, cardId: number, direction: Direction) {
const existing = db
.select()
.from(cardProgress)
.where(sql`${cardProgress.cardId} = ${cardId} AND ${cardProgress.direction} = ${direction}`)
.get();
if (!existing) {
db.insert(cardProgress).values({ cardId, direction, box: 1, nextDueAt: 0 }).run();
}
}
export async function createCard(db: Db, lessonId: number, input: CardCreateInput): Promise<Card> {
const lesson = db.select().from(lessons).where(eq(lessons.id, lessonId)).get();
if (!lesson) throw ApiError.notFound('Lesson');
const positions = db.select({ pos: cards.position }).from(cards).where(eq(cards.lessonId, lessonId)).all();
const position = positions.length === 0 ? 0 : Math.max(...positions.map((p) => p.pos)) + 1;
const [row] = db.insert(cards).values({
lessonId,
question: input.question,
answer: input.answer,
hint: input.hint ?? null,
position,
}).returning().all();
ensureProgress(db, row.id, 'forward');
if (lesson.bidirectional) ensureProgress(db, row.id, 'backward');
return rowToCard(row);
}
export async function listCardsByLesson(db: Db, lessonId: number): Promise<Card[]> {
return db.select().from(cards).where(eq(cards.lessonId, lessonId)).orderBy(cards.position).all().map(rowToCard);
}
export async function getCard(db: Db, id: number): Promise<Card> {
const row = db.select().from(cards).where(eq(cards.id, id)).get();
if (!row) throw ApiError.notFound('Card');
return rowToCard(row);
}
export async function updateCard(db: Db, id: number, input: CardUpdateInput): Promise<Card> {
const existing = db.select().from(cards).where(eq(cards.id, id)).get();
if (!existing) throw ApiError.notFound('Card');
const [row] = db.update(cards).set({
...(input.question !== undefined && { question: input.question }),
...(input.answer !== undefined && { answer: input.answer }),
...(input.hint !== undefined && { hint: input.hint }),
updatedAt: Math.floor(Date.now() / 1000),
}).where(eq(cards.id, id)).returning().all();
return rowToCard(row);
}
export async function deleteCard(db: Db, id: number): Promise<void> {
const r = db.delete(cards).where(eq(cards.id, id)).run();
if (r.changes === 0) throw ApiError.notFound('Card');
}
- Step 4: Run — pass
Run: npm -w @flashcard/backend test
Expected: cards tests pass.
- Step 5: Create
routes/cards.ts
import { Router } from 'express';
import { cardCreateSchema, cardUpdateSchema } from '@flashcard/shared';
import type { Db } from '../db/client.js';
import { createCard, deleteCard, listCardsByLesson, updateCard } from '../services/cards.js';
export function cardsRouter(db: Db): Router {
const r = Router({ mergeParams: true });
r.get('/lessons/:lessonId/cards', async (req, res, next) => {
try { res.json(await listCardsByLesson(db, Number(req.params.lessonId))); } catch (e) { next(e); }
});
r.post('/lessons/:lessonId/cards', async (req, res, next) => {
try {
const input = cardCreateSchema.parse(req.body);
res.status(201).json(await createCard(db, Number(req.params.lessonId), input));
} catch (e) { next(e); }
});
r.patch('/cards/:id', async (req, res, next) => {
try {
const input = cardUpdateSchema.parse(req.body);
res.json(await updateCard(db, Number(req.params.id), input));
} catch (e) { next(e); }
});
r.delete('/cards/:id', async (req, res, next) => {
try { await deleteCard(db, Number(req.params.id)); res.status(204).end(); } catch (e) { next(e); }
});
return r;
}
- Step 6: Mount router in
app.ts
Add after app.use('/api/lessons', lessonsRouter(db));:
import { cardsRouter } from './routes/cards.js';
// ...
app.use('/api', cardsRouter(db));
- Step 7: Commit
git add -A
git commit -m "feat(backend): cards CRUD service and routes"
Task 9: Session engine + Leitner integration (TDD)
Files:
-
Create:
packages/backend/src/services/sessions.ts -
Create:
packages/backend/src/services/sessions.test.ts -
Step 1: Write failing tests covering queue build, next, record attempt, end
// sessions.test.ts
import { describe, it, expect, beforeEach } from 'vitest';
import { makeTestDb } from '../tests/dbHelper.js';
import { createLesson } from './lessons.js';
import { createCard } from './cards.js';
import { startSession, getNextItem, recordAttempt, endSession, getActiveSession } from './sessions.js';
let env: ReturnType<typeof makeTestDb>;
beforeEach(() => { env = makeTestDb(); });
async function seedLesson(name = 'L', cards = 3, bidi = false) {
const lesson = await createLesson(env.db, { name, bidirectional: bidi });
for (let i = 0; i < cards; i++) {
await createCard(env.db, lesson.id, { question: `q${i}`, answer: `a${i}` });
}
return lesson;
}
describe('session engine', () => {
it('starts a session and queues all cards from lesson + descendants', async () => {
const root = await createLesson(env.db, { name: 'R' });
const child = await createLesson(env.db, { name: 'C', parentId: root.id });
await createCard(env.db, root.id, { question: 'r1', answer: 'a' });
await createCard(env.db, child.id, { question: 'c1', answer: 'a' });
const s = await startSession(env.db, { lessonId: root.id, shuffle: false });
expect(s.queue).toHaveLength(2);
});
it('returns items in queue order and marks done when exhausted', async () => {
const l = await seedLesson('L', 2);
const s = await startSession(env.db, { lessonId: l.id, shuffle: false });
const a = await getNextItem(env.db, s.session.id);
expect(a).not.toBeNull();
await recordAttempt(env.db, s.session.id, { cardId: a!.cardId, direction: 'forward', result: 'correct' });
const b = await getNextItem(env.db, s.session.id);
expect(b).not.toBeNull();
await recordAttempt(env.db, s.session.id, { cardId: b!.cardId, direction: 'forward', result: 'correct' });
const done = await getNextItem(env.db, s.session.id);
expect(done).toBeNull();
});
it('reinserts an incorrect card later in the same session', async () => {
const l = await seedLesson('L', 4);
const s = await startSession(env.db, { lessonId: l.id, shuffle: false });
const first = await getNextItem(env.db, s.session.id);
await recordAttempt(env.db, s.session.id, { cardId: first!.cardId, direction: 'forward', result: 'incorrect' });
// Burn through 3 more; the wrong card should reappear
const seen: number[] = [first!.cardId];
for (let i = 0; i < 4; i++) {
const nx = await getNextItem(env.db, s.session.id);
if (!nx) break;
seen.push(nx.cardId);
await recordAttempt(env.db, s.session.id, { cardId: nx.cardId, direction: 'forward', result: 'correct' });
}
expect(seen.filter((c) => c === first!.cardId).length).toBeGreaterThanOrEqual(2);
});
it('respects maxCards limit', async () => {
const l = await seedLesson('L', 10);
const s = await startSession(env.db, { lessonId: l.id, shuffle: false, maxCards: 3 });
expect(s.queue).toHaveLength(3);
});
it('tracks counters on session and ends with duration', async () => {
const l = await seedLesson('L', 2);
const s = await startSession(env.db, { lessonId: l.id, shuffle: false });
const a = await getNextItem(env.db, s.session.id);
await recordAttempt(env.db, s.session.id, { cardId: a!.cardId, direction: 'forward', result: 'correct' });
const b = await getNextItem(env.db, s.session.id);
await recordAttempt(env.db, s.session.id, { cardId: b!.cardId, direction: 'forward', result: 'incorrect' });
const ended = await endSession(env.db, s.session.id);
expect(ended.cardsCorrect).toBe(1);
expect(ended.cardsIncorrect).toBe(1);
expect(ended.cardsShown).toBe(2);
expect(ended.status).toBe('completed');
expect(ended.durationSeconds).not.toBeNull();
});
it('returns active session if one exists', async () => {
const l = await seedLesson('L', 1);
const s = await startSession(env.db, { lessonId: l.id });
const active = await getActiveSession(env.db);
expect(active?.id).toBe(s.session.id);
await endSession(env.db, s.session.id);
const after = await getActiveSession(env.db);
expect(after).toBeNull();
});
});
- Step 2: Run — fail
Run: npm -w @flashcard/backend test
Expected: module not found.
- Step 3: Implement
services/sessions.ts
import { and, eq, inArray, sql } from 'drizzle-orm';
import type { Db } from '../db/client.js';
import { attempts, cardProgress, cards, lessons, sessions } from '../db/schema.js';
import { ApiError } from '../lib/errors.js';
import type {
AttemptCreateInput, Direction, QueueItem, SessionRow, SessionStartInput,
} from '@flashcard/shared';
import { applyResult } from './leitner.js';
import { getDescendantLessonIds } from './lessons.js';
const REINSERT_OFFSET = 3;
function nowSec() { return Math.floor(Date.now() / 1000); }
function shuffleInPlace<T>(arr: T[]): T[] {
for (let i = arr.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[arr[i], arr[j]] = [arr[j]!, arr[i]!];
}
return arr;
}
export interface StartedSession {
session: SessionRow;
queue: QueueItem[];
}
export async function startSession(db: Db, input: SessionStartInput): Promise<StartedSession> {
const lesson = db.select().from(lessons).where(eq(lessons.id, input.lessonId)).get();
if (!lesson) throw ApiError.notFound('Lesson');
const lessonIds = await getDescendantLessonIds(db, input.lessonId);
const lessonRows = db.select().from(lessons).where(inArray(lessons.id, lessonIds)).all();
const bidirById = new Map(lessonRows.map((l) => [l.id, l.bidirectional]));
const allCards = db.select().from(cards).where(inArray(cards.lessonId, lessonIds)).all();
const direction = input.direction ?? 'forward';
// Build candidate queue items
const candidates: QueueItem[] = [];
for (const c of allCards) {
const isBidi = bidirById.get(c.lessonId) === true;
if (direction === 'forward' || direction === 'both') {
candidates.push({ cardId: c.id, direction: 'forward' });
}
if ((direction === 'backward' || direction === 'both') && isBidi) {
candidates.push({ cardId: c.id, direction: 'backward' });
}
}
// Ensure progress rows exist & fetch boxes
for (const item of candidates) {
const existing = db.select().from(cardProgress)
.where(sql`${cardProgress.cardId} = ${item.cardId} AND ${cardProgress.direction} = ${item.direction}`)
.get();
if (!existing) {
db.insert(cardProgress).values({
cardId: item.cardId, direction: item.direction, box: 1, nextDueAt: 0,
}).run();
}
}
const progressRows = db.select().from(cardProgress)
.where(inArray(cardProgress.cardId, allCards.map((c) => c.id)))
.all();
const progByKey = new Map<string, typeof progressRows[number]>();
for (const p of progressRows) progByKey.set(`${p.cardId}:${p.direction}`, p);
const now = nowSec();
// Partition: due first (box ascending), then non-due (box ascending)
const due: QueueItem[] = [];
const future: QueueItem[] = [];
for (const item of candidates) {
const p = progByKey.get(`${item.cardId}:${item.direction}`)!;
(p.nextDueAt <= now ? due : future).push(item);
}
const shuffle = input.shuffle ?? true;
const sortByBox = (a: QueueItem, b: QueueItem) => {
const pa = progByKey.get(`${a.cardId}:${a.direction}`)!;
const pb = progByKey.get(`${b.cardId}:${b.direction}`)!;
return pa.box - pb.box;
};
if (shuffle) {
shuffleInPlace(due);
shuffleInPlace(future);
}
due.sort(sortByBox);
future.sort(sortByBox);
let queue: QueueItem[] = [...due, ...future];
const max = input.maxCards ?? null;
if (max !== null) queue = queue.slice(0, max);
const [row] = db.insert(sessions).values({
lessonId: input.lessonId,
queueSnapshot: JSON.stringify({ remaining: queue, index: 0 }),
}).returning().all();
return { session: rowToSession(row), queue };
}
function rowToSession(r: typeof sessions.$inferSelect): SessionRow {
return {
id: r.id,
lessonId: r.lessonId,
startedAt: r.startedAt,
endedAt: r.endedAt ?? null,
durationSeconds: r.durationSeconds ?? null,
cardsShown: r.cardsShown,
cardsCorrect: r.cardsCorrect,
cardsIncorrect: r.cardsIncorrect,
status: r.status,
};
}
interface QueueState { remaining: QueueItem[]; index: number; }
function readQueue(snapshot: string | null | undefined): QueueState {
if (!snapshot) return { remaining: [], index: 0 };
return JSON.parse(snapshot) as QueueState;
}
export async function getNextItem(db: Db, sessionId: number): Promise<QueueItem | null> {
const row = db.select().from(sessions).where(eq(sessions.id, sessionId)).get();
if (!row) throw ApiError.notFound('Session');
if (row.status !== 'active') return null;
const state = readQueue(row.queueSnapshot);
return state.remaining[state.index] ?? null;
}
export async function recordAttempt(
db: Db,
sessionId: number,
input: AttemptCreateInput
): Promise<void> {
const sess = db.select().from(sessions).where(eq(sessions.id, sessionId)).get();
if (!sess) throw ApiError.notFound('Session');
if (sess.status !== 'active') throw ApiError.validation('Session is not active');
const now = nowSec();
db.insert(attempts).values({
sessionId,
cardId: input.cardId,
direction: input.direction,
result: input.result,
timeToAnswerMs: input.timeToAnswerMs ?? null,
}).run();
const prog = db.select().from(cardProgress).where(
sql`${cardProgress.cardId} = ${input.cardId} AND ${cardProgress.direction} = ${input.direction}`
).get();
if (prog) {
const delta = applyResult(
{ box: prog.box, correctCount: prog.correctCount, incorrectCount: prog.incorrectCount },
input.result,
now
);
db.update(cardProgress).set({
box: delta.box,
correctCount: delta.correctCount,
incorrectCount: delta.incorrectCount,
nextDueAt: delta.nextDueAt,
lastShownAt: delta.lastShownAt,
}).where(
sql`${cardProgress.cardId} = ${input.cardId} AND ${cardProgress.direction} = ${input.direction}`
).run();
}
// Update queue state
const state = readQueue(sess.queueSnapshot);
state.index += 1;
if (input.result === 'incorrect') {
const insertAt = Math.min(state.remaining.length, state.index + REINSERT_OFFSET);
state.remaining.splice(insertAt, 0, { cardId: input.cardId, direction: input.direction });
}
db.update(sessions).set({
queueSnapshot: JSON.stringify(state),
cardsShown: sess.cardsShown + 1,
cardsCorrect: sess.cardsCorrect + (input.result === 'correct' ? 1 : 0),
cardsIncorrect: sess.cardsIncorrect + (input.result === 'incorrect' ? 1 : 0),
}).where(eq(sessions.id, sessionId)).run();
}
export async function endSession(db: Db, sessionId: number): Promise<SessionRow> {
const sess = db.select().from(sessions).where(eq(sessions.id, sessionId)).get();
if (!sess) throw ApiError.notFound('Session');
const endedAt = nowSec();
const duration = endedAt - sess.startedAt;
const [row] = db.update(sessions).set({
status: 'completed',
endedAt,
durationSeconds: duration,
queueSnapshot: null,
}).where(eq(sessions.id, sessionId)).returning().all();
return rowToSession(row);
}
export async function abandonSession(db: Db, sessionId: number): Promise<SessionRow> {
const sess = db.select().from(sessions).where(eq(sessions.id, sessionId)).get();
if (!sess) throw ApiError.notFound('Session');
const endedAt = nowSec();
const duration = endedAt - sess.startedAt;
const [row] = db.update(sessions).set({
status: 'abandoned', endedAt, durationSeconds: duration,
}).where(eq(sessions.id, sessionId)).returning().all();
return rowToSession(row);
}
export async function getActiveSession(db: Db): Promise<SessionRow | null> {
const row = db.select().from(sessions).where(eq(sessions.status, 'active')).orderBy(sql`${sessions.startedAt} DESC`).get();
return row ? rowToSession(row) : null;
}
export async function getSessionState(db: Db, sessionId: number): Promise<{ session: SessionRow; queue: QueueItem[]; index: number } | null> {
const row = db.select().from(sessions).where(eq(sessions.id, sessionId)).get();
if (!row) return null;
const state = readQueue(row.queueSnapshot);
return { session: rowToSession(row), queue: state.remaining, index: state.index };
}
- Step 4: Run tests — pass
Run: npm -w @flashcard/backend test
Expected: all session tests pass. Iterate if any fail (most likely cause: queue index off-by-one or empty queue handling).
- Step 5: Commit
git add -A
git commit -m "feat(backend): session engine with Leitner integration"
Task 10: Sessions routes
Files:
-
Create:
packages/backend/src/routes/sessions.ts -
Modify:
packages/backend/src/app.ts -
Step 1: Create
routes/sessions.ts
import { Router } from 'express';
import { attemptCreateSchema, sessionStartSchema } from '@flashcard/shared';
import type { Db } from '../db/client.js';
import {
abandonSession, endSession, getActiveSession, getNextItem, getSessionState,
recordAttempt, startSession,
} from '../services/sessions.js';
import { ApiError } from '../lib/errors.js';
export function sessionsRouter(db: Db): Router {
const r = Router();
r.post('/', async (req, res, next) => {
try {
const input = sessionStartSchema.parse(req.body);
res.status(201).json(await startSession(db, input));
} catch (e) { next(e); }
});
r.get('/active', async (_req, res, next) => {
try { res.json(await getActiveSession(db)); } catch (e) { next(e); }
});
r.get('/:id', async (req, res, next) => {
try {
const state = await getSessionState(db, Number(req.params.id));
if (!state) throw ApiError.notFound('Session');
res.json(state);
} catch (e) { next(e); }
});
r.get('/:id/next', async (req, res, next) => {
try {
const item = await getNextItem(db, Number(req.params.id));
if (!item) { res.json({ done: true }); return; }
res.json({ done: false, item });
} catch (e) { next(e); }
});
r.post('/:id/attempts', async (req, res, next) => {
try {
const input = attemptCreateSchema.parse(req.body);
await recordAttempt(db, Number(req.params.id), input);
res.status(204).end();
} catch (e) { next(e); }
});
r.post('/:id/end', async (req, res, next) => {
try { res.json(await endSession(db, Number(req.params.id))); } catch (e) { next(e); }
});
r.post('/:id/abandon', async (req, res, next) => {
try { res.json(await abandonSession(db, Number(req.params.id))); } catch (e) { next(e); }
});
return r;
}
- Step 2: Mount in
app.ts
Add import { sessionsRouter } from './routes/sessions.js'; and app.use('/api/sessions', sessionsRouter(db));.
- Step 3: Commit
git add -A
git commit -m "feat(backend): sessions routes"
Task 11: Statistics service + routes
Files:
-
Create:
packages/backend/src/services/stats.ts -
Create:
packages/backend/src/services/stats.test.ts -
Create:
packages/backend/src/routes/stats.ts -
Modify:
packages/backend/src/app.ts -
Step 1: Write failing tests
// stats.test.ts
import { describe, it, expect, beforeEach } from 'vitest';
import { makeTestDb } from '../tests/dbHelper.js';
import { createLesson } from './lessons.js';
import { createCard } from './cards.js';
import { startSession, recordAttempt, getNextItem, endSession } from './sessions.js';
import { getCardStats, getLessonStats, getOverview } from './stats.js';
let env: ReturnType<typeof makeTestDb>;
beforeEach(() => { env = makeTestDb(); });
describe('stats', () => {
it('computes per-card attempts and box', async () => {
const l = await createLesson(env.db, { name: 'L' });
const c = await createCard(env.db, l.id, { question: 'q', answer: 'a' });
const s = await startSession(env.db, { lessonId: l.id, shuffle: false });
const item = await getNextItem(env.db, s.session.id);
await recordAttempt(env.db, s.session.id, { cardId: item!.cardId, direction: 'forward', result: 'correct' });
await endSession(env.db, s.session.id);
const stats = await getCardStats(env.db, c.id);
expect(stats.attempts).toBe(1);
expect(stats.correct).toBe(1);
expect(stats.box.forward).toBe(2);
});
it('aggregates lesson stats with descendants', async () => {
const root = await createLesson(env.db, { name: 'R' });
const child = await createLesson(env.db, { name: 'C', parentId: root.id });
await createCard(env.db, child.id, { question: 'q', answer: 'a' });
const s = await startSession(env.db, { lessonId: root.id, shuffle: false });
const it = await getNextItem(env.db, s.session.id);
await recordAttempt(env.db, s.session.id, { cardId: it!.cardId, direction: 'forward', result: 'correct' });
await endSession(env.db, s.session.id);
const ls = await getLessonStats(env.db, root.id);
expect(ls.totalCards).toBe(1);
expect(ls.sessions).toBe(1);
expect(ls.totalDurationSeconds).toBeGreaterThanOrEqual(0);
});
it('overview returns streak ≥ 1 after a session today', async () => {
const l = await createLesson(env.db, { name: 'L' });
await createCard(env.db, l.id, { question: 'q', answer: 'a' });
const s = await startSession(env.db, { lessonId: l.id });
const it = await getNextItem(env.db, s.session.id);
await recordAttempt(env.db, s.session.id, { cardId: it!.cardId, direction: 'forward', result: 'correct' });
await endSession(env.db, s.session.id);
const ov = await getOverview(env.db);
expect(ov.totalSessions).toBe(1);
expect(ov.streakDays).toBeGreaterThanOrEqual(1);
});
});
- Step 2: Run — fail
Run: npm -w @flashcard/backend test
- Step 3: Implement
services/stats.ts
import { and, desc, eq, inArray, sql } from 'drizzle-orm';
import type { Db } from '../db/client.js';
import { attempts, cardProgress, cards, lessons, sessions } from '../db/schema.js';
import { ApiError } from '../lib/errors.js';
import { getDescendantLessonIds } from './lessons.js';
const MIN_ATTEMPTS_FOR_SCORE = 3;
export interface CardStats {
cardId: number;
attempts: number;
correct: number;
incorrect: number;
box: { forward: number; backward: number | null };
lastShownAt: number | null;
nextDueAt: number;
history: { shownAt: number; result: 'correct' | 'incorrect'; direction: 'forward' | 'backward' }[];
}
export async function getCardStats(db: Db, cardId: number): Promise<CardStats> {
const card = db.select().from(cards).where(eq(cards.id, cardId)).get();
if (!card) throw ApiError.notFound('Card');
const prog = db.select().from(cardProgress).where(eq(cardProgress.cardId, cardId)).all();
const history = db.select({
shownAt: attempts.shownAt, result: attempts.result, direction: attempts.direction,
}).from(attempts).where(eq(attempts.cardId, cardId)).orderBy(desc(attempts.shownAt)).all();
const forward = prog.find((p) => p.direction === 'forward');
const backward = prog.find((p) => p.direction === 'backward');
const correct = history.filter((h) => h.result === 'correct').length;
return {
cardId,
attempts: history.length,
correct,
incorrect: history.length - correct,
box: { forward: forward?.box ?? 1, backward: backward?.box ?? null },
lastShownAt: forward?.lastShownAt ?? null,
nextDueAt: forward?.nextDueAt ?? 0,
history,
};
}
export interface LessonStats {
lessonId: number;
totalCards: number;
mastered: number; // box >= 4
score: number; // 0..1, weighted
sessions: number;
totalDurationSeconds: number;
attempts: number;
correct: number;
incorrect: number;
}
export async function getLessonStats(db: Db, lessonId: number): Promise<LessonStats> {
const lesson = db.select().from(lessons).where(eq(lessons.id, lessonId)).get();
if (!lesson) throw ApiError.notFound('Lesson');
const ids = await getDescendantLessonIds(db, lessonId);
const cardRows = db.select({ id: cards.id }).from(cards).where(inArray(cards.lessonId, ids)).all();
const cardIds = cardRows.map((c) => c.id);
let totalCards = cardIds.length;
let mastered = 0;
let attemptsTotal = 0;
let correctTotal = 0;
let score = 0;
let countedForScore = 0;
if (cardIds.length > 0) {
const prog = db.select().from(cardProgress).where(inArray(cardProgress.cardId, cardIds)).all();
const att = db.select().from(attempts).where(inArray(attempts.cardId, cardIds)).all();
attemptsTotal = att.length;
correctTotal = att.filter((a) => a.result === 'correct').length;
const byCard = new Map<number, typeof prog[number][]>();
for (const p of prog) {
if (!byCard.has(p.cardId)) byCard.set(p.cardId, []);
byCard.get(p.cardId)!.push(p);
}
for (const id of cardIds) {
const ps = byCard.get(id) ?? [];
if (ps.some((p) => p.box >= 4)) mastered += 1;
const total = ps.reduce((s, p) => s + p.correctCount + p.incorrectCount, 0);
const correct = ps.reduce((s, p) => s + p.correctCount, 0);
if (total >= MIN_ATTEMPTS_FOR_SCORE) {
score += correct / total;
countedForScore += 1;
}
}
score = countedForScore === 0 ? 0 : score / countedForScore;
}
const sessRows = db.select({
id: sessions.id, duration: sessions.durationSeconds,
}).from(sessions).where(and(inArray(sessions.lessonId, ids), eq(sessions.status, 'completed'))).all();
const totalDurationSeconds = sessRows.reduce((s, r) => s + (r.duration ?? 0), 0);
return {
lessonId, totalCards, mastered, score,
sessions: sessRows.length, totalDurationSeconds,
attempts: attemptsTotal, correct: correctTotal, incorrect: attemptsTotal - correctTotal,
};
}
export interface Overview {
totalSessions: number;
totalDurationSeconds: number;
totalAttempts: number;
streakDays: number;
recentSessions: { id: number; lessonId: number; startedAt: number; durationSeconds: number | null; cardsShown: number; cardsCorrect: number }[];
}
function dayKeyUTC(unixSec: number): string {
const d = new Date(unixSec * 1000);
return `${d.getUTCFullYear()}-${d.getUTCMonth()}-${d.getUTCDate()}`;
}
export async function getOverview(db: Db): Promise<Overview> {
const sessRows = db.select().from(sessions).where(eq(sessions.status, 'completed')).all();
const totalDurationSeconds = sessRows.reduce((s, r) => s + (r.durationSeconds ?? 0), 0);
const totalAttempts = db.select({ c: sql<number>`count(*)`.as('c') }).from(attempts).get()?.c ?? 0;
// streak
const days = new Set(sessRows.map((s) => dayKeyUTC(s.startedAt)));
let streak = 0;
let cursor = new Date();
for (;;) {
const k = dayKeyUTC(Math.floor(cursor.getTime() / 1000));
if (days.has(k)) {
streak += 1;
cursor.setUTCDate(cursor.getUTCDate() - 1);
} else break;
}
const recent = db.select({
id: sessions.id, lessonId: sessions.lessonId, startedAt: sessions.startedAt,
durationSeconds: sessions.durationSeconds, cardsShown: sessions.cardsShown, cardsCorrect: sessions.cardsCorrect,
}).from(sessions).where(eq(sessions.status, 'completed')).orderBy(desc(sessions.startedAt)).limit(10).all();
return {
totalSessions: sessRows.length,
totalDurationSeconds,
totalAttempts: Number(totalAttempts),
streakDays: streak,
recentSessions: recent.map((r) => ({ ...r, durationSeconds: r.durationSeconds ?? null })),
};
}
export interface HeatmapPoint { day: string; sessions: number; attempts: number; }
export async function getHeatmap(db: Db, weeks: number): Promise<HeatmapPoint[]> {
const since = Math.floor(Date.now() / 1000) - weeks * 7 * 24 * 60 * 60;
const sessRows = db.select({ startedAt: sessions.startedAt }).from(sessions)
.where(and(eq(sessions.status, 'completed'), sql`${sessions.startedAt} >= ${since}`)).all();
const attRows = db.select({ shownAt: attempts.shownAt }).from(attempts)
.where(sql`${attempts.shownAt} >= ${since}`).all();
const map = new Map<string, { sessions: number; attempts: number }>();
for (const s of sessRows) {
const k = dayKeyUTC(s.startedAt);
const m = map.get(k) ?? { sessions: 0, attempts: 0 };
m.sessions += 1; map.set(k, m);
}
for (const a of attRows) {
const k = dayKeyUTC(a.shownAt);
const m = map.get(k) ?? { sessions: 0, attempts: 0 };
m.attempts += 1; map.set(k, m);
}
return Array.from(map.entries()).map(([day, v]) => ({ day, ...v }));
}
- Step 4: Run tests — pass
Run: npm -w @flashcard/backend test
- Step 5: Create
routes/stats.ts
import { Router } from 'express';
import type { Db } from '../db/client.js';
import { getCardStats, getHeatmap, getLessonStats, getOverview } from '../services/stats.js';
export function statsRouter(db: Db): Router {
const r = Router();
r.get('/overview', async (_req, res, next) => {
try { res.json(await getOverview(db)); } catch (e) { next(e); }
});
r.get('/lessons/:id', async (req, res, next) => {
try { res.json(await getLessonStats(db, Number(req.params.id))); } catch (e) { next(e); }
});
r.get('/cards/:id', async (req, res, next) => {
try { res.json(await getCardStats(db, Number(req.params.id))); } catch (e) { next(e); }
});
r.get('/heatmap', async (req, res, next) => {
try {
const weeks = Math.min(52, Math.max(1, Number(req.query.weeks ?? 12)));
res.json(await getHeatmap(db, weeks));
} catch (e) { next(e); }
});
return r;
}
- Step 6: Mount in
app.ts
Add import { statsRouter } from './routes/stats.js'; and app.use('/api/stats', statsRouter(db));.
- Step 7: Commit
git add -A
git commit -m "feat(backend): stats service and routes"
Task 12: Excel import & export
Files:
-
Create:
packages/backend/src/services/import.ts -
Create:
packages/backend/src/services/import.test.ts -
Modify:
packages/backend/src/routes/cards.ts -
Step 1: Write failing tests
// import.test.ts
import { describe, it, expect, beforeEach } from 'vitest';
import * as XLSX from 'xlsx';
import { makeTestDb } from '../tests/dbHelper.js';
import { createLesson } from './lessons.js';
import { importCardsFromBuffer, exportCardsToBuffer } from './import.js';
import { listCardsByLesson } from './cards.js';
let env: ReturnType<typeof makeTestDb>;
beforeEach(() => { env = makeTestDb(); });
function buildXlsx(rows: Record<string, string>[]): Buffer {
const ws = XLSX.utils.json_to_sheet(rows);
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, 'cards');
return XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' }) as Buffer;
}
describe('excel import', () => {
it('imports cards into the target lesson when no lesson_path column present', async () => {
const l = await createLesson(env.db, { name: 'L' });
const buf = buildXlsx([
{ question: 'q1', answer: 'a1' },
{ question: 'q2', answer: 'a2', hint: 'tip' },
]);
const result = await importCardsFromBuffer(env.db, l.id, buf, { updateExisting: true, createMissingLessons: false });
expect(result.inserted).toBe(2);
expect(result.errors).toHaveLength(0);
expect(await listCardsByLesson(env.db, l.id)).toHaveLength(2);
});
it('creates intermediate lessons from lesson_path when allowed', async () => {
const root = await createLesson(env.db, { name: 'Spaans' });
const buf = buildXlsx([
{ question: 'hola', answer: 'hello', lesson_path: 'Spaans/Begroetingen' },
]);
const result = await importCardsFromBuffer(env.db, root.id, buf, { updateExisting: true, createMissingLessons: true });
expect(result.inserted).toBe(1);
});
it('updates an existing card on duplicate question in same lesson', async () => {
const l = await createLesson(env.db, { name: 'L' });
await importCardsFromBuffer(env.db, l.id, buildXlsx([{ question: 'q', answer: 'a' }]), { updateExisting: true, createMissingLessons: false });
const res = await importCardsFromBuffer(env.db, l.id, buildXlsx([{ question: 'q', answer: 'b' }]), { updateExisting: true, createMissingLessons: false });
expect(res.updated).toBe(1);
const cards = await listCardsByLesson(env.db, l.id);
expect(cards[0].answer).toBe('b');
});
it('exports a buffer that round-trips back via import', async () => {
const l = await createLesson(env.db, { name: 'L' });
await importCardsFromBuffer(env.db, l.id, buildXlsx([{ question: 'q', answer: 'a' }]), { updateExisting: true, createMissingLessons: false });
const buf = await exportCardsToBuffer(env.db, l.id, false);
const wb = XLSX.read(buf, { type: 'buffer' });
const rows = XLSX.utils.sheet_to_json(wb.Sheets[wb.SheetNames[0]!]!);
expect(rows).toHaveLength(1);
});
});
- Step 2: Run — fail
Run: npm -w @flashcard/backend test
- Step 3: Implement
services/import.ts
import { and, eq, inArray, sql } from 'drizzle-orm';
import * as XLSX from 'xlsx';
import type { Db } from '../db/client.js';
import { cardProgress, cards, lessons } from '../db/schema.js';
import { getDescendantLessonIds } from './lessons.js';
export interface ImportRow {
question: string;
answer: string;
hint?: string;
lesson_path?: string;
}
export interface ImportOptions {
updateExisting: boolean;
createMissingLessons: boolean;
}
export interface ImportResult {
inserted: number;
updated: number;
skipped: number;
errors: { row: number; message: string }[];
}
function parseSheet(buf: Buffer): ImportRow[] {
const wb = XLSX.read(buf, { type: 'buffer' });
const sheet = wb.Sheets[wb.SheetNames[0]!];
if (!sheet) return [];
return XLSX.utils.sheet_to_json<ImportRow>(sheet, { defval: '' });
}
function nowSec() { return Math.floor(Date.now() / 1000); }
async function resolveLesson(db: Db, defaultLessonId: number, lessonPath: string | undefined, createMissing: boolean): Promise<number | null> {
if (!lessonPath || lessonPath.trim() === '') return defaultLessonId;
const parts = lessonPath.split('/').map((s) => s.trim()).filter(Boolean);
if (parts.length === 0) return defaultLessonId;
let parentId: number | null = null;
for (const name of parts) {
const found = db.select().from(lessons)
.where(sql`${lessons.name} = ${name} AND (${lessons.parentId} IS ${parentId === null ? sql`NULL` : sql`${parentId}`})`)
.get();
if (found) { parentId = found.id; continue; }
if (!createMissing) return null;
const [row] = db.insert(lessons).values({ name, parentId, position: 0 }).returning().all();
parentId = row.id;
}
return parentId;
}
export async function importCardsFromBuffer(
db: Db, defaultLessonId: number, buffer: Buffer, opts: ImportOptions
): Promise<ImportResult> {
const rows = parseSheet(buffer);
const result: ImportResult = { inserted: 0, updated: 0, skipped: 0, errors: [] };
for (let i = 0; i < rows.length; i++) {
const row = rows[i]!;
const rowNum = i + 2; // header is row 1
const q = String(row.question ?? '').trim();
const a = String(row.answer ?? '').trim();
if (!q || !a) {
result.errors.push({ row: rowNum, message: 'question and answer are required' });
continue;
}
const targetLessonId = await resolveLesson(db, defaultLessonId, row.lesson_path, opts.createMissingLessons);
if (targetLessonId === null) {
result.errors.push({ row: rowNum, message: `lesson_path not found: ${row.lesson_path}` });
continue;
}
const existing = db.select().from(cards).where(and(eq(cards.lessonId, targetLessonId), eq(cards.question, q))).get();
const hint = row.hint && String(row.hint).trim() !== '' ? String(row.hint) : null;
if (existing) {
if (!opts.updateExisting) { result.skipped += 1; continue; }
db.update(cards).set({ answer: a, hint, updatedAt: nowSec() }).where(eq(cards.id, existing.id)).run();
result.updated += 1;
} else {
const positions = db.select({ pos: cards.position }).from(cards).where(eq(cards.lessonId, targetLessonId)).all();
const position = positions.length === 0 ? 0 : Math.max(...positions.map((p) => p.pos)) + 1;
const [inserted] = db.insert(cards).values({ lessonId: targetLessonId, question: q, answer: a, hint, position }).returning().all();
db.insert(cardProgress).values({ cardId: inserted.id, direction: 'forward', box: 1, nextDueAt: 0 }).run();
const lesson = db.select().from(lessons).where(eq(lessons.id, targetLessonId)).get();
if (lesson?.bidirectional) {
db.insert(cardProgress).values({ cardId: inserted.id, direction: 'backward', box: 1, nextDueAt: 0 }).run();
}
result.inserted += 1;
}
}
return result;
}
export async function exportCardsToBuffer(db: Db, lessonId: number, includeDescendants: boolean): Promise<Buffer> {
const ids = includeDescendants ? await getDescendantLessonIds(db, lessonId) : [lessonId];
const lessonRows = db.select().from(lessons).where(inArray(lessons.id, ids)).all();
const pathById = buildPathMap(lessonRows);
const cardRows = db.select().from(cards).where(inArray(cards.lessonId, ids)).all();
const data = cardRows.map((c) => ({
question: c.question,
answer: c.answer,
hint: c.hint ?? '',
lesson_path: pathById.get(c.lessonId) ?? '',
}));
const ws = XLSX.utils.json_to_sheet(data);
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, 'cards');
return XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' }) as Buffer;
}
function buildPathMap(allLessons: { id: number; name: string; parentId: number | null }[]): Map<number, string> {
const byId = new Map(allLessons.map((l) => [l.id, l]));
const cache = new Map<number, string>();
function path(id: number): string {
if (cache.has(id)) return cache.get(id)!;
const l = byId.get(id);
if (!l) return '';
const p = l.parentId === null ? l.name : `${path(l.parentId)}/${l.name}`;
cache.set(id, p);
return p;
}
for (const l of allLessons) path(l.id);
return cache;
}
- Step 4: Run tests — pass
Run: npm -w @flashcard/backend test
- Step 5: Add routes to
routes/cards.ts
Append inside cardsRouter(db) before return r;:
import multer from 'multer';
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 10 * 1024 * 1024 } });
r.post('/lessons/:lessonId/cards/import', upload.single('file'), async (req, res, next) => {
try {
if (!req.file) throw ApiError.validation('file is required');
const updateExisting = req.body.updateExisting !== 'false';
const createMissingLessons = req.body.createMissingLessons === 'true';
const result = await importCardsFromBuffer(
db, Number(req.params.lessonId), req.file.buffer, { updateExisting, createMissingLessons }
);
res.json(result);
} catch (e) { next(e); }
});
r.get('/lessons/:lessonId/cards/export', async (req, res, next) => {
try {
const includeDescendants = req.query.include_descendants === 'true';
const buf = await exportCardsToBuffer(db, Number(req.params.lessonId), includeDescendants);
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
res.setHeader('Content-Disposition', `attachment; filename="cards-lesson-${req.params.lessonId}.xlsx"`);
res.send(buf);
} catch (e) { next(e); }
});
Add imports at top of routes/cards.ts:
import multer from 'multer';
import { ApiError } from '../lib/errors.js';
import { exportCardsToBuffer, importCardsFromBuffer } from '../services/import.js';
- Step 6: Commit
git add -A
git commit -m "feat(backend): excel import and export"
Task 13: Production static frontend serving
Files:
-
Modify:
packages/backend/src/app.ts -
Step 1: Serve built frontend when present
Add at the bottom of createApp (before the error handler):
import { existsSync } from 'node:fs';
import { resolve } from 'node:path';
import express from 'express';
// ...
const frontendDist = resolve(import.meta.dirname, '../../frontend/dist');
if (existsSync(frontendDist)) {
app.use(express.static(frontendDist));
app.get('*', (_req, res) => res.sendFile(resolve(frontendDist, 'index.html')));
}
- Step 2: Commit
git add -A
git commit -m "feat(backend): serve built frontend in production"
Task 14: Frontend bootstrap
Files:
-
Create:
packages/frontend/package.json -
Create:
packages/frontend/tsconfig.json -
Create:
packages/frontend/vite.config.ts -
Create:
packages/frontend/tailwind.config.ts -
Create:
packages/frontend/postcss.config.js -
Create:
packages/frontend/index.html -
Create:
packages/frontend/src/main.tsx -
Create:
packages/frontend/src/App.tsx -
Create:
packages/frontend/src/router.tsx -
Create:
packages/frontend/src/styles.css -
Step 1: Create
packages/frontend/package.json
{
"name": "@flashcard/frontend",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build",
"preview": "vite preview",
"typecheck": "tsc --noEmit",
"test": "vitest run"
},
"dependencies": {
"@flashcard/shared": "*",
"canvas-confetti": "^1.9.0",
"framer-motion": "^11.0.0",
"react": "^18.3.0",
"react-dom": "^18.3.0",
"react-router-dom": "^6.26.0",
"zustand": "^4.5.0"
},
"devDependencies": {
"@testing-library/jest-dom": "^6.5.0",
"@testing-library/react": "^16.0.0",
"@types/canvas-confetti": "^1.6.0",
"@types/react": "^18.3.0",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.0",
"autoprefixer": "^10.4.0",
"jsdom": "^25.0.0",
"postcss": "^8.4.0",
"tailwindcss": "^3.4.0",
"typescript": "^5.5.0",
"vite": "^7.0.0",
"vitest": "^2.0.0"
}
}
- Step 2: Create
tsconfig.json
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"jsx": "react-jsx",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"moduleResolution": "Bundler"
},
"include": ["src/**/*", "index.html"]
}
- Step 3: Create
vite.config.ts
import react from '@vitejs/plugin-react';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
proxy: { '/api': 'http://localhost:3000' },
},
test: { environment: 'jsdom', setupFiles: ['./src/test-setup.ts'] },
});
- Step 4: Create
tailwind.config.ts
import type { Config } from 'tailwindcss';
export default {
content: ['./index.html', './src/**/*.{ts,tsx}'],
darkMode: 'class',
theme: {
extend: {
animation: {
'flip': 'flip 0.4s ease-out forwards',
},
keyframes: {
flip: {
'0%': { transform: 'rotateY(0)' },
'100%': { transform: 'rotateY(180deg)' },
},
},
},
},
plugins: [],
} satisfies Config;
- Step 5: Create
postcss.config.js
export default { plugins: { tailwindcss: {}, autoprefixer: {} } };
- Step 6: Create
index.html
<!doctype html>
<html lang="nl" class="h-full">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Flashcards</title>
</head>
<body class="h-full bg-slate-50 text-slate-900 dark:bg-slate-950 dark:text-slate-100">
<div id="root" class="h-full"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
- Step 7: Create
src/styles.css
@tailwind base;
@tailwind components;
@tailwind utilities;
html, body, #root { height: 100%; }
.card-perspective { perspective: 1000px; }
.card-face { backface-visibility: hidden; }
- Step 8: Create
src/main.tsx
import React from 'react';
import { createRoot } from 'react-dom/client';
import { RouterProvider } from 'react-router-dom';
import { router } from './router.js';
import './styles.css';
const root = createRoot(document.getElementById('root')!);
root.render(
<React.StrictMode>
<RouterProvider router={router} />
</React.StrictMode>
);
- Step 9: Create
src/router.tsx(skeleton; pages added in later tasks)
import { createBrowserRouter, Navigate } from 'react-router-dom';
import { Layout } from './components/Layout.js';
export const router = createBrowserRouter([
{
path: '/',
element: <Layout />,
children: [
{ index: true, element: <div className="p-6">Dashboard placeholder</div> },
{ path: 'admin', element: <div className="p-6">Admin placeholder</div> },
{ path: '*', element: <Navigate to="/" replace /> },
],
},
]);
- Step 10: Create
src/components/Layout.tsx
import { Link, Outlet } from 'react-router-dom';
export function Layout() {
return (
<div className="flex h-full flex-col">
<header className="border-b border-slate-200 bg-white px-6 py-3 dark:border-slate-800 dark:bg-slate-900">
<nav className="flex gap-4 text-sm">
<Link to="/" className="font-semibold">Flashcards</Link>
<Link to="/admin">Admin</Link>
<Link to="/stats">Stats</Link>
</nav>
</header>
<main className="flex-1 overflow-auto"><Outlet /></main>
</div>
);
}
- Step 11: Create
src/test-setup.ts
import '@testing-library/jest-dom/vitest';
- Step 12: Install
Run: npm install
- Step 13: Smoke test
Run: npm run dev from repo root. Open http://localhost:5173. You should see the header with Dashboard placeholder.
- Step 14: Commit
git add -A
git commit -m "feat(frontend): bootstrap React + Vite + Tailwind + Router + Layout"
Task 15: Frontend API client
Files:
-
Create:
packages/frontend/src/api/client.ts -
Create:
packages/frontend/src/api/lessons.ts -
Create:
packages/frontend/src/api/cards.ts -
Create:
packages/frontend/src/api/sessions.ts -
Create:
packages/frontend/src/api/stats.ts -
Step 1: Create
api/client.ts
export class ApiClientError extends Error {
constructor(public status: number, public code: string, message: string, public details?: unknown) {
super(message);
}
}
async function request<T>(method: string, path: string, body?: unknown, opts?: { isFormData?: boolean }): Promise<T> {
const headers: Record<string, string> = {};
let payload: BodyInit | undefined;
if (opts?.isFormData) {
payload = body as FormData;
} else if (body !== undefined) {
headers['Content-Type'] = 'application/json';
payload = JSON.stringify(body);
}
const res = await fetch(`/api${path}`, { method, headers, body: payload });
if (res.status === 204) return undefined as T;
const isJson = res.headers.get('content-type')?.includes('application/json');
const data = isJson ? await res.json() : await res.blob();
if (!res.ok) {
const e = (data as { error?: { code: string; message: string; details?: unknown } }).error;
throw new ApiClientError(res.status, e?.code ?? 'UNKNOWN', e?.message ?? 'Request failed', e?.details);
}
return data as T;
}
export const api = {
get: <T>(path: string) => request<T>('GET', path),
post: <T>(path: string, body?: unknown) => request<T>('POST', path, body),
postForm: <T>(path: string, form: FormData) => request<T>('POST', path, form, { isFormData: true }),
patch: <T>(path: string, body: unknown) => request<T>('PATCH', path, body),
delete: <T>(path: string) => request<T>('DELETE', path),
getBlob: async (path: string): Promise<Blob> => {
const res = await fetch(`/api${path}`);
if (!res.ok) throw new ApiClientError(res.status, 'UNKNOWN', 'Request failed');
return res.blob();
},
};
- Step 2: Create
api/lessons.ts
import type { Lesson, LessonCreateInput, LessonMoveInput, LessonTreeNode, LessonUpdateInput } from '@flashcard/shared';
import { api } from './client.js';
export const lessonsApi = {
tree: () => api.get<LessonTreeNode[]>('/lessons/tree'),
create: (input: LessonCreateInput) => api.post<Lesson>('/lessons', input),
update: (id: number, input: LessonUpdateInput) => api.patch<Lesson>(`/lessons/${id}`, input),
remove: (id: number) => api.delete<void>(`/lessons/${id}`),
move: (id: number, input: LessonMoveInput) => api.post<Lesson>(`/lessons/${id}/move`, input),
};
- Step 3: Create
api/cards.ts
import type { Card, CardCreateInput, CardUpdateInput } from '@flashcard/shared';
import { api } from './client.js';
export interface ImportResult {
inserted: number; updated: number; skipped: number; errors: { row: number; message: string }[];
}
export const cardsApi = {
list: (lessonId: number) => api.get<Card[]>(`/lessons/${lessonId}/cards`),
create: (lessonId: number, input: CardCreateInput) => api.post<Card>(`/lessons/${lessonId}/cards`, input),
update: (id: number, input: CardUpdateInput) => api.patch<Card>(`/cards/${id}`, input),
remove: (id: number) => api.delete<void>(`/cards/${id}`),
importXlsx: (lessonId: number, file: File, opts: { updateExisting: boolean; createMissingLessons: boolean }) => {
const fd = new FormData();
fd.append('file', file);
fd.append('updateExisting', String(opts.updateExisting));
fd.append('createMissingLessons', String(opts.createMissingLessons));
return api.postForm<ImportResult>(`/lessons/${lessonId}/cards/import`, fd);
},
exportUrl: (lessonId: number, includeDescendants: boolean) =>
`/api/lessons/${lessonId}/cards/export?include_descendants=${includeDescendants}`,
};
- Step 4: Create
api/sessions.ts
import type { QueueItem, SessionRow, SessionStartInput, AttemptCreateInput } from '@flashcard/shared';
import { api } from './client.js';
export interface StartedSession { session: SessionRow; queue: QueueItem[]; }
export interface SessionState { session: SessionRow; queue: QueueItem[]; index: number; }
export const sessionsApi = {
start: (input: SessionStartInput) => api.post<StartedSession>('/sessions', input),
active: () => api.get<SessionRow | null>('/sessions/active'),
state: (id: number) => api.get<SessionState>(`/sessions/${id}`),
next: (id: number) => api.get<{ done: true } | { done: false; item: QueueItem }>(`/sessions/${id}/next`),
attempt: (id: number, input: AttemptCreateInput) => api.post<void>(`/sessions/${id}/attempts`, input),
end: (id: number) => api.post<SessionRow>(`/sessions/${id}/end`),
abandon: (id: number) => api.post<SessionRow>(`/sessions/${id}/abandon`),
};
- Step 5: Create
api/stats.ts
import { api } from './client.js';
export interface Overview {
totalSessions: number; totalDurationSeconds: number; totalAttempts: number; streakDays: number;
recentSessions: { id: number; lessonId: number; startedAt: number; durationSeconds: number | null; cardsShown: number; cardsCorrect: number }[];
}
export interface LessonStats {
lessonId: number; totalCards: number; mastered: number; score: number;
sessions: number; totalDurationSeconds: number; attempts: number; correct: number; incorrect: number;
}
export interface CardStats {
cardId: number; attempts: number; correct: number; incorrect: number;
box: { forward: number; backward: number | null };
lastShownAt: number | null; nextDueAt: number;
history: { shownAt: number; result: 'correct' | 'incorrect'; direction: 'forward' | 'backward' }[];
}
export const statsApi = {
overview: () => api.get<Overview>('/stats/overview'),
lesson: (id: number) => api.get<LessonStats>(`/stats/lessons/${id}`),
card: (id: number) => api.get<CardStats>(`/stats/cards/${id}`),
heatmap: (weeks = 12) => api.get<{ day: string; sessions: number; attempts: number }[]>(`/stats/heatmap?weeks=${weeks}`),
};
- Step 6: Commit
git add -A
git commit -m "feat(frontend): API client modules"
Task 16: Frontend stores (Zustand)
Files:
-
Create:
packages/frontend/src/stores/lessonsStore.ts -
Create:
packages/frontend/src/stores/sessionStore.ts -
Create:
packages/frontend/src/stores/settingsStore.ts -
Step 1: Create
stores/settingsStore.ts
import { create } from 'zustand';
interface SettingsState {
theme: 'light' | 'dark';
defaultMaxCards: number;
toggleTheme: () => void;
setDefaultMaxCards: (n: number) => void;
hydrate: () => void;
}
const KEY = 'flashcard:settings';
export const useSettings = create<SettingsState>((set, get) => ({
theme: 'light',
defaultMaxCards: 20,
toggleTheme: () => {
const theme = get().theme === 'light' ? 'dark' : 'light';
set({ theme });
document.documentElement.classList.toggle('dark', theme === 'dark');
localStorage.setItem(KEY, JSON.stringify({ theme, defaultMaxCards: get().defaultMaxCards }));
},
setDefaultMaxCards: (n) => {
set({ defaultMaxCards: n });
localStorage.setItem(KEY, JSON.stringify({ theme: get().theme, defaultMaxCards: n }));
},
hydrate: () => {
try {
const raw = localStorage.getItem(KEY);
if (!raw) return;
const parsed = JSON.parse(raw) as { theme: 'light' | 'dark'; defaultMaxCards: number };
set(parsed);
document.documentElement.classList.toggle('dark', parsed.theme === 'dark');
} catch { /* ignore */ }
},
}));
- Step 2: Call hydrate in
main.tsx
Modify main.tsx:
import { useSettings } from './stores/settingsStore.js';
useSettings.getState().hydrate();
(Add immediately before createRoot.)
- Step 3: Create
stores/lessonsStore.ts
import { create } from 'zustand';
import type { LessonTreeNode } from '@flashcard/shared';
import { lessonsApi } from '../api/lessons.js';
interface LessonsState {
tree: LessonTreeNode[];
loading: boolean;
refresh: () => Promise<void>;
}
export const useLessons = create<LessonsState>((set) => ({
tree: [],
loading: false,
refresh: async () => {
set({ loading: true });
try { set({ tree: await lessonsApi.tree() }); }
finally { set({ loading: false }); }
},
}));
- Step 4: Create
stores/sessionStore.ts
import { create } from 'zustand';
import type { SessionRow, QueueItem } from '@flashcard/shared';
import { sessionsApi } from '../api/sessions.js';
interface SessionState {
session: SessionRow | null;
current: QueueItem | null;
done: boolean;
showAnswer: boolean;
shownAt: number | null;
start: (input: { lessonId: number; maxCards: number | null; shuffle: boolean; direction: 'forward' | 'backward' | 'both' }) => Promise<void>;
reveal: () => void;
answer: (result: 'correct' | 'incorrect') => Promise<void>;
end: () => Promise<void>;
abandon: () => Promise<void>;
reset: () => void;
}
export const useSession = create<SessionState>((set, get) => ({
session: null, current: null, done: false, showAnswer: false, shownAt: null,
start: async (input) => {
const { session } = await sessionsApi.start(input);
const nx = await sessionsApi.next(session.id);
set({
session,
done: nx.done,
current: nx.done ? null : nx.item,
showAnswer: false,
shownAt: Date.now(),
});
},
reveal: () => set({ showAnswer: true }),
answer: async (result) => {
const s = get();
if (!s.session || !s.current) return;
const ttm = s.shownAt ? Date.now() - s.shownAt : null;
await sessionsApi.attempt(s.session.id, {
cardId: s.current.cardId, direction: s.current.direction, result, timeToAnswerMs: ttm,
});
const nx = await sessionsApi.next(s.session.id);
if (nx.done) {
set({ done: true, current: null, showAnswer: false });
} else {
set({ current: nx.item, showAnswer: false, shownAt: Date.now() });
}
},
end: async () => {
const s = get();
if (!s.session) return;
const finished = await sessionsApi.end(s.session.id);
set({ session: finished });
},
abandon: async () => {
const s = get();
if (!s.session) return;
await sessionsApi.abandon(s.session.id);
set({ session: null, current: null, done: false, showAnswer: false });
},
reset: () => set({ session: null, current: null, done: false, showAnswer: false, shownAt: null }),
}));
- Step 5: Commit
git add -A
git commit -m "feat(frontend): zustand stores for lessons, session, settings"
Task 17: Admin — Lessons tree
Files:
-
Create:
packages/frontend/src/pages/Admin.tsx -
Create:
packages/frontend/src/components/LessonTree.tsx -
Modify:
packages/frontend/src/router.tsx -
Step 1: Create
components/LessonTree.tsx
import { useState } from 'react';
import { Link } from 'react-router-dom';
import type { LessonTreeNode } from '@flashcard/shared';
import { lessonsApi } from '../api/lessons.js';
import { useLessons } from '../stores/lessonsStore.js';
export function LessonTree({ nodes, depth = 0 }: { nodes: LessonTreeNode[]; depth?: number }) {
const refresh = useLessons((s) => s.refresh);
const [addingTo, setAddingTo] = useState<number | null>(null);
const [name, setName] = useState('');
async function addChild(parentId: number | null) {
if (!name.trim()) return;
await lessonsApi.create({ name: name.trim(), parentId });
setName(''); setAddingTo(null);
await refresh();
}
async function rename(id: number, current: string) {
const next = prompt('Nieuwe naam', current);
if (next && next.trim() && next !== current) {
await lessonsApi.update(id, { name: next.trim() });
await refresh();
}
}
async function remove(id: number) {
if (!confirm('Verwijder les en alle onderliggende lessen en kaarten?')) return;
await lessonsApi.remove(id);
await refresh();
}
return (
<ul className="space-y-1">
{nodes.map((n) => (
<li key={n.id} style={{ paddingLeft: depth * 16 }}>
<div className="group flex items-center gap-2 rounded px-2 py-1 hover:bg-slate-100 dark:hover:bg-slate-800">
<Link to={`/admin/lessons/${n.id}`} className="flex-1">
{n.name} <span className="text-xs text-slate-500">({n.cardCount})</span>
</Link>
<button className="text-xs opacity-0 group-hover:opacity-100" onClick={() => setAddingTo(n.id)}>+ subles</button>
<button className="text-xs opacity-0 group-hover:opacity-100" onClick={() => rename(n.id, n.name)}>rename</button>
<button className="text-xs text-red-600 opacity-0 group-hover:opacity-100" onClick={() => remove(n.id)}>delete</button>
</div>
{addingTo === n.id && (
<div className="ml-6 flex gap-1 py-1">
<input className="rounded border px-2 py-1 text-sm dark:bg-slate-900" value={name} onChange={(e) => setName(e.target.value)} placeholder="Naam" />
<button className="rounded bg-blue-600 px-2 py-1 text-sm text-white" onClick={() => addChild(n.id)}>Toevoegen</button>
<button className="text-sm" onClick={() => setAddingTo(null)}>Annuleren</button>
</div>
)}
{n.children.length > 0 && <LessonTree nodes={n.children} depth={depth + 1} />}
</li>
))}
</ul>
);
}
- Step 2: Create
pages/Admin.tsx
import { useEffect, useState } from 'react';
import { lessonsApi } from '../api/lessons.js';
import { useLessons } from '../stores/lessonsStore.js';
import { LessonTree } from '../components/LessonTree.js';
export function AdminPage() {
const { tree, refresh, loading } = useLessons();
const [newRoot, setNewRoot] = useState('');
useEffect(() => { refresh(); }, [refresh]);
async function addRoot() {
if (!newRoot.trim()) return;
await lessonsApi.create({ name: newRoot.trim(), parentId: null });
setNewRoot('');
await refresh();
}
return (
<div className="mx-auto max-w-3xl p-6">
<h1 className="mb-4 text-2xl font-semibold">Lessen beheer</h1>
<div className="mb-4 flex gap-2">
<input className="flex-1 rounded border px-3 py-2 dark:bg-slate-900" placeholder="Nieuwe wortel-les..." value={newRoot} onChange={(e) => setNewRoot(e.target.value)} />
<button className="rounded bg-blue-600 px-4 py-2 text-white" onClick={addRoot}>Toevoegen</button>
</div>
{loading ? <p>Laden...</p> : <LessonTree nodes={tree} />}
</div>
);
}
- Step 3: Mount in router
Replace /admin route in router.tsx:
{ path: 'admin', element: <AdminPage /> },
And import: import { AdminPage } from './pages/Admin.js';
- Step 4: Manual smoke
Run dev server. Navigate to /admin. Add a root lesson, then a sub-lesson, rename one, delete one. All operations should refresh the tree.
- Step 5: Commit
git add -A
git commit -m "feat(frontend): admin lesson tree CRUD"
Task 18: Admin — Card management + Import/Export
Files:
-
Create:
packages/frontend/src/pages/AdminLesson.tsx -
Create:
packages/frontend/src/components/CardTable.tsx -
Create:
packages/frontend/src/components/ImportDialog.tsx -
Modify:
packages/frontend/src/router.tsx -
Step 1: Create
components/CardTable.tsx
import { useState } from 'react';
import type { Card, CardCreateInput } from '@flashcard/shared';
import { cardsApi } from '../api/cards.js';
export function CardTable({ lessonId, cards, onChange }: { lessonId: number; cards: Card[]; onChange: () => void }) {
const [draft, setDraft] = useState<CardCreateInput>({ question: '', answer: '', hint: '' });
async function add() {
if (!draft.question.trim() || !draft.answer.trim()) return;
await cardsApi.create(lessonId, { question: draft.question.trim(), answer: draft.answer.trim(), hint: draft.hint?.trim() || null });
setDraft({ question: '', answer: '', hint: '' });
onChange();
}
async function update(c: Card, field: 'question' | 'answer' | 'hint', value: string) {
await cardsApi.update(c.id, { [field]: value || null });
onChange();
}
async function remove(id: number) {
if (!confirm('Verwijder kaart?')) return;
await cardsApi.remove(id);
onChange();
}
return (
<table className="w-full text-sm">
<thead><tr className="text-left text-slate-500">
<th className="py-2">Vraag</th><th>Antwoord</th><th>Hint</th><th></th>
</tr></thead>
<tbody>
{cards.map((c) => (
<tr key={c.id} className="border-t border-slate-200 dark:border-slate-800">
<td><input className="w-full bg-transparent py-1" defaultValue={c.question} onBlur={(e) => update(c, 'question', e.target.value)} /></td>
<td><input className="w-full bg-transparent py-1" defaultValue={c.answer} onBlur={(e) => update(c, 'answer', e.target.value)} /></td>
<td><input className="w-full bg-transparent py-1" defaultValue={c.hint ?? ''} onBlur={(e) => update(c, 'hint', e.target.value)} /></td>
<td><button className="text-xs text-red-600" onClick={() => remove(c.id)}>x</button></td>
</tr>
))}
<tr className="border-t border-slate-200 dark:border-slate-800">
<td><input className="w-full bg-transparent py-1" placeholder="Nieuwe vraag" value={draft.question} onChange={(e) => setDraft({ ...draft, question: e.target.value })} /></td>
<td><input className="w-full bg-transparent py-1" placeholder="Antwoord" value={draft.answer} onChange={(e) => setDraft({ ...draft, answer: e.target.value })} /></td>
<td><input className="w-full bg-transparent py-1" placeholder="Hint (optioneel)" value={draft.hint ?? ''} onChange={(e) => setDraft({ ...draft, hint: e.target.value })} /></td>
<td><button className="rounded bg-blue-600 px-2 py-1 text-xs text-white" onClick={add}>+</button></td>
</tr>
</tbody>
</table>
);
}
- Step 2: Create
components/ImportDialog.tsx
import { useState } from 'react';
import { cardsApi, type ImportResult } from '../api/cards.js';
export function ImportDialog({ lessonId, onClose, onDone }: { lessonId: number; onClose: () => void; onDone: () => void }) {
const [file, setFile] = useState<File | null>(null);
const [updateExisting, setUpdateExisting] = useState(true);
const [createMissing, setCreateMissing] = useState(false);
const [result, setResult] = useState<ImportResult | null>(null);
const [busy, setBusy] = useState(false);
async function run() {
if (!file) return;
setBusy(true);
try {
const r = await cardsApi.importXlsx(lessonId, file, { updateExisting, createMissingLessons: createMissing });
setResult(r);
onDone();
} finally { setBusy(false); }
}
return (
<div className="fixed inset-0 flex items-center justify-center bg-black/40">
<div className="w-full max-w-md rounded bg-white p-6 dark:bg-slate-900">
<h2 className="mb-3 text-lg font-semibold">Excel import</h2>
<p className="mb-3 text-xs text-slate-500">Kolommen: <code>question</code>, <code>answer</code>, <code>hint</code> (optioneel), <code>lesson_path</code> (optioneel, bv. "Spaans/Begroetingen").</p>
<input type="file" accept=".xlsx" onChange={(e) => setFile(e.target.files?.[0] ?? null)} />
<label className="mt-3 flex items-center gap-2 text-sm"><input type="checkbox" checked={updateExisting} onChange={(e) => setUpdateExisting(e.target.checked)} /> Bestaande kaarten bijwerken</label>
<label className="mt-1 flex items-center gap-2 text-sm"><input type="checkbox" checked={createMissing} onChange={(e) => setCreateMissing(e.target.checked)} /> Onbekende lessen aanmaken</label>
{result && (
<div className="mt-3 rounded bg-slate-100 p-2 text-xs dark:bg-slate-800">
<div>Toegevoegd: {result.inserted}</div>
<div>Bijgewerkt: {result.updated}</div>
<div>Overgeslagen: {result.skipped}</div>
{result.errors.length > 0 && (<div className="text-red-600">Fouten: {result.errors.length}<ul>{result.errors.map((e, i) => <li key={i}>rij {e.row}: {e.message}</li>)}</ul></div>)}
</div>
)}
<div className="mt-4 flex justify-end gap-2">
<button className="px-3 py-1" onClick={onClose}>Sluiten</button>
<button className="rounded bg-blue-600 px-3 py-1 text-white disabled:opacity-50" onClick={run} disabled={!file || busy}>Importeer</button>
</div>
</div>
</div>
);
}
- Step 3: Create
pages/AdminLesson.tsx
import { useEffect, useState } from 'react';
import { Link, useParams } from 'react-router-dom';
import type { Card } from '@flashcard/shared';
import { cardsApi } from '../api/cards.js';
import { CardTable } from '../components/CardTable.js';
import { ImportDialog } from '../components/ImportDialog.js';
export function AdminLessonPage() {
const { id } = useParams();
const lessonId = Number(id);
const [cards, setCards] = useState<Card[]>([]);
const [showImport, setShowImport] = useState(false);
async function refresh() { setCards(await cardsApi.list(lessonId)); }
useEffect(() => { refresh(); }, [lessonId]);
return (
<div className="mx-auto max-w-4xl p-6">
<Link to="/admin" className="text-sm text-blue-600">← Lessen</Link>
<h1 className="my-3 text-2xl font-semibold">Kaarten</h1>
<div className="mb-4 flex gap-2">
<button className="rounded bg-slate-200 px-3 py-1 dark:bg-slate-800" onClick={() => setShowImport(true)}>Importeer Excel</button>
<a className="rounded bg-slate-200 px-3 py-1 dark:bg-slate-800" href={cardsApi.exportUrl(lessonId, false)}>Exporteer Excel</a>
<a className="rounded bg-slate-200 px-3 py-1 dark:bg-slate-800" href={cardsApi.exportUrl(lessonId, true)}>Exporteer + sublessen</a>
<Link to={`/practice/${lessonId}/setup`} className="ml-auto rounded bg-green-600 px-3 py-1 text-white">Start oefenen →</Link>
</div>
<CardTable lessonId={lessonId} cards={cards} onChange={refresh} />
{showImport && <ImportDialog lessonId={lessonId} onClose={() => setShowImport(false)} onDone={refresh} />}
</div>
);
}
- Step 4: Mount in router
Add to children:
{ path: 'admin/lessons/:id', element: <AdminLessonPage /> },
And import.
- Step 5: Smoke test
Add a few cards. Try import with a small xlsx. Click export — file downloads.
- Step 6: Commit
git add -A
git commit -m "feat(frontend): admin card management with excel import/export"
Task 19: Practice flow — setup, session, done
Files:
-
Create:
packages/frontend/src/pages/PracticeSetup.tsx -
Create:
packages/frontend/src/pages/Practice.tsx -
Create:
packages/frontend/src/pages/PracticeDone.tsx -
Create:
packages/frontend/src/components/Flashcard.tsx -
Create:
packages/frontend/src/components/Confetti.tsx -
Modify:
packages/frontend/src/router.tsx -
Step 1: Create
components/Flashcard.tsx
import { AnimatePresence, motion } from 'framer-motion';
export function Flashcard({
question, answer, hint, showAnswer, onReveal, onAnswer,
}: {
question: string; answer: string; hint: string | null;
showAnswer: boolean;
onReveal: () => void;
onAnswer: (r: 'correct' | 'incorrect') => void;
}) {
return (
<div className="card-perspective mx-auto w-full max-w-2xl">
<AnimatePresence mode="wait">
<motion.div
key={showAnswer ? 'a' : 'q'}
initial={{ opacity: 0, x: 40 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -40 }}
transition={{ duration: 0.2 }}
className="card-face rounded-2xl bg-white p-12 shadow-xl dark:bg-slate-900"
>
<div className="min-h-[160px] text-center text-3xl font-medium">
{showAnswer ? answer : question}
</div>
{!showAnswer && hint && (
<div className="mt-4 text-center text-sm text-slate-500">💡 {hint}</div>
)}
<div className="mt-8 flex justify-center gap-3">
{!showAnswer ? (
<button className="rounded-lg bg-blue-600 px-6 py-3 font-medium text-white hover:bg-blue-700" onClick={onReveal}>
Toon antwoord
</button>
) : (
<>
<motion.button whileTap={{ scale: 0.95 }} className="rounded-lg bg-red-600 px-6 py-3 font-medium text-white hover:bg-red-700" onClick={() => onAnswer('incorrect')}>
Fout
</motion.button>
<motion.button whileTap={{ scale: 0.95 }} className="rounded-lg bg-green-600 px-6 py-3 font-medium text-white hover:bg-green-700" onClick={() => onAnswer('correct')}>
Goed
</motion.button>
</>
)}
</div>
</motion.div>
</AnimatePresence>
</div>
);
}
- Step 2: Create
components/Confetti.tsx
import confetti from 'canvas-confetti';
import { useEffect } from 'react';
export function Confetti({ trigger }: { trigger: boolean }) {
useEffect(() => {
if (!trigger) return;
const end = Date.now() + 1200;
(function frame() {
confetti({ particleCount: 4, angle: 60, spread: 55, origin: { x: 0 } });
confetti({ particleCount: 4, angle: 120, spread: 55, origin: { x: 1 } });
if (Date.now() < end) requestAnimationFrame(frame);
})();
}, [trigger]);
return null;
}
- Step 3: Create
pages/PracticeSetup.tsx
import { useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useSettings } from '../stores/settingsStore.js';
import { useSession } from '../stores/sessionStore.js';
export function PracticeSetupPage() {
const { lessonId } = useParams();
const id = Number(lessonId);
const defaultMax = useSettings((s) => s.defaultMaxCards);
const start = useSession((s) => s.start);
const navigate = useNavigate();
const [maxCards, setMaxCards] = useState<number | 'all'>(defaultMax);
const [shuffle, setShuffle] = useState(true);
const [direction, setDirection] = useState<'forward' | 'backward' | 'both'>('forward');
const [busy, setBusy] = useState(false);
async function begin() {
setBusy(true);
await start({ lessonId: id, maxCards: maxCards === 'all' ? null : maxCards, shuffle, direction });
navigate(`/practice/${id}`);
}
return (
<div className="mx-auto max-w-md p-6">
<h1 className="mb-4 text-2xl font-semibold">Sessie starten</h1>
<label className="mb-2 block text-sm">Max. aantal kaarten
<select className="ml-2 rounded border px-2 py-1 dark:bg-slate-900" value={String(maxCards)} onChange={(e) => setMaxCards(e.target.value === 'all' ? 'all' : Number(e.target.value))}>
{[10, 20, 30, 50].map((n) => <option key={n} value={n}>{n}</option>)}
<option value="all">Alle</option>
</select>
</label>
<label className="mb-2 flex items-center gap-2 text-sm"><input type="checkbox" checked={shuffle} onChange={(e) => setShuffle(e.target.checked)} /> Shuffle</label>
<label className="mb-4 block text-sm">Richting
<select className="ml-2 rounded border px-2 py-1 dark:bg-slate-900" value={direction} onChange={(e) => setDirection(e.target.value as typeof direction)}>
<option value="forward">Vraag → antwoord</option>
<option value="backward">Antwoord → vraag</option>
<option value="both">Beide</option>
</select>
</label>
<button className="w-full rounded bg-green-600 py-3 font-medium text-white" onClick={begin} disabled={busy}>
Start
</button>
</div>
);
}
- Step 4: Create
pages/Practice.tsx
import { useEffect, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import type { Card } from '@flashcard/shared';
import { cardsApi } from '../api/cards.js';
import { useSession } from '../stores/sessionStore.js';
import { Flashcard } from '../components/Flashcard.js';
export function PracticePage() {
const { lessonId } = useParams();
const { session, current, done, showAnswer, reveal, answer, end } = useSession();
const navigate = useNavigate();
const [card, setCard] = useState<Card | null>(null);
useEffect(() => {
if (!session) { navigate(`/practice/${lessonId}/setup`); return; }
}, [session, lessonId, navigate]);
useEffect(() => {
let cancel = false;
(async () => {
if (!current) { setCard(null); return; }
// simple cache-bust: fetch list for the card's lesson? Simpler: fetch card via stats endpoint not ideal; use existing list endpoint cached by lesson.
const cards = await cardsApi.list(session!.lessonId);
if (cancel) return;
// Card may belong to a descendant — fall back: fetch full set via /stats/cards/:id is not the card content. We need a /api/cards/:id endpoint.
const found = cards.find((c) => c.id === current.cardId);
setCard(found ?? null);
})();
return () => { cancel = true; };
}, [current, session]);
useEffect(() => {
if (done && session) { (async () => { await end(); navigate(`/practice/${lessonId}/done`); })(); }
}, [done, session, end, navigate, lessonId]);
if (!current || !card) return <div className="p-6 text-center">Laden...</div>;
const isReverse = current.direction === 'backward';
return (
<div className="flex h-full flex-col">
<div className="mx-auto w-full max-w-2xl p-6">
<div className="mb-2 text-xs text-slate-500">{session?.cardsShown ?? 0} kaarten behandeld</div>
<Flashcard
question={isReverse ? card.answer : card.question}
answer={isReverse ? card.question : card.answer}
hint={card.hint}
showAnswer={showAnswer}
onReveal={reveal}
onAnswer={(r) => answer(r)}
/>
</div>
</div>
);
}
Note: This page currently fetches all cards in the session's root lesson and assumes the active card is there. For sessions including descendants, that may not work. Therefore add a backend endpoint as part of this task — see next step.
- Step 5: Add
GET /api/cards/:idbackend endpoint
In packages/backend/src/routes/cards.ts, add inside cardsRouter:
r.get('/cards/:id', async (req, res, next) => {
try { res.json(await getCard(db, Number(req.params.id))); } catch (e) { next(e); }
});
Import: import { getCard } from '../services/cards.js';
In packages/frontend/src/api/cards.ts add:
get: (id: number) => api.get<Card>(`/cards/${id}`),
In Practice.tsx replace the card-fetching effect with:
useEffect(() => {
let cancel = false;
(async () => {
if (!current) { setCard(null); return; }
const c = await cardsApi.get(current.cardId);
if (!cancel) setCard(c);
})();
return () => { cancel = true; };
}, [current]);
- Step 6: Create
pages/PracticeDone.tsx
import { Link, useParams } from 'react-router-dom';
import { useSession } from '../stores/sessionStore.js';
import { Confetti } from '../components/Confetti.js';
export function PracticeDonePage() {
const { lessonId } = useParams();
const session = useSession((s) => s.session);
const reset = useSession((s) => s.reset);
if (!session) return <div className="p-6">Geen sessie gegevens. <Link to="/" className="text-blue-600">Terug</Link></div>;
const total = session.cardsShown || 1;
const pct = Math.round((session.cardsCorrect / total) * 100);
return (
<div className="mx-auto max-w-md p-6 text-center">
<Confetti trigger={pct >= 80} />
<h1 className="text-3xl font-semibold">Sessie klaar!</h1>
<p className="mt-4 text-5xl font-bold">{pct}%</p>
<p className="mt-2 text-slate-500">{session.cardsCorrect} goed, {session.cardsIncorrect} fout van {session.cardsShown}</p>
<p className="mt-1 text-sm text-slate-500">Duur: {session.durationSeconds ?? 0}s</p>
<div className="mt-6 flex justify-center gap-3">
<Link to={`/practice/${lessonId}/setup`} className="rounded bg-green-600 px-4 py-2 text-white" onClick={reset}>Opnieuw</Link>
<Link to="/" className="rounded bg-slate-200 px-4 py-2 dark:bg-slate-800" onClick={reset}>Dashboard</Link>
</div>
</div>
);
}
- Step 7: Mount routes
In router.tsx, add:
{ path: 'practice/:lessonId/setup', element: <PracticeSetupPage /> },
{ path: 'practice/:lessonId', element: <PracticePage /> },
{ path: 'practice/:lessonId/done', element: <PracticeDonePage /> },
And import the pages.
- Step 8: Smoke test
Start a practice session with a lesson that has cards. Reveal answer, click Goed/Fout, ensure session progresses and ends with the done page.
- Step 9: Commit
git add -A
git commit -m "feat(frontend): practice setup, session and done flow"
Task 20: Dashboard + Stats pages
Files:
-
Create:
packages/frontend/src/pages/Dashboard.tsx -
Create:
packages/frontend/src/pages/Stats.tsx -
Create:
packages/frontend/src/pages/StatsLesson.tsx -
Create:
packages/frontend/src/pages/StatsCard.tsx -
Create:
packages/frontend/src/lib/format.ts -
Modify:
packages/frontend/src/router.tsx -
Step 1: Create
lib/format.ts
export function formatDuration(seconds: number): string {
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = seconds % 60;
if (h > 0) return `${h}u ${m}m`;
if (m > 0) return `${m}m ${s}s`;
return `${s}s`;
}
export function formatPct(n: number): string { return `${Math.round(n * 100)}%`; }
export function formatDate(unixSec: number): string {
return new Date(unixSec * 1000).toLocaleString();
}
- Step 2: Create
pages/Dashboard.tsx
import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { statsApi, type Overview } from '../api/stats.js';
import { useLessons } from '../stores/lessonsStore.js';
import { formatDuration, formatDate } from '../lib/format.js';
export function DashboardPage() {
const { tree, refresh } = useLessons();
const [ov, setOv] = useState<Overview | null>(null);
useEffect(() => {
refresh();
statsApi.overview().then(setOv);
}, [refresh]);
return (
<div className="mx-auto max-w-4xl p-6">
<h1 className="mb-6 text-3xl font-semibold">Dashboard</h1>
<div className="mb-6 grid grid-cols-3 gap-4">
<Stat label="🔥 Streak" value={`${ov?.streakDays ?? 0} dagen`} />
<Stat label="Sessies" value={String(ov?.totalSessions ?? 0)} />
<Stat label="Totale tijd" value={ov ? formatDuration(ov.totalDurationSeconds) : '—'} />
</div>
<h2 className="mb-2 text-lg font-medium">Lessen</h2>
<ul className="mb-6 space-y-1">
{tree.map((n) => (
<li key={n.id} className="flex items-center justify-between rounded p-2 hover:bg-slate-100 dark:hover:bg-slate-800">
<span>{n.name} <span className="text-xs text-slate-500">({n.cardCount} kaarten)</span></span>
<Link to={`/practice/${n.id}/setup`} className="rounded bg-green-600 px-3 py-1 text-sm text-white">Oefenen</Link>
</li>
))}
</ul>
<h2 className="mb-2 text-lg font-medium">Recente sessies</h2>
<ul className="space-y-1 text-sm">
{ov?.recentSessions.map((s) => (
<li key={s.id} className="rounded p-2 hover:bg-slate-100 dark:hover:bg-slate-800">
{formatDate(s.startedAt)} — {s.cardsCorrect}/{s.cardsShown} goed · {formatDuration(s.durationSeconds ?? 0)}
</li>
))}
</ul>
</div>
);
}
function Stat({ label, value }: { label: string; value: string }) {
return (
<div className="rounded-xl bg-white p-4 shadow dark:bg-slate-900">
<div className="text-xs uppercase tracking-wide text-slate-500">{label}</div>
<div className="mt-1 text-2xl font-semibold">{value}</div>
</div>
);
}
- Step 3: Create
pages/Stats.tsx(overview + heatmap)
import { useEffect, useState } from 'react';
import { statsApi } from '../api/stats.js';
export function StatsPage() {
const [heatmap, setHeatmap] = useState<{ day: string; sessions: number; attempts: number }[]>([]);
useEffect(() => { statsApi.heatmap(12).then(setHeatmap); }, []);
const max = Math.max(1, ...heatmap.map((d) => d.attempts));
return (
<div className="mx-auto max-w-4xl p-6">
<h1 className="mb-4 text-2xl font-semibold">Statistieken</h1>
<div className="flex flex-wrap gap-1">
{heatmap.map((d) => (
<div key={d.day} title={`${d.day}: ${d.attempts} pogingen`} className="h-4 w-4 rounded"
style={{ backgroundColor: `rgba(34,197,94,${0.15 + 0.85 * (d.attempts / max)})` }} />
))}
</div>
</div>
);
}
- Step 4: Create
pages/StatsLesson.tsx
import { useEffect, useState } from 'react';
import { useParams } from 'react-router-dom';
import { statsApi, type LessonStats } from '../api/stats.js';
import { formatDuration, formatPct } from '../lib/format.js';
export function StatsLessonPage() {
const { id } = useParams();
const [s, setS] = useState<LessonStats | null>(null);
useEffect(() => { statsApi.lesson(Number(id)).then(setS); }, [id]);
if (!s) return <div className="p-6">Laden...</div>;
return (
<div className="mx-auto max-w-2xl p-6">
<h1 className="mb-4 text-2xl font-semibold">Les statistiek</h1>
<ul className="space-y-1 text-sm">
<li>Score: <b>{formatPct(s.score)}</b></li>
<li>Beheerst: {s.mastered} / {s.totalCards}</li>
<li>Sessies: {s.sessions}</li>
<li>Totale tijd: {formatDuration(s.totalDurationSeconds)}</li>
<li>Pogingen: {s.correct}/{s.attempts} goed</li>
</ul>
</div>
);
}
- Step 5: Create
pages/StatsCard.tsx
import { useEffect, useState } from 'react';
import { useParams } from 'react-router-dom';
import { statsApi, type CardStats } from '../api/stats.js';
import { formatDate } from '../lib/format.js';
export function StatsCardPage() {
const { id } = useParams();
const [s, setS] = useState<CardStats | null>(null);
useEffect(() => { statsApi.card(Number(id)).then(setS); }, [id]);
if (!s) return <div className="p-6">Laden...</div>;
return (
<div className="mx-auto max-w-2xl p-6">
<h1 className="mb-4 text-2xl font-semibold">Kaart statistiek</h1>
<p>Doos: {s.box.forward}{s.box.backward != null && ` / ${s.box.backward} (achterwaarts)`}</p>
<p>Pogingen: {s.correct}/{s.attempts} goed</p>
<p>Volgende due: {s.nextDueAt ? formatDate(s.nextDueAt) : '—'}</p>
<h2 className="mt-4 font-medium">Geschiedenis</h2>
<ul className="text-sm">
{s.history.map((h, i) => (
<li key={i}>{formatDate(h.shownAt)} — {h.direction} — {h.result === 'correct' ? '✅' : '❌'}</li>
))}
</ul>
</div>
);
}
- Step 6: Replace dashboard route + mount stats
In router.tsx set the index route to <DashboardPage />, and add:
{ path: 'stats', element: <StatsPage /> },
{ path: 'stats/lessons/:id', element: <StatsLessonPage /> },
{ path: 'stats/cards/:id', element: <StatsCardPage /> },
Import all pages.
- Step 7: Commit
git add -A
git commit -m "feat(frontend): dashboard and stats pages"
Task 21: Settings & dark mode toggle
Files:
-
Create:
packages/frontend/src/pages/Settings.tsx -
Modify:
packages/frontend/src/components/Layout.tsx -
Modify:
packages/frontend/src/router.tsx -
Step 1: Create
pages/Settings.tsx
import { useSettings } from '../stores/settingsStore.js';
export function SettingsPage() {
const { theme, defaultMaxCards, toggleTheme, setDefaultMaxCards } = useSettings();
return (
<div className="mx-auto max-w-md p-6">
<h1 className="mb-4 text-2xl font-semibold">Instellingen</h1>
<label className="mb-3 flex items-center gap-2 text-sm">
<input type="checkbox" checked={theme === 'dark'} onChange={toggleTheme} /> Dark mode
</label>
<label className="block text-sm">Standaard max kaarten per sessie
<input type="number" min={1} max={500} className="ml-2 w-20 rounded border px-2 py-1 dark:bg-slate-900" value={defaultMaxCards} onChange={(e) => setDefaultMaxCards(Math.max(1, Number(e.target.value)))} />
</label>
</div>
);
}
- Step 2: Update
Layout.tsxwith settings link & dark toggle
import { Link, Outlet } from 'react-router-dom';
import { useSettings } from '../stores/settingsStore.js';
export function Layout() {
const { theme, toggleTheme } = useSettings();
return (
<div className="flex h-full flex-col">
<header className="border-b border-slate-200 bg-white px-6 py-3 dark:border-slate-800 dark:bg-slate-900">
<nav className="flex items-center gap-4 text-sm">
<Link to="/" className="font-semibold">Flashcards</Link>
<Link to="/admin">Admin</Link>
<Link to="/stats">Stats</Link>
<Link to="/settings" className="ml-auto">Instellingen</Link>
<button onClick={toggleTheme} className="rounded border px-2 py-0.5 text-xs">
{theme === 'dark' ? '☀️' : '🌙'}
</button>
</nav>
</header>
<main className="flex-1 overflow-auto"><Outlet /></main>
</div>
);
}
- Step 3: Mount route
Add { path: 'settings', element: <SettingsPage /> } and import.
- Step 4: Commit
git add -A
git commit -m "feat(frontend): settings page with dark mode and defaults"
Task 22: Resume active session prompt
Files:
-
Modify:
packages/frontend/src/pages/Dashboard.tsx -
Step 1: On mount, check for active session
Add to DashboardPage:
import { sessionsApi } from '../api/sessions.js';
import type { SessionRow } from '@flashcard/shared';
import { useNavigate } from 'react-router-dom';
// ...inside component:
const [active, setActive] = useState<SessionRow | null>(null);
const navigate = useNavigate();
useEffect(() => {
sessionsApi.active().then(setActive).catch(() => {});
}, []);
// ...in JSX, above the lessons list:
{active && (
<div className="mb-4 flex items-center justify-between rounded bg-amber-100 p-3 text-sm dark:bg-amber-900/30">
<span>Je hebt een open sessie ({active.cardsShown} kaarten behandeld).</span>
<div className="flex gap-2">
<button className="rounded bg-amber-600 px-3 py-1 text-white" onClick={() => navigate(`/practice/${active.lessonId}`)}>Hervatten</button>
<button className="rounded bg-slate-200 px-3 py-1 dark:bg-slate-800" onClick={async () => { await sessionsApi.abandon(active.id); setActive(null); }}>Afsluiten</button>
</div>
</div>
)}
- Step 2: Handle resume in
Practice.tsx
Add an effect that, when session is null but the URL has a lesson, tries to load active session:
import { sessionsApi } from '../api/sessions.js';
import { useSession } from '../stores/sessionStore.js';
// inside PracticePage, before the existing "if no session redirect" effect:
useEffect(() => {
if (useSession.getState().session) return;
(async () => {
const active = await sessionsApi.active();
if (!active || String(active.lessonId) !== lessonId) return;
const state = await sessionsApi.state(active.id);
const nx = await sessionsApi.next(active.id);
useSession.setState({
session: active,
current: nx.done ? null : nx.item,
done: nx.done,
showAnswer: false,
shownAt: Date.now(),
});
})();
}, [lessonId]);
- Step 3: Commit
git add -A
git commit -m "feat(frontend): resume active session prompt"
Task 23: Playwright E2E smoke
Files:
-
Create:
packages/frontend/playwright.config.ts -
Create:
e2e/smoke.spec.ts -
Modify: root
package.json(adde2escript) -
Step 1: Install Playwright
Run: npm i -D -w @flashcard/frontend @playwright/test
Run: npx -w @flashcard/frontend playwright install chromium
- Step 2: Create
playwright.config.ts
import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: '../../e2e',
webServer: [
{ command: 'npm run dev', cwd: '../..', port: 3000, reuseExistingServer: true, env: { DB_PATH: '../../data/e2e.db' } },
{ command: 'npm run dev:fe', cwd: '../..', port: 5173, reuseExistingServer: true },
],
use: { baseURL: 'http://localhost:5173' },
});
- Step 3: Create
e2e/smoke.spec.ts
import { test, expect } from '@playwright/test';
test('create lesson, add card, practice once', async ({ page }) => {
await page.goto('/admin');
await page.getByPlaceholder('Nieuwe wortel-les...').fill('E2E les');
await page.getByRole('button', { name: 'Toevoegen' }).click();
await page.getByText('E2E les').click();
await page.getByPlaceholder('Nieuwe vraag').fill('q1');
await page.getByPlaceholder('Antwoord').fill('a1');
await page.getByRole('button', { name: '+' }).click();
await page.getByRole('link', { name: /Start oefenen/ }).click();
await page.getByRole('button', { name: 'Start' }).click();
await page.getByRole('button', { name: 'Toon antwoord' }).click();
await page.getByRole('button', { name: 'Goed' }).click();
await expect(page.getByText(/Sessie klaar!/)).toBeVisible();
});
- Step 4: Add root script
Add to root package.json scripts:
"e2e": "playwright test --config packages/frontend/playwright.config.ts"
- Step 5: Run
Run: npm run e2e
Expected: smoke test passes.
- Step 6: Commit
git add -A
git commit -m "test(e2e): playwright smoke for create→practice flow"
Task 24: README & developer docs
Files:
-
Create:
README.md -
Step 1: Write
README.md
# Flashcard
Single-user lokale flashcard webapp met hiërarchische lessen, spaced repetition (Leitner), Excel import/export en statistiek.
## Snelstart
```bash
npm install
npm run db:migrate
npm run db:seed # optioneel, voegt demo data toe
npm run dev # backend op :3000, frontend op :5173
Open http://localhost:5173.
Build (productie)
npm run build
npm start # serveert frontend + backend op :3000
Tests
npm test # unit (backend + frontend)
npm run e2e # playwright smoke
Excel-formaat
Eén werkblad met header-rij. Kolommen:
question(verplicht)answer(verplicht)hint(optioneel)lesson_path(optioneel, bv.Spaans/Begroetingen)
- [ ] **Step 2: Commit**
```bash
git add -A
git commit -m "docs: readme with quickstart"
Spec coverage check
| Spec section | Implemented in task |
|---|---|
| 3.1 Lessenstructuur (CRUD, move, bidirectional, cascade) | 7 |
| 3.2 Flashcards CRUD + position | 8 |
| 3.3 Oefensessie incl. descendants, settings, flip/animations | 9, 10, 19 |
| 3.4 Leitner + intra-sessie reinsert | 5, 9 |
| 3.5 Statistieken per kaart/les/sessie/globaal | 11, 20 |
| 3.6 Admin + Excel import/export | 17, 18, 12 |
| 3.7 Sessie hervatten | 22 |
| 3.8 Daily streak | 11, 20 |
| Tech stack | 1-4, 14 |
| Foutafhandeling (Zod, ApiError, toasts) | 3 (errors), 15 (client) |
| Test-strategie (TDD core + smoke E2E) | 5, 7, 8, 9, 11, 12, 23 |
| Deployment scripts | 1, 13, 24 |
Execution Handoff
Plan complete and saved to docs/superpowers/plans/2026-05-20-flashcard-app.md. Two execution options:
1. Subagent-Driven (recommended) - I dispatch a fresh subagent per task, review between tasks, fast iteration
2. Inline Execution - Execute tasks in this session using executing-plans, batch execution with checkpoints
Which approach?