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>
3968 lines
130 KiB
Markdown
3968 lines
130 KiB
Markdown
# Flashcard Webapplicatie — Implementation Plan
|
|
|
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
|
|
**Goal:** Build a single-user local flashcard webapp with hierarchical lessons, Leitner-based spaced repetition, Excel import/export, statistics, and animated practice UI.
|
|
|
|
**Architecture:** npm-workspaces monorepo. Express+TS backend with SQLite via Drizzle ORM. React+Vite+TS frontend with Tailwind, Zustand, Framer Motion. Shared Zod schemas in a `shared` package. Backend serves the built frontend in production.
|
|
|
|
**Tech Stack:** Node 20+, TypeScript (ESM), Express 4, Drizzle ORM, better-sqlite3, Zod, SheetJS (xlsx), React 18, Vite 7, Tailwind 3, React Router 6, Zustand, Framer Motion, Vitest, Playwright.
|
|
|
|
**Spec:** `docs/superpowers/specs/2026-05-20-flashcard-app-design.md`
|
|
|
|
---
|
|
|
|
## File Structure (overview)
|
|
|
|
```
|
|
flashcard/
|
|
├── package.json # workspaces + scripts
|
|
├── tsconfig.base.json # shared TS config
|
|
├── .gitignore
|
|
├── packages/
|
|
│ ├── shared/
|
|
│ │ ├── package.json
|
|
│ │ ├── tsconfig.json
|
|
│ │ └── src/
|
|
│ │ ├── index.ts # re-exports
|
|
│ │ ├── types.ts # domain types
|
|
│ │ └── schemas.ts # Zod schemas
|
|
│ ├── backend/
|
|
│ │ ├── package.json
|
|
│ │ ├── tsconfig.json
|
|
│ │ ├── drizzle.config.ts
|
|
│ │ ├── vitest.config.ts
|
|
│ │ ├── src/
|
|
│ │ │ ├── index.ts # server entry
|
|
│ │ │ ├── app.ts # express app factory
|
|
│ │ │ ├── db/
|
|
│ │ │ │ ├── client.ts # drizzle client
|
|
│ │ │ │ ├── schema.ts # tables
|
|
│ │ │ │ └── migrate.ts # migration runner
|
|
│ │ │ ├── routes/
|
|
│ │ │ │ ├── lessons.ts
|
|
│ │ │ │ ├── cards.ts
|
|
│ │ │ │ ├── sessions.ts
|
|
│ │ │ │ └── stats.ts
|
|
│ │ │ ├── services/
|
|
│ │ │ │ ├── leitner.ts # pure algorithm
|
|
│ │ │ │ ├── sessions.ts # session engine
|
|
│ │ │ │ ├── stats.ts # aggregations
|
|
│ │ │ │ └── import.ts # excel parsing
|
|
│ │ │ ├── lib/
|
|
│ │ │ │ ├── errors.ts # error helpers
|
|
│ │ │ │ └── excel.ts # SheetJS wrappers
|
|
│ │ │ └── tests/ # *.test.ts
|
|
│ │ └── drizzle/ # generated migrations
|
|
│ └── frontend/
|
|
│ ├── package.json
|
|
│ ├── tsconfig.json
|
|
│ ├── vite.config.ts
|
|
│ ├── tailwind.config.ts
|
|
│ ├── postcss.config.js
|
|
│ ├── index.html
|
|
│ └── src/
|
|
│ ├── main.tsx
|
|
│ ├── App.tsx
|
|
│ ├── router.tsx
|
|
│ ├── styles.css
|
|
│ ├── api/
|
|
│ │ ├── client.ts
|
|
│ │ ├── lessons.ts
|
|
│ │ ├── cards.ts
|
|
│ │ ├── sessions.ts
|
|
│ │ └── stats.ts
|
|
│ ├── stores/
|
|
│ │ ├── sessionStore.ts
|
|
│ │ ├── lessonsStore.ts
|
|
│ │ └── settingsStore.ts
|
|
│ ├── pages/
|
|
│ │ ├── Dashboard.tsx
|
|
│ │ ├── Admin.tsx
|
|
│ │ ├── AdminLesson.tsx
|
|
│ │ ├── PracticeSetup.tsx
|
|
│ │ ├── Practice.tsx
|
|
│ │ ├── PracticeDone.tsx
|
|
│ │ ├── Stats.tsx
|
|
│ │ ├── StatsLesson.tsx
|
|
│ │ ├── StatsCard.tsx
|
|
│ │ └── Settings.tsx
|
|
│ ├── components/
|
|
│ │ ├── Layout.tsx
|
|
│ │ ├── LessonTree.tsx
|
|
│ │ ├── CardTable.tsx
|
|
│ │ ├── Flashcard.tsx
|
|
│ │ ├── ImportDialog.tsx
|
|
│ │ ├── Confetti.tsx
|
|
│ │ └── ...
|
|
│ └── lib/
|
|
│ ├── format.ts
|
|
│ └── streak.ts
|
|
└── data/
|
|
└── templates/import-template.xlsx # generated/committed sample
|
|
```
|
|
|
|
---
|
|
|
|
## Task 1: Monorepo bootstrap
|
|
|
|
**Files:**
|
|
- Create: `package.json`
|
|
- Create: `tsconfig.base.json`
|
|
- Create: `.gitignore` (already exists, verify)
|
|
- Create: `packages/shared/package.json`
|
|
- Create: `packages/shared/tsconfig.json`
|
|
- Create: `packages/shared/src/index.ts`
|
|
|
|
- [ ] **Step 1: Create root `package.json`**
|
|
|
|
```json
|
|
{
|
|
"name": "flashcard",
|
|
"private": true,
|
|
"version": "0.1.0",
|
|
"type": "module",
|
|
"workspaces": ["packages/*"],
|
|
"scripts": {
|
|
"dev": "concurrently -k -n be,fe -c blue,green \"npm:dev:be\" \"npm:dev:fe\"",
|
|
"dev:be": "npm -w @flashcard/backend run dev",
|
|
"dev:fe": "npm -w @flashcard/frontend run dev",
|
|
"build": "npm -w @flashcard/shared run build && npm -w @flashcard/backend run build && npm -w @flashcard/frontend run build",
|
|
"start": "node packages/backend/dist/index.js",
|
|
"test": "npm -w @flashcard/backend run test && npm -w @flashcard/frontend run test",
|
|
"db:migrate": "npm -w @flashcard/backend run db:migrate",
|
|
"db:seed": "npm -w @flashcard/backend run db:seed",
|
|
"typecheck": "npm -ws run typecheck --if-present"
|
|
},
|
|
"devDependencies": {
|
|
"concurrently": "^9.0.0",
|
|
"typescript": "^5.5.0"
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Create `tsconfig.base.json`**
|
|
|
|
```json
|
|
{
|
|
"compilerOptions": {
|
|
"target": "ES2022",
|
|
"module": "ESNext",
|
|
"moduleResolution": "Bundler",
|
|
"strict": true,
|
|
"esModuleInterop": true,
|
|
"skipLibCheck": true,
|
|
"forceConsistentCasingInFileNames": true,
|
|
"resolveJsonModule": true,
|
|
"isolatedModules": true,
|
|
"noUncheckedIndexedAccess": true,
|
|
"noImplicitOverride": true,
|
|
"verbatimModuleSyntax": false
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Verify `.gitignore` includes node_modules, dist, *.db, .env**
|
|
|
|
Open `.gitignore` and confirm it matches the file already committed. If missing entries, add them.
|
|
|
|
- [ ] **Step 4: Create `packages/shared/package.json`**
|
|
|
|
```json
|
|
{
|
|
"name": "@flashcard/shared",
|
|
"version": "0.1.0",
|
|
"private": true,
|
|
"type": "module",
|
|
"main": "./src/index.ts",
|
|
"types": "./src/index.ts",
|
|
"exports": {
|
|
".": "./src/index.ts"
|
|
},
|
|
"scripts": {
|
|
"typecheck": "tsc --noEmit",
|
|
"build": "tsc --noEmit"
|
|
},
|
|
"dependencies": {
|
|
"zod": "^3.23.0"
|
|
},
|
|
"devDependencies": {
|
|
"typescript": "^5.5.0"
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 5: Create `packages/shared/tsconfig.json`**
|
|
|
|
```json
|
|
{
|
|
"extends": "../../tsconfig.base.json",
|
|
"compilerOptions": {
|
|
"outDir": "dist",
|
|
"rootDir": "src"
|
|
},
|
|
"include": ["src/**/*"]
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 6: Create `packages/shared/src/index.ts` (placeholder)**
|
|
|
|
```ts
|
|
export {};
|
|
```
|
|
|
|
- [ ] **Step 7: Install root deps**
|
|
|
|
Run: `npm install`
|
|
Expected: workspaces resolved, no errors.
|
|
|
|
- [ ] **Step 8: Commit**
|
|
|
|
```bash
|
|
git add -A
|
|
git commit -m "chore: bootstrap monorepo with shared package"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 2: Shared types & Zod schemas
|
|
|
|
**Files:**
|
|
- Create: `packages/shared/src/types.ts`
|
|
- Create: `packages/shared/src/schemas.ts`
|
|
- Modify: `packages/shared/src/index.ts`
|
|
|
|
- [ ] **Step 1: Write `packages/shared/src/types.ts`**
|
|
|
|
```ts
|
|
export type Direction = 'forward' | 'backward';
|
|
export type AttemptResult = 'correct' | 'incorrect';
|
|
export type SessionStatus = 'active' | 'completed' | 'abandoned';
|
|
|
|
export interface Lesson {
|
|
id: number;
|
|
parentId: number | null;
|
|
name: string;
|
|
description: string | null;
|
|
position: number;
|
|
bidirectional: boolean;
|
|
createdAt: number;
|
|
updatedAt: number;
|
|
}
|
|
|
|
export interface LessonTreeNode extends Lesson {
|
|
children: LessonTreeNode[];
|
|
cardCount: number;
|
|
}
|
|
|
|
export interface Card {
|
|
id: number;
|
|
lessonId: number;
|
|
question: string;
|
|
answer: string;
|
|
hint: string | null;
|
|
position: number;
|
|
createdAt: number;
|
|
updatedAt: number;
|
|
}
|
|
|
|
export interface CardProgress {
|
|
cardId: number;
|
|
direction: Direction;
|
|
box: number; // 1..5
|
|
correctCount: number;
|
|
incorrectCount: number;
|
|
lastShownAt: number | null;
|
|
nextDueAt: number;
|
|
}
|
|
|
|
export interface SessionRow {
|
|
id: number;
|
|
lessonId: number;
|
|
startedAt: number;
|
|
endedAt: number | null;
|
|
durationSeconds: number | null;
|
|
cardsShown: number;
|
|
cardsCorrect: number;
|
|
cardsIncorrect: number;
|
|
status: SessionStatus;
|
|
}
|
|
|
|
export interface Attempt {
|
|
id: number;
|
|
sessionId: number;
|
|
cardId: number;
|
|
direction: Direction;
|
|
shownAt: number;
|
|
result: AttemptResult;
|
|
timeToAnswerMs: number | null;
|
|
}
|
|
|
|
export interface QueueItem {
|
|
cardId: number;
|
|
direction: Direction;
|
|
}
|
|
|
|
export interface SessionSettings {
|
|
maxCards: number | null; // null = all available
|
|
shuffle: boolean;
|
|
direction: 'forward' | 'backward' | 'both';
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Write `packages/shared/src/schemas.ts`**
|
|
|
|
```ts
|
|
import { z } from 'zod';
|
|
|
|
export const lessonCreateSchema = z.object({
|
|
parentId: z.number().int().nullable().optional(),
|
|
name: z.string().min(1).max(200),
|
|
description: z.string().max(2000).optional().nullable(),
|
|
bidirectional: z.boolean().optional(),
|
|
});
|
|
|
|
export const lessonUpdateSchema = lessonCreateSchema.partial();
|
|
|
|
export const lessonMoveSchema = z.object({
|
|
parentId: z.number().int().nullable(),
|
|
position: z.number().int().min(0),
|
|
});
|
|
|
|
export const cardCreateSchema = z.object({
|
|
question: z.string().min(1).max(2000),
|
|
answer: z.string().min(1).max(2000),
|
|
hint: z.string().max(2000).optional().nullable(),
|
|
});
|
|
|
|
export const cardUpdateSchema = cardCreateSchema.partial();
|
|
|
|
export const sessionStartSchema = z.object({
|
|
lessonId: z.number().int().positive(),
|
|
maxCards: z.number().int().min(1).max(500).nullable().optional(),
|
|
shuffle: z.boolean().optional(),
|
|
direction: z.enum(['forward', 'backward', 'both']).optional(),
|
|
});
|
|
|
|
export const attemptCreateSchema = z.object({
|
|
cardId: z.number().int().positive(),
|
|
direction: z.enum(['forward', 'backward']),
|
|
result: z.enum(['correct', 'incorrect']),
|
|
timeToAnswerMs: z.number().int().min(0).nullable().optional(),
|
|
});
|
|
|
|
export type LessonCreateInput = z.infer<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`**
|
|
|
|
```ts
|
|
export * from './types.js';
|
|
export * from './schemas.js';
|
|
```
|
|
|
|
- [ ] **Step 4: Typecheck**
|
|
|
|
Run: `npm -w @flashcard/shared run typecheck`
|
|
Expected: no errors.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add -A
|
|
git commit -m "feat(shared): add domain types and zod schemas"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 3: Backend bootstrap
|
|
|
|
**Files:**
|
|
- Create: `packages/backend/package.json`
|
|
- Create: `packages/backend/tsconfig.json`
|
|
- Create: `packages/backend/vitest.config.ts`
|
|
- Create: `packages/backend/src/index.ts`
|
|
- Create: `packages/backend/src/app.ts`
|
|
- Create: `packages/backend/src/lib/errors.ts`
|
|
|
|
- [ ] **Step 1: Create `packages/backend/package.json`**
|
|
|
|
```json
|
|
{
|
|
"name": "@flashcard/backend",
|
|
"version": "0.1.0",
|
|
"private": true,
|
|
"type": "module",
|
|
"main": "dist/index.js",
|
|
"scripts": {
|
|
"dev": "tsx watch src/index.ts",
|
|
"build": "tsc -p tsconfig.json",
|
|
"start": "node dist/index.js",
|
|
"typecheck": "tsc --noEmit",
|
|
"test": "vitest run",
|
|
"test:watch": "vitest",
|
|
"db:generate": "drizzle-kit generate",
|
|
"db:migrate": "tsx src/db/migrate.ts",
|
|
"db:seed": "tsx src/db/seed.ts"
|
|
},
|
|
"dependencies": {
|
|
"@flashcard/shared": "*",
|
|
"better-sqlite3": "^11.0.0",
|
|
"drizzle-orm": "^0.33.0",
|
|
"express": "^4.19.0",
|
|
"multer": "^1.4.5-lts.1",
|
|
"xlsx": "^0.18.5",
|
|
"zod": "^3.23.0"
|
|
},
|
|
"devDependencies": {
|
|
"@types/better-sqlite3": "^7.6.0",
|
|
"@types/express": "^4.17.0",
|
|
"@types/multer": "^1.4.0",
|
|
"@types/node": "^20.0.0",
|
|
"@types/supertest": "^6.0.0",
|
|
"drizzle-kit": "^0.24.0",
|
|
"supertest": "^7.0.0",
|
|
"tsx": "^4.16.0",
|
|
"typescript": "^5.5.0",
|
|
"vitest": "^2.0.0"
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Create `packages/backend/tsconfig.json`**
|
|
|
|
```json
|
|
{
|
|
"extends": "../../tsconfig.base.json",
|
|
"compilerOptions": {
|
|
"outDir": "dist",
|
|
"rootDir": "src",
|
|
"module": "NodeNext",
|
|
"moduleResolution": "NodeNext"
|
|
},
|
|
"include": ["src/**/*"]
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Create `packages/backend/vitest.config.ts`**
|
|
|
|
```ts
|
|
import { defineConfig } from 'vitest/config';
|
|
|
|
export default defineConfig({
|
|
test: {
|
|
environment: 'node',
|
|
include: ['src/**/*.test.ts'],
|
|
pool: 'forks',
|
|
},
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 4: Create `packages/backend/src/lib/errors.ts`**
|
|
|
|
```ts
|
|
export class ApiError extends Error {
|
|
constructor(
|
|
public status: number,
|
|
public code: string,
|
|
message: string,
|
|
public details?: unknown
|
|
) {
|
|
super(message);
|
|
}
|
|
|
|
static notFound(what = 'Resource') {
|
|
return new ApiError(404, 'NOT_FOUND', `${what} not found`);
|
|
}
|
|
static validation(message: string, details?: unknown) {
|
|
return new ApiError(400, 'VALIDATION_ERROR', message, details);
|
|
}
|
|
static conflict(message: string) {
|
|
return new ApiError(409, 'CONFLICT', message);
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 5: Create `packages/backend/src/app.ts`**
|
|
|
|
```ts
|
|
import express, { type Express, type NextFunction, type Request, type Response } from 'express';
|
|
import { ZodError } from 'zod';
|
|
import { ApiError } from './lib/errors.js';
|
|
|
|
export function createApp(): Express {
|
|
const app = express();
|
|
app.use(express.json({ limit: '5mb' }));
|
|
|
|
app.get('/api/health', (_req, res) => {
|
|
res.json({ ok: true });
|
|
});
|
|
|
|
// Routes mounted in later tasks.
|
|
|
|
app.use((err: unknown, _req: Request, res: Response, _next: NextFunction) => {
|
|
if (err instanceof ZodError) {
|
|
res.status(400).json({
|
|
error: { code: 'VALIDATION_ERROR', message: 'Invalid input', details: err.flatten() },
|
|
});
|
|
return;
|
|
}
|
|
if (err instanceof ApiError) {
|
|
res.status(err.status).json({
|
|
error: { code: err.code, message: err.message, details: err.details },
|
|
});
|
|
return;
|
|
}
|
|
console.error(err);
|
|
res.status(500).json({ error: { code: 'INTERNAL', message: 'Internal server error' } });
|
|
});
|
|
|
|
return app;
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 6: Create `packages/backend/src/index.ts`**
|
|
|
|
```ts
|
|
import { createApp } from './app.js';
|
|
|
|
const PORT = Number(process.env.PORT ?? 3000);
|
|
const app = createApp();
|
|
app.listen(PORT, () => {
|
|
console.log(`Backend listening on http://localhost:${PORT}`);
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 7: Install deps**
|
|
|
|
Run: `npm install`
|
|
Expected: deps resolved.
|
|
|
|
- [ ] **Step 8: Smoke test the server**
|
|
|
|
Run: `npm -w @flashcard/backend run dev` (let it start, then in another shell: `curl http://localhost:3000/api/health`)
|
|
Expected: `{"ok":true}`. Stop the server (Ctrl+C).
|
|
|
|
- [ ] **Step 9: Commit**
|
|
|
|
```bash
|
|
git add -A
|
|
git commit -m "feat(backend): bootstrap express app with error handling"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 4: Database schema + migrations
|
|
|
|
**Files:**
|
|
- Create: `packages/backend/drizzle.config.ts`
|
|
- Create: `packages/backend/src/db/schema.ts`
|
|
- Create: `packages/backend/src/db/client.ts`
|
|
- Create: `packages/backend/src/db/migrate.ts`
|
|
- Create: `packages/backend/src/db/seed.ts`
|
|
|
|
- [ ] **Step 1: Create `packages/backend/drizzle.config.ts`**
|
|
|
|
```ts
|
|
import { defineConfig } from 'drizzle-kit';
|
|
|
|
export default defineConfig({
|
|
schema: './src/db/schema.ts',
|
|
out: './drizzle',
|
|
dialect: 'sqlite',
|
|
dbCredentials: {
|
|
url: process.env.DB_PATH ?? '../../data/flashcard.db',
|
|
},
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Create `packages/backend/src/db/schema.ts`**
|
|
|
|
```ts
|
|
import { sql } from 'drizzle-orm';
|
|
import { integer, sqliteTable, text, index } from 'drizzle-orm/sqlite-core';
|
|
|
|
export const lessons = sqliteTable('lessons', {
|
|
id: integer('id').primaryKey({ autoIncrement: true }),
|
|
parentId: integer('parent_id'),
|
|
name: text('name').notNull(),
|
|
description: text('description'),
|
|
position: integer('position').notNull().default(0),
|
|
bidirectional: integer('bidirectional', { mode: 'boolean' }).notNull().default(false),
|
|
createdAt: integer('created_at').notNull().default(sql`(unixepoch())`),
|
|
updatedAt: integer('updated_at').notNull().default(sql`(unixepoch())`),
|
|
});
|
|
|
|
export const cards = sqliteTable(
|
|
'cards',
|
|
{
|
|
id: integer('id').primaryKey({ autoIncrement: true }),
|
|
lessonId: integer('lesson_id').notNull().references(() => lessons.id, { onDelete: 'cascade' }),
|
|
question: text('question').notNull(),
|
|
answer: text('answer').notNull(),
|
|
hint: text('hint'),
|
|
position: integer('position').notNull().default(0),
|
|
createdAt: integer('created_at').notNull().default(sql`(unixepoch())`),
|
|
updatedAt: integer('updated_at').notNull().default(sql`(unixepoch())`),
|
|
},
|
|
(t) => ({ lessonIdx: index('cards_lesson_idx').on(t.lessonId) })
|
|
);
|
|
|
|
export const cardProgress = sqliteTable(
|
|
'card_progress',
|
|
{
|
|
cardId: integer('card_id').notNull().references(() => cards.id, { onDelete: 'cascade' }),
|
|
direction: text('direction', { enum: ['forward', 'backward'] }).notNull(),
|
|
box: integer('box').notNull().default(1),
|
|
correctCount: integer('correct_count').notNull().default(0),
|
|
incorrectCount: integer('incorrect_count').notNull().default(0),
|
|
lastShownAt: integer('last_shown_at'),
|
|
nextDueAt: integer('next_due_at').notNull().default(0),
|
|
},
|
|
(t) => ({
|
|
pk: index('card_progress_pk').on(t.cardId, t.direction),
|
|
dueIdx: index('card_progress_due_idx').on(t.nextDueAt),
|
|
})
|
|
);
|
|
|
|
export const sessions = sqliteTable(
|
|
'sessions',
|
|
{
|
|
id: integer('id').primaryKey({ autoIncrement: true }),
|
|
lessonId: integer('lesson_id').notNull().references(() => lessons.id, { onDelete: 'cascade' }),
|
|
startedAt: integer('started_at').notNull().default(sql`(unixepoch())`),
|
|
endedAt: integer('ended_at'),
|
|
durationSeconds: integer('duration_seconds'),
|
|
cardsShown: integer('cards_shown').notNull().default(0),
|
|
cardsCorrect: integer('cards_correct').notNull().default(0),
|
|
cardsIncorrect: integer('cards_incorrect').notNull().default(0),
|
|
status: text('status', { enum: ['active', 'completed', 'abandoned'] }).notNull().default('active'),
|
|
queueSnapshot: text('queue_snapshot'),
|
|
},
|
|
(t) => ({ statusIdx: index('sessions_status_idx').on(t.status) })
|
|
);
|
|
|
|
export const attempts = sqliteTable(
|
|
'attempts',
|
|
{
|
|
id: integer('id').primaryKey({ autoIncrement: true }),
|
|
sessionId: integer('session_id').notNull().references(() => sessions.id, { onDelete: 'cascade' }),
|
|
cardId: integer('card_id').notNull().references(() => cards.id, { onDelete: 'cascade' }),
|
|
direction: text('direction', { enum: ['forward', 'backward'] }).notNull(),
|
|
shownAt: integer('shown_at').notNull().default(sql`(unixepoch())`),
|
|
result: text('result', { enum: ['correct', 'incorrect'] }).notNull(),
|
|
timeToAnswerMs: integer('time_to_answer_ms'),
|
|
},
|
|
(t) => ({
|
|
sessionIdx: index('attempts_session_idx').on(t.sessionId),
|
|
cardIdx: index('attempts_card_idx').on(t.cardId),
|
|
})
|
|
);
|
|
|
|
export type LessonRow = typeof lessons.$inferSelect;
|
|
export type CardRow = typeof cards.$inferSelect;
|
|
export type CardProgressRow = typeof cardProgress.$inferSelect;
|
|
export type SessionRow = typeof sessions.$inferSelect;
|
|
export type AttemptRow = typeof attempts.$inferSelect;
|
|
```
|
|
|
|
- [ ] **Step 3: Create `packages/backend/src/db/client.ts`**
|
|
|
|
```ts
|
|
import Database from 'better-sqlite3';
|
|
import { drizzle, type BetterSQLite3Database } from 'drizzle-orm/better-sqlite3';
|
|
import { mkdirSync } from 'node:fs';
|
|
import { dirname, resolve } from 'node:path';
|
|
import * as schema from './schema.js';
|
|
|
|
export type Db = BetterSQLite3Database<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`**
|
|
|
|
```ts
|
|
import { migrate } from 'drizzle-orm/better-sqlite3/migrator';
|
|
import { resolve } from 'node:path';
|
|
import { createDb } from './client.js';
|
|
|
|
const { db, sqlite } = createDb();
|
|
migrate(db, { migrationsFolder: resolve(import.meta.dirname, '../../drizzle') });
|
|
sqlite.close();
|
|
console.log('Migrations applied.');
|
|
```
|
|
|
|
- [ ] **Step 6: Run migrations**
|
|
|
|
Run: `npm run db:migrate`
|
|
Expected: `data/flashcard.db` is created and `Migrations applied.` is logged.
|
|
|
|
- [ ] **Step 7: Create `packages/backend/src/db/seed.ts`**
|
|
|
|
```ts
|
|
import { createDb } from './client.js';
|
|
import { cards, lessons } from './schema.js';
|
|
|
|
const { db, sqlite } = createDb();
|
|
const [root] = db.insert(lessons).values({ name: 'Demo: Spaans', position: 0 }).returning().all();
|
|
const [sub] = db.insert(lessons).values({ name: 'Begroetingen', parentId: root.id, position: 0 }).returning().all();
|
|
db.insert(cards).values([
|
|
{ lessonId: sub.id, question: 'Hallo', answer: 'Hola', position: 0 },
|
|
{ lessonId: sub.id, question: 'Goedemorgen', answer: 'Buenos días', position: 1 },
|
|
{ lessonId: sub.id, question: 'Tot ziens', answer: 'Adiós', position: 2 },
|
|
]).run();
|
|
sqlite.close();
|
|
console.log('Seed inserted.');
|
|
```
|
|
|
|
- [ ] **Step 8: Commit**
|
|
|
|
```bash
|
|
git add -A
|
|
git commit -m "feat(db): drizzle schema, migrations, and seed"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 5: Leitner algorithm (pure, TDD)
|
|
|
|
**Files:**
|
|
- Create: `packages/backend/src/services/leitner.ts`
|
|
- Create: `packages/backend/src/services/leitner.test.ts`
|
|
|
|
- [ ] **Step 1: Write the failing tests**
|
|
|
|
```ts
|
|
// leitner.test.ts
|
|
import { describe, it, expect } from 'vitest';
|
|
import { applyResult, BOX_INTERVALS_SEC, MAX_BOX } from './leitner.js';
|
|
|
|
describe('leitner.applyResult', () => {
|
|
const now = 1_000_000;
|
|
|
|
it('moves correct answer to next box and schedules due', () => {
|
|
const next = applyResult({ box: 1, correctCount: 0, incorrectCount: 0 }, 'correct', now);
|
|
expect(next.box).toBe(2);
|
|
expect(next.nextDueAt).toBe(now + BOX_INTERVALS_SEC[2]);
|
|
expect(next.correctCount).toBe(1);
|
|
});
|
|
|
|
it('caps box at MAX_BOX on correct answer', () => {
|
|
const next = applyResult({ box: MAX_BOX, correctCount: 9, incorrectCount: 0 }, 'correct', now);
|
|
expect(next.box).toBe(MAX_BOX);
|
|
expect(next.nextDueAt).toBe(now + BOX_INTERVALS_SEC[MAX_BOX]);
|
|
});
|
|
|
|
it('resets to box 1 on incorrect', () => {
|
|
const next = applyResult({ box: 4, correctCount: 3, incorrectCount: 1 }, 'incorrect', now);
|
|
expect(next.box).toBe(1);
|
|
expect(next.nextDueAt).toBe(now + BOX_INTERVALS_SEC[1]);
|
|
expect(next.incorrectCount).toBe(2);
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Run tests — they fail**
|
|
|
|
Run: `npm -w @flashcard/backend test`
|
|
Expected: failure (module not found).
|
|
|
|
- [ ] **Step 3: Implement `leitner.ts`**
|
|
|
|
```ts
|
|
import type { AttemptResult } from '@flashcard/shared';
|
|
|
|
export const MAX_BOX = 5;
|
|
// index 1..5; index 0 unused
|
|
export const BOX_INTERVALS_SEC: Record<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**
|
|
|
|
```bash
|
|
git add -A
|
|
git commit -m "feat(backend): leitner algorithm with tests"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 6: Test helpers — in-memory db
|
|
|
|
**Files:**
|
|
- Create: `packages/backend/src/tests/dbHelper.ts`
|
|
|
|
- [ ] **Step 1: Create `dbHelper.ts`**
|
|
|
|
```ts
|
|
import { migrate } from 'drizzle-orm/better-sqlite3/migrator';
|
|
import { resolve } from 'node:path';
|
|
import { createDb, type Db } from '../db/client.js';
|
|
|
|
export function makeTestDb(): { db: Db; close: () => void } {
|
|
const { db, sqlite } = createDb(':memory:');
|
|
migrate(db, { migrationsFolder: resolve(import.meta.dirname, '../../drizzle') });
|
|
return { db, close: () => sqlite.close() };
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Commit**
|
|
|
|
```bash
|
|
git add -A
|
|
git commit -m "test(backend): in-memory db helper"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 7: Lessons CRUD service + routes
|
|
|
|
**Files:**
|
|
- Create: `packages/backend/src/services/lessons.ts`
|
|
- Create: `packages/backend/src/services/lessons.test.ts`
|
|
- Create: `packages/backend/src/routes/lessons.ts`
|
|
- Modify: `packages/backend/src/app.ts`
|
|
|
|
- [ ] **Step 1: Write failing tests for `lessons.ts` service**
|
|
|
|
```ts
|
|
// lessons.test.ts
|
|
import { describe, it, expect, beforeEach } from 'vitest';
|
|
import { makeTestDb } from '../tests/dbHelper.js';
|
|
import { createLesson, getLessonTree, updateLesson, deleteLesson, moveLesson } from './lessons.js';
|
|
|
|
let env: ReturnType<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`**
|
|
|
|
```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`**
|
|
|
|
```ts
|
|
import { Router } from 'express';
|
|
import { lessonCreateSchema, lessonMoveSchema, lessonUpdateSchema } from '@flashcard/shared';
|
|
import type { Db } from '../db/client.js';
|
|
import {
|
|
createLesson, deleteLesson, getLessonTree, moveLesson, updateLesson,
|
|
} from '../services/lessons.js';
|
|
|
|
export function lessonsRouter(db: Db): Router {
|
|
const r = Router();
|
|
|
|
r.get('/tree', async (_req, res, next) => {
|
|
try { res.json(await getLessonTree(db)); } catch (e) { next(e); }
|
|
});
|
|
|
|
r.post('/', async (req, res, next) => {
|
|
try {
|
|
const input = lessonCreateSchema.parse(req.body);
|
|
res.status(201).json(await createLesson(db, input));
|
|
} catch (e) { next(e); }
|
|
});
|
|
|
|
r.patch('/:id', async (req, res, next) => {
|
|
try {
|
|
const id = Number(req.params.id);
|
|
const input = lessonUpdateSchema.parse(req.body);
|
|
res.json(await updateLesson(db, id, input));
|
|
} catch (e) { next(e); }
|
|
});
|
|
|
|
r.delete('/:id', async (req, res, next) => {
|
|
try {
|
|
await deleteLesson(db, Number(req.params.id));
|
|
res.status(204).end();
|
|
} catch (e) { next(e); }
|
|
});
|
|
|
|
r.post('/:id/move', async (req, res, next) => {
|
|
try {
|
|
const input = lessonMoveSchema.parse(req.body);
|
|
res.json(await moveLesson(db, Number(req.params.id), input));
|
|
} catch (e) { next(e); }
|
|
});
|
|
|
|
return r;
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 6: Wire router in `app.ts`**
|
|
|
|
Modify `createApp()` to accept a `Db` parameter and mount the router. Update `app.ts`:
|
|
|
|
```ts
|
|
import express, { type Express, type NextFunction, type Request, type Response } from 'express';
|
|
import { ZodError } from 'zod';
|
|
import type { Db } from './db/client.js';
|
|
import { ApiError } from './lib/errors.js';
|
|
import { lessonsRouter } from './routes/lessons.js';
|
|
|
|
export function createApp(db: Db): Express {
|
|
const app = express();
|
|
app.use(express.json({ limit: '5mb' }));
|
|
|
|
app.get('/api/health', (_req, res) => res.json({ ok: true }));
|
|
app.use('/api/lessons', lessonsRouter(db));
|
|
|
|
app.use((err: unknown, _req: Request, res: Response, _next: NextFunction) => {
|
|
if (err instanceof ZodError) {
|
|
res.status(400).json({ error: { code: 'VALIDATION_ERROR', message: 'Invalid input', details: err.flatten() } });
|
|
return;
|
|
}
|
|
if (err instanceof ApiError) {
|
|
res.status(err.status).json({ error: { code: err.code, message: err.message, details: err.details } });
|
|
return;
|
|
}
|
|
console.error(err);
|
|
res.status(500).json({ error: { code: 'INTERNAL', message: 'Internal server error' } });
|
|
});
|
|
|
|
return app;
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 7: Update `index.ts`**
|
|
|
|
```ts
|
|
import { createApp } from './app.js';
|
|
import { createDb } from './db/client.js';
|
|
|
|
const PORT = Number(process.env.PORT ?? 3000);
|
|
const { db } = createDb();
|
|
const app = createApp(db);
|
|
app.listen(PORT, () => {
|
|
console.log(`Backend listening on http://localhost:${PORT}`);
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 8: Commit**
|
|
|
|
```bash
|
|
git add -A
|
|
git commit -m "feat(backend): lessons CRUD service and routes"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 8: Cards CRUD service + routes
|
|
|
|
**Files:**
|
|
- Create: `packages/backend/src/services/cards.ts`
|
|
- Create: `packages/backend/src/services/cards.test.ts`
|
|
- Create: `packages/backend/src/routes/cards.ts`
|
|
- Modify: `packages/backend/src/app.ts`
|
|
|
|
- [ ] **Step 1: Write failing tests**
|
|
|
|
```ts
|
|
// cards.test.ts
|
|
import { describe, it, expect, beforeEach } from 'vitest';
|
|
import { makeTestDb } from '../tests/dbHelper.js';
|
|
import { createLesson } from './lessons.js';
|
|
import { createCard, listCardsByLesson, updateCard, deleteCard } from './cards.js';
|
|
|
|
let env: ReturnType<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`**
|
|
|
|
```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`**
|
|
|
|
```ts
|
|
import { Router } from 'express';
|
|
import { cardCreateSchema, cardUpdateSchema } from '@flashcard/shared';
|
|
import type { Db } from '../db/client.js';
|
|
import { createCard, deleteCard, listCardsByLesson, updateCard } from '../services/cards.js';
|
|
|
|
export function cardsRouter(db: Db): Router {
|
|
const r = Router({ mergeParams: true });
|
|
|
|
r.get('/lessons/:lessonId/cards', async (req, res, next) => {
|
|
try { res.json(await listCardsByLesson(db, Number(req.params.lessonId))); } catch (e) { next(e); }
|
|
});
|
|
|
|
r.post('/lessons/:lessonId/cards', async (req, res, next) => {
|
|
try {
|
|
const input = cardCreateSchema.parse(req.body);
|
|
res.status(201).json(await createCard(db, Number(req.params.lessonId), input));
|
|
} catch (e) { next(e); }
|
|
});
|
|
|
|
r.patch('/cards/:id', async (req, res, next) => {
|
|
try {
|
|
const input = cardUpdateSchema.parse(req.body);
|
|
res.json(await updateCard(db, Number(req.params.id), input));
|
|
} catch (e) { next(e); }
|
|
});
|
|
|
|
r.delete('/cards/:id', async (req, res, next) => {
|
|
try { await deleteCard(db, Number(req.params.id)); res.status(204).end(); } catch (e) { next(e); }
|
|
});
|
|
|
|
return r;
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 6: Mount router in `app.ts`**
|
|
|
|
Add after `app.use('/api/lessons', lessonsRouter(db));`:
|
|
```ts
|
|
import { cardsRouter } from './routes/cards.js';
|
|
// ...
|
|
app.use('/api', cardsRouter(db));
|
|
```
|
|
|
|
- [ ] **Step 7: Commit**
|
|
|
|
```bash
|
|
git add -A
|
|
git commit -m "feat(backend): cards CRUD service and routes"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 9: Session engine + Leitner integration (TDD)
|
|
|
|
**Files:**
|
|
- Create: `packages/backend/src/services/sessions.ts`
|
|
- Create: `packages/backend/src/services/sessions.test.ts`
|
|
|
|
- [ ] **Step 1: Write failing tests covering queue build, next, record attempt, end**
|
|
|
|
```ts
|
|
// sessions.test.ts
|
|
import { describe, it, expect, beforeEach } from 'vitest';
|
|
import { makeTestDb } from '../tests/dbHelper.js';
|
|
import { createLesson } from './lessons.js';
|
|
import { createCard } from './cards.js';
|
|
import { startSession, getNextItem, recordAttempt, endSession, getActiveSession } from './sessions.js';
|
|
|
|
let env: ReturnType<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`**
|
|
|
|
```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**
|
|
|
|
```bash
|
|
git add -A
|
|
git commit -m "feat(backend): session engine with Leitner integration"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 10: Sessions routes
|
|
|
|
**Files:**
|
|
- Create: `packages/backend/src/routes/sessions.ts`
|
|
- Modify: `packages/backend/src/app.ts`
|
|
|
|
- [ ] **Step 1: Create `routes/sessions.ts`**
|
|
|
|
```ts
|
|
import { Router } from 'express';
|
|
import { attemptCreateSchema, sessionStartSchema } from '@flashcard/shared';
|
|
import type { Db } from '../db/client.js';
|
|
import {
|
|
abandonSession, endSession, getActiveSession, getNextItem, getSessionState,
|
|
recordAttempt, startSession,
|
|
} from '../services/sessions.js';
|
|
import { ApiError } from '../lib/errors.js';
|
|
|
|
export function sessionsRouter(db: Db): Router {
|
|
const r = Router();
|
|
|
|
r.post('/', async (req, res, next) => {
|
|
try {
|
|
const input = sessionStartSchema.parse(req.body);
|
|
res.status(201).json(await startSession(db, input));
|
|
} catch (e) { next(e); }
|
|
});
|
|
|
|
r.get('/active', async (_req, res, next) => {
|
|
try { res.json(await getActiveSession(db)); } catch (e) { next(e); }
|
|
});
|
|
|
|
r.get('/:id', async (req, res, next) => {
|
|
try {
|
|
const state = await getSessionState(db, Number(req.params.id));
|
|
if (!state) throw ApiError.notFound('Session');
|
|
res.json(state);
|
|
} catch (e) { next(e); }
|
|
});
|
|
|
|
r.get('/:id/next', async (req, res, next) => {
|
|
try {
|
|
const item = await getNextItem(db, Number(req.params.id));
|
|
if (!item) { res.json({ done: true }); return; }
|
|
res.json({ done: false, item });
|
|
} catch (e) { next(e); }
|
|
});
|
|
|
|
r.post('/:id/attempts', async (req, res, next) => {
|
|
try {
|
|
const input = attemptCreateSchema.parse(req.body);
|
|
await recordAttempt(db, Number(req.params.id), input);
|
|
res.status(204).end();
|
|
} catch (e) { next(e); }
|
|
});
|
|
|
|
r.post('/:id/end', async (req, res, next) => {
|
|
try { res.json(await endSession(db, Number(req.params.id))); } catch (e) { next(e); }
|
|
});
|
|
|
|
r.post('/:id/abandon', async (req, res, next) => {
|
|
try { res.json(await abandonSession(db, Number(req.params.id))); } catch (e) { next(e); }
|
|
});
|
|
|
|
return r;
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Mount in `app.ts`**
|
|
|
|
Add `import { sessionsRouter } from './routes/sessions.js';` and `app.use('/api/sessions', sessionsRouter(db));`.
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add -A
|
|
git commit -m "feat(backend): sessions routes"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 11: Statistics service + routes
|
|
|
|
**Files:**
|
|
- Create: `packages/backend/src/services/stats.ts`
|
|
- Create: `packages/backend/src/services/stats.test.ts`
|
|
- Create: `packages/backend/src/routes/stats.ts`
|
|
- Modify: `packages/backend/src/app.ts`
|
|
|
|
- [ ] **Step 1: Write failing tests**
|
|
|
|
```ts
|
|
// stats.test.ts
|
|
import { describe, it, expect, beforeEach } from 'vitest';
|
|
import { makeTestDb } from '../tests/dbHelper.js';
|
|
import { createLesson } from './lessons.js';
|
|
import { createCard } from './cards.js';
|
|
import { startSession, recordAttempt, getNextItem, endSession } from './sessions.js';
|
|
import { getCardStats, getLessonStats, getOverview } from './stats.js';
|
|
|
|
let env: ReturnType<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`**
|
|
|
|
```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`**
|
|
|
|
```ts
|
|
import { Router } from 'express';
|
|
import type { Db } from '../db/client.js';
|
|
import { getCardStats, getHeatmap, getLessonStats, getOverview } from '../services/stats.js';
|
|
|
|
export function statsRouter(db: Db): Router {
|
|
const r = Router();
|
|
r.get('/overview', async (_req, res, next) => {
|
|
try { res.json(await getOverview(db)); } catch (e) { next(e); }
|
|
});
|
|
r.get('/lessons/:id', async (req, res, next) => {
|
|
try { res.json(await getLessonStats(db, Number(req.params.id))); } catch (e) { next(e); }
|
|
});
|
|
r.get('/cards/:id', async (req, res, next) => {
|
|
try { res.json(await getCardStats(db, Number(req.params.id))); } catch (e) { next(e); }
|
|
});
|
|
r.get('/heatmap', async (req, res, next) => {
|
|
try {
|
|
const weeks = Math.min(52, Math.max(1, Number(req.query.weeks ?? 12)));
|
|
res.json(await getHeatmap(db, weeks));
|
|
} catch (e) { next(e); }
|
|
});
|
|
return r;
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 6: Mount in `app.ts`**
|
|
|
|
Add `import { statsRouter } from './routes/stats.js';` and `app.use('/api/stats', statsRouter(db));`.
|
|
|
|
- [ ] **Step 7: Commit**
|
|
|
|
```bash
|
|
git add -A
|
|
git commit -m "feat(backend): stats service and routes"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 12: Excel import & export
|
|
|
|
**Files:**
|
|
- Create: `packages/backend/src/services/import.ts`
|
|
- Create: `packages/backend/src/services/import.test.ts`
|
|
- Modify: `packages/backend/src/routes/cards.ts`
|
|
|
|
- [ ] **Step 1: Write failing tests**
|
|
|
|
```ts
|
|
// import.test.ts
|
|
import { describe, it, expect, beforeEach } from 'vitest';
|
|
import * as XLSX from 'xlsx';
|
|
import { makeTestDb } from '../tests/dbHelper.js';
|
|
import { createLesson } from './lessons.js';
|
|
import { importCardsFromBuffer, exportCardsToBuffer } from './import.js';
|
|
import { listCardsByLesson } from './cards.js';
|
|
|
|
let env: ReturnType<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`**
|
|
|
|
```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;`:
|
|
|
|
```ts
|
|
import multer from 'multer';
|
|
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 10 * 1024 * 1024 } });
|
|
|
|
r.post('/lessons/:lessonId/cards/import', upload.single('file'), async (req, res, next) => {
|
|
try {
|
|
if (!req.file) throw ApiError.validation('file is required');
|
|
const updateExisting = req.body.updateExisting !== 'false';
|
|
const createMissingLessons = req.body.createMissingLessons === 'true';
|
|
const result = await importCardsFromBuffer(
|
|
db, Number(req.params.lessonId), req.file.buffer, { updateExisting, createMissingLessons }
|
|
);
|
|
res.json(result);
|
|
} catch (e) { next(e); }
|
|
});
|
|
|
|
r.get('/lessons/:lessonId/cards/export', async (req, res, next) => {
|
|
try {
|
|
const includeDescendants = req.query.include_descendants === 'true';
|
|
const buf = await exportCardsToBuffer(db, Number(req.params.lessonId), includeDescendants);
|
|
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
|
|
res.setHeader('Content-Disposition', `attachment; filename="cards-lesson-${req.params.lessonId}.xlsx"`);
|
|
res.send(buf);
|
|
} catch (e) { next(e); }
|
|
});
|
|
```
|
|
|
|
Add imports at top of `routes/cards.ts`:
|
|
```ts
|
|
import multer from 'multer';
|
|
import { ApiError } from '../lib/errors.js';
|
|
import { exportCardsToBuffer, importCardsFromBuffer } from '../services/import.js';
|
|
```
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```bash
|
|
git add -A
|
|
git commit -m "feat(backend): excel import and export"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 13: Production static frontend serving
|
|
|
|
**Files:**
|
|
- Modify: `packages/backend/src/app.ts`
|
|
|
|
- [ ] **Step 1: Serve built frontend when present**
|
|
|
|
Add at the bottom of `createApp` (before the error handler):
|
|
|
|
```ts
|
|
import { existsSync } from 'node:fs';
|
|
import { resolve } from 'node:path';
|
|
import express from 'express';
|
|
// ...
|
|
const frontendDist = resolve(import.meta.dirname, '../../frontend/dist');
|
|
if (existsSync(frontendDist)) {
|
|
app.use(express.static(frontendDist));
|
|
app.get('*', (_req, res) => res.sendFile(resolve(frontendDist, 'index.html')));
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Commit**
|
|
|
|
```bash
|
|
git add -A
|
|
git commit -m "feat(backend): serve built frontend in production"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 14: Frontend bootstrap
|
|
|
|
**Files:**
|
|
- Create: `packages/frontend/package.json`
|
|
- Create: `packages/frontend/tsconfig.json`
|
|
- Create: `packages/frontend/vite.config.ts`
|
|
- Create: `packages/frontend/tailwind.config.ts`
|
|
- Create: `packages/frontend/postcss.config.js`
|
|
- Create: `packages/frontend/index.html`
|
|
- Create: `packages/frontend/src/main.tsx`
|
|
- Create: `packages/frontend/src/App.tsx`
|
|
- Create: `packages/frontend/src/router.tsx`
|
|
- Create: `packages/frontend/src/styles.css`
|
|
|
|
- [ ] **Step 1: Create `packages/frontend/package.json`**
|
|
|
|
```json
|
|
{
|
|
"name": "@flashcard/frontend",
|
|
"version": "0.1.0",
|
|
"private": true,
|
|
"type": "module",
|
|
"scripts": {
|
|
"dev": "vite",
|
|
"build": "tsc --noEmit && vite build",
|
|
"preview": "vite preview",
|
|
"typecheck": "tsc --noEmit",
|
|
"test": "vitest run"
|
|
},
|
|
"dependencies": {
|
|
"@flashcard/shared": "*",
|
|
"canvas-confetti": "^1.9.0",
|
|
"framer-motion": "^11.0.0",
|
|
"react": "^18.3.0",
|
|
"react-dom": "^18.3.0",
|
|
"react-router-dom": "^6.26.0",
|
|
"zustand": "^4.5.0"
|
|
},
|
|
"devDependencies": {
|
|
"@testing-library/jest-dom": "^6.5.0",
|
|
"@testing-library/react": "^16.0.0",
|
|
"@types/canvas-confetti": "^1.6.0",
|
|
"@types/react": "^18.3.0",
|
|
"@types/react-dom": "^18.3.0",
|
|
"@vitejs/plugin-react": "^4.3.0",
|
|
"autoprefixer": "^10.4.0",
|
|
"jsdom": "^25.0.0",
|
|
"postcss": "^8.4.0",
|
|
"tailwindcss": "^3.4.0",
|
|
"typescript": "^5.5.0",
|
|
"vite": "^7.0.0",
|
|
"vitest": "^2.0.0"
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Create `tsconfig.json`**
|
|
|
|
```json
|
|
{
|
|
"extends": "../../tsconfig.base.json",
|
|
"compilerOptions": {
|
|
"jsx": "react-jsx",
|
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
|
"moduleResolution": "Bundler"
|
|
},
|
|
"include": ["src/**/*", "index.html"]
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Create `vite.config.ts`**
|
|
|
|
```ts
|
|
import react from '@vitejs/plugin-react';
|
|
import { defineConfig } from 'vite';
|
|
|
|
export default defineConfig({
|
|
plugins: [react()],
|
|
server: {
|
|
port: 5173,
|
|
proxy: { '/api': 'http://localhost:3000' },
|
|
},
|
|
test: { environment: 'jsdom', setupFiles: ['./src/test-setup.ts'] },
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 4: Create `tailwind.config.ts`**
|
|
|
|
```ts
|
|
import type { Config } from 'tailwindcss';
|
|
export default {
|
|
content: ['./index.html', './src/**/*.{ts,tsx}'],
|
|
darkMode: 'class',
|
|
theme: {
|
|
extend: {
|
|
animation: {
|
|
'flip': 'flip 0.4s ease-out forwards',
|
|
},
|
|
keyframes: {
|
|
flip: {
|
|
'0%': { transform: 'rotateY(0)' },
|
|
'100%': { transform: 'rotateY(180deg)' },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
plugins: [],
|
|
} satisfies Config;
|
|
```
|
|
|
|
- [ ] **Step 5: Create `postcss.config.js`**
|
|
|
|
```js
|
|
export default { plugins: { tailwindcss: {}, autoprefixer: {} } };
|
|
```
|
|
|
|
- [ ] **Step 6: Create `index.html`**
|
|
|
|
```html
|
|
<!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`**
|
|
|
|
```css
|
|
@tailwind base;
|
|
@tailwind components;
|
|
@tailwind utilities;
|
|
|
|
html, body, #root { height: 100%; }
|
|
.card-perspective { perspective: 1000px; }
|
|
.card-face { backface-visibility: hidden; }
|
|
```
|
|
|
|
- [ ] **Step 8: Create `src/main.tsx`**
|
|
|
|
```tsx
|
|
import React from 'react';
|
|
import { createRoot } from 'react-dom/client';
|
|
import { RouterProvider } from 'react-router-dom';
|
|
import { router } from './router.js';
|
|
import './styles.css';
|
|
|
|
const root = createRoot(document.getElementById('root')!);
|
|
root.render(
|
|
<React.StrictMode>
|
|
<RouterProvider router={router} />
|
|
</React.StrictMode>
|
|
);
|
|
```
|
|
|
|
- [ ] **Step 9: Create `src/router.tsx`** (skeleton; pages added in later tasks)
|
|
|
|
```tsx
|
|
import { createBrowserRouter, Navigate } from 'react-router-dom';
|
|
import { Layout } from './components/Layout.js';
|
|
|
|
export const router = createBrowserRouter([
|
|
{
|
|
path: '/',
|
|
element: <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`**
|
|
|
|
```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`**
|
|
|
|
```ts
|
|
import '@testing-library/jest-dom/vitest';
|
|
```
|
|
|
|
- [ ] **Step 12: Install**
|
|
|
|
Run: `npm install`
|
|
|
|
- [ ] **Step 13: Smoke test**
|
|
|
|
Run: `npm run dev` from repo root. Open http://localhost:5173. You should see the header with `Dashboard placeholder`.
|
|
|
|
- [ ] **Step 14: Commit**
|
|
|
|
```bash
|
|
git add -A
|
|
git commit -m "feat(frontend): bootstrap React + Vite + Tailwind + Router + Layout"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 15: Frontend API client
|
|
|
|
**Files:**
|
|
- Create: `packages/frontend/src/api/client.ts`
|
|
- Create: `packages/frontend/src/api/lessons.ts`
|
|
- Create: `packages/frontend/src/api/cards.ts`
|
|
- Create: `packages/frontend/src/api/sessions.ts`
|
|
- Create: `packages/frontend/src/api/stats.ts`
|
|
|
|
- [ ] **Step 1: Create `api/client.ts`**
|
|
|
|
```ts
|
|
export class ApiClientError extends Error {
|
|
constructor(public status: number, public code: string, message: string, public details?: unknown) {
|
|
super(message);
|
|
}
|
|
}
|
|
|
|
async function request<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`**
|
|
|
|
```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`**
|
|
|
|
```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`**
|
|
|
|
```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`**
|
|
|
|
```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**
|
|
|
|
```bash
|
|
git add -A
|
|
git commit -m "feat(frontend): API client modules"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 16: Frontend stores (Zustand)
|
|
|
|
**Files:**
|
|
- Create: `packages/frontend/src/stores/lessonsStore.ts`
|
|
- Create: `packages/frontend/src/stores/sessionStore.ts`
|
|
- Create: `packages/frontend/src/stores/settingsStore.ts`
|
|
|
|
- [ ] **Step 1: Create `stores/settingsStore.ts`**
|
|
|
|
```ts
|
|
import { create } from 'zustand';
|
|
|
|
interface SettingsState {
|
|
theme: 'light' | 'dark';
|
|
defaultMaxCards: number;
|
|
toggleTheme: () => void;
|
|
setDefaultMaxCards: (n: number) => void;
|
|
hydrate: () => void;
|
|
}
|
|
|
|
const KEY = 'flashcard:settings';
|
|
|
|
export const useSettings = create<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`:
|
|
|
|
```tsx
|
|
import { useSettings } from './stores/settingsStore.js';
|
|
useSettings.getState().hydrate();
|
|
```
|
|
|
|
(Add immediately before `createRoot`.)
|
|
|
|
- [ ] **Step 3: Create `stores/lessonsStore.ts`**
|
|
|
|
```ts
|
|
import { create } from 'zustand';
|
|
import type { LessonTreeNode } from '@flashcard/shared';
|
|
import { lessonsApi } from '../api/lessons.js';
|
|
|
|
interface LessonsState {
|
|
tree: LessonTreeNode[];
|
|
loading: boolean;
|
|
refresh: () => Promise<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`**
|
|
|
|
```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**
|
|
|
|
```bash
|
|
git add -A
|
|
git commit -m "feat(frontend): zustand stores for lessons, session, settings"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 17: Admin — Lessons tree
|
|
|
|
**Files:**
|
|
- Create: `packages/frontend/src/pages/Admin.tsx`
|
|
- Create: `packages/frontend/src/components/LessonTree.tsx`
|
|
- Modify: `packages/frontend/src/router.tsx`
|
|
|
|
- [ ] **Step 1: Create `components/LessonTree.tsx`**
|
|
|
|
```tsx
|
|
import { useState } from 'react';
|
|
import { Link } from 'react-router-dom';
|
|
import type { LessonTreeNode } from '@flashcard/shared';
|
|
import { lessonsApi } from '../api/lessons.js';
|
|
import { useLessons } from '../stores/lessonsStore.js';
|
|
|
|
export function LessonTree({ nodes, depth = 0 }: { nodes: LessonTreeNode[]; depth?: number }) {
|
|
const refresh = useLessons((s) => s.refresh);
|
|
const [addingTo, setAddingTo] = useState<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`**
|
|
|
|
```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`:
|
|
```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**
|
|
|
|
```bash
|
|
git add -A
|
|
git commit -m "feat(frontend): admin lesson tree CRUD"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 18: Admin — Card management + Import/Export
|
|
|
|
**Files:**
|
|
- Create: `packages/frontend/src/pages/AdminLesson.tsx`
|
|
- Create: `packages/frontend/src/components/CardTable.tsx`
|
|
- Create: `packages/frontend/src/components/ImportDialog.tsx`
|
|
- Modify: `packages/frontend/src/router.tsx`
|
|
|
|
- [ ] **Step 1: Create `components/CardTable.tsx`**
|
|
|
|
```tsx
|
|
import { useState } from 'react';
|
|
import type { Card, CardCreateInput } from '@flashcard/shared';
|
|
import { cardsApi } from '../api/cards.js';
|
|
|
|
export function CardTable({ lessonId, cards, onChange }: { lessonId: number; cards: Card[]; onChange: () => void }) {
|
|
const [draft, setDraft] = useState<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`**
|
|
|
|
```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`**
|
|
|
|
```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:
|
|
```tsx
|
|
{ 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**
|
|
|
|
```bash
|
|
git add -A
|
|
git commit -m "feat(frontend): admin card management with excel import/export"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 19: Practice flow — setup, session, done
|
|
|
|
**Files:**
|
|
- Create: `packages/frontend/src/pages/PracticeSetup.tsx`
|
|
- Create: `packages/frontend/src/pages/Practice.tsx`
|
|
- Create: `packages/frontend/src/pages/PracticeDone.tsx`
|
|
- Create: `packages/frontend/src/components/Flashcard.tsx`
|
|
- Create: `packages/frontend/src/components/Confetti.tsx`
|
|
- Modify: `packages/frontend/src/router.tsx`
|
|
|
|
- [ ] **Step 1: Create `components/Flashcard.tsx`**
|
|
|
|
```tsx
|
|
import { AnimatePresence, motion } from 'framer-motion';
|
|
|
|
export function Flashcard({
|
|
question, answer, hint, showAnswer, onReveal, onAnswer,
|
|
}: {
|
|
question: string; answer: string; hint: string | null;
|
|
showAnswer: boolean;
|
|
onReveal: () => void;
|
|
onAnswer: (r: 'correct' | 'incorrect') => void;
|
|
}) {
|
|
return (
|
|
<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`**
|
|
|
|
```tsx
|
|
import confetti from 'canvas-confetti';
|
|
import { useEffect } from 'react';
|
|
|
|
export function Confetti({ trigger }: { trigger: boolean }) {
|
|
useEffect(() => {
|
|
if (!trigger) return;
|
|
const end = Date.now() + 1200;
|
|
(function frame() {
|
|
confetti({ particleCount: 4, angle: 60, spread: 55, origin: { x: 0 } });
|
|
confetti({ particleCount: 4, angle: 120, spread: 55, origin: { x: 1 } });
|
|
if (Date.now() < end) requestAnimationFrame(frame);
|
|
})();
|
|
}, [trigger]);
|
|
return null;
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Create `pages/PracticeSetup.tsx`**
|
|
|
|
```tsx
|
|
import { useState } from 'react';
|
|
import { useNavigate, useParams } from 'react-router-dom';
|
|
import { useSettings } from '../stores/settingsStore.js';
|
|
import { useSession } from '../stores/sessionStore.js';
|
|
|
|
export function PracticeSetupPage() {
|
|
const { lessonId } = useParams();
|
|
const id = Number(lessonId);
|
|
const defaultMax = useSettings((s) => s.defaultMaxCards);
|
|
const start = useSession((s) => s.start);
|
|
const navigate = useNavigate();
|
|
|
|
const [maxCards, setMaxCards] = useState<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`**
|
|
|
|
```tsx
|
|
import { useEffect, useState } from 'react';
|
|
import { useNavigate, useParams } from 'react-router-dom';
|
|
import type { Card } from '@flashcard/shared';
|
|
import { cardsApi } from '../api/cards.js';
|
|
import { useSession } from '../stores/sessionStore.js';
|
|
import { Flashcard } from '../components/Flashcard.js';
|
|
|
|
export function PracticePage() {
|
|
const { lessonId } = useParams();
|
|
const { session, current, done, showAnswer, reveal, answer, end } = useSession();
|
|
const navigate = useNavigate();
|
|
const [card, setCard] = useState<Card | null>(null);
|
|
|
|
useEffect(() => {
|
|
if (!session) { navigate(`/practice/${lessonId}/setup`); return; }
|
|
}, [session, lessonId, navigate]);
|
|
|
|
useEffect(() => {
|
|
let cancel = false;
|
|
(async () => {
|
|
if (!current) { setCard(null); return; }
|
|
// simple cache-bust: fetch list for the card's lesson? Simpler: fetch card via stats endpoint not ideal; use existing list endpoint cached by lesson.
|
|
const cards = await cardsApi.list(session!.lessonId);
|
|
if (cancel) return;
|
|
// Card may belong to a descendant — fall back: fetch full set via /stats/cards/:id is not the card content. We need a /api/cards/:id endpoint.
|
|
const found = cards.find((c) => c.id === current.cardId);
|
|
setCard(found ?? null);
|
|
})();
|
|
return () => { cancel = true; };
|
|
}, [current, session]);
|
|
|
|
useEffect(() => {
|
|
if (done && session) { (async () => { await end(); navigate(`/practice/${lessonId}/done`); })(); }
|
|
}, [done, session, end, navigate, lessonId]);
|
|
|
|
if (!current || !card) return <div className="p-6 text-center">Laden...</div>;
|
|
|
|
const isReverse = current.direction === 'backward';
|
|
return (
|
|
<div className="flex h-full flex-col">
|
|
<div className="mx-auto w-full max-w-2xl p-6">
|
|
<div className="mb-2 text-xs text-slate-500">{session?.cardsShown ?? 0} kaarten behandeld</div>
|
|
<Flashcard
|
|
question={isReverse ? card.answer : card.question}
|
|
answer={isReverse ? card.question : card.answer}
|
|
hint={card.hint}
|
|
showAnswer={showAnswer}
|
|
onReveal={reveal}
|
|
onAnswer={(r) => answer(r)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
> Note: This page currently fetches all cards in the session's *root* lesson and assumes the active card is there. For sessions including descendants, that may not work. Therefore add a backend endpoint as part of this task — see next step.
|
|
|
|
- [ ] **Step 5: Add `GET /api/cards/:id` backend endpoint**
|
|
|
|
In `packages/backend/src/routes/cards.ts`, add inside `cardsRouter`:
|
|
```ts
|
|
r.get('/cards/:id', async (req, res, next) => {
|
|
try { res.json(await getCard(db, Number(req.params.id))); } catch (e) { next(e); }
|
|
});
|
|
```
|
|
Import: `import { getCard } from '../services/cards.js';`
|
|
|
|
In `packages/frontend/src/api/cards.ts` add:
|
|
```ts
|
|
get: (id: number) => api.get<Card>(`/cards/${id}`),
|
|
```
|
|
|
|
In `Practice.tsx` replace the card-fetching effect with:
|
|
```tsx
|
|
useEffect(() => {
|
|
let cancel = false;
|
|
(async () => {
|
|
if (!current) { setCard(null); return; }
|
|
const c = await cardsApi.get(current.cardId);
|
|
if (!cancel) setCard(c);
|
|
})();
|
|
return () => { cancel = true; };
|
|
}, [current]);
|
|
```
|
|
|
|
- [ ] **Step 6: Create `pages/PracticeDone.tsx`**
|
|
|
|
```tsx
|
|
import { Link, useParams } from 'react-router-dom';
|
|
import { useSession } from '../stores/sessionStore.js';
|
|
import { Confetti } from '../components/Confetti.js';
|
|
|
|
export function PracticeDonePage() {
|
|
const { lessonId } = useParams();
|
|
const session = useSession((s) => s.session);
|
|
const reset = useSession((s) => s.reset);
|
|
if (!session) return <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:
|
|
```tsx
|
|
{ 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**
|
|
|
|
```bash
|
|
git add -A
|
|
git commit -m "feat(frontend): practice setup, session and done flow"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 20: Dashboard + Stats pages
|
|
|
|
**Files:**
|
|
- Create: `packages/frontend/src/pages/Dashboard.tsx`
|
|
- Create: `packages/frontend/src/pages/Stats.tsx`
|
|
- Create: `packages/frontend/src/pages/StatsLesson.tsx`
|
|
- Create: `packages/frontend/src/pages/StatsCard.tsx`
|
|
- Create: `packages/frontend/src/lib/format.ts`
|
|
- Modify: `packages/frontend/src/router.tsx`
|
|
|
|
- [ ] **Step 1: Create `lib/format.ts`**
|
|
|
|
```ts
|
|
export function formatDuration(seconds: number): string {
|
|
const h = Math.floor(seconds / 3600);
|
|
const m = Math.floor((seconds % 3600) / 60);
|
|
const s = seconds % 60;
|
|
if (h > 0) return `${h}u ${m}m`;
|
|
if (m > 0) return `${m}m ${s}s`;
|
|
return `${s}s`;
|
|
}
|
|
export function formatPct(n: number): string { return `${Math.round(n * 100)}%`; }
|
|
export function formatDate(unixSec: number): string {
|
|
return new Date(unixSec * 1000).toLocaleString();
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Create `pages/Dashboard.tsx`**
|
|
|
|
```tsx
|
|
import { useEffect, useState } from 'react';
|
|
import { Link } from 'react-router-dom';
|
|
import { statsApi, type Overview } from '../api/stats.js';
|
|
import { useLessons } from '../stores/lessonsStore.js';
|
|
import { formatDuration, formatDate } from '../lib/format.js';
|
|
|
|
export function DashboardPage() {
|
|
const { tree, refresh } = useLessons();
|
|
const [ov, setOv] = useState<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)
|
|
|
|
```tsx
|
|
import { useEffect, useState } from 'react';
|
|
import { statsApi } from '../api/stats.js';
|
|
|
|
export function StatsPage() {
|
|
const [heatmap, setHeatmap] = useState<{ day: string; sessions: number; attempts: number }[]>([]);
|
|
useEffect(() => { statsApi.heatmap(12).then(setHeatmap); }, []);
|
|
|
|
const max = Math.max(1, ...heatmap.map((d) => d.attempts));
|
|
return (
|
|
<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`**
|
|
|
|
```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`**
|
|
|
|
```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:
|
|
```tsx
|
|
{ path: 'stats', element: <StatsPage /> },
|
|
{ path: 'stats/lessons/:id', element: <StatsLessonPage /> },
|
|
{ path: 'stats/cards/:id', element: <StatsCardPage /> },
|
|
```
|
|
Import all pages.
|
|
|
|
- [ ] **Step 7: Commit**
|
|
|
|
```bash
|
|
git add -A
|
|
git commit -m "feat(frontend): dashboard and stats pages"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 21: Settings & dark mode toggle
|
|
|
|
**Files:**
|
|
- Create: `packages/frontend/src/pages/Settings.tsx`
|
|
- Modify: `packages/frontend/src/components/Layout.tsx`
|
|
- Modify: `packages/frontend/src/router.tsx`
|
|
|
|
- [ ] **Step 1: Create `pages/Settings.tsx`**
|
|
|
|
```tsx
|
|
import { useSettings } from '../stores/settingsStore.js';
|
|
|
|
export function SettingsPage() {
|
|
const { theme, defaultMaxCards, toggleTheme, setDefaultMaxCards } = useSettings();
|
|
return (
|
|
<div className="mx-auto max-w-md p-6">
|
|
<h1 className="mb-4 text-2xl font-semibold">Instellingen</h1>
|
|
<label className="mb-3 flex items-center gap-2 text-sm">
|
|
<input type="checkbox" checked={theme === 'dark'} onChange={toggleTheme} /> Dark mode
|
|
</label>
|
|
<label className="block text-sm">Standaard max kaarten per sessie
|
|
<input type="number" min={1} max={500} className="ml-2 w-20 rounded border px-2 py-1 dark:bg-slate-900" value={defaultMaxCards} onChange={(e) => setDefaultMaxCards(Math.max(1, Number(e.target.value)))} />
|
|
</label>
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Update `Layout.tsx`** with settings link & dark toggle
|
|
|
|
```tsx
|
|
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**
|
|
|
|
```bash
|
|
git add -A
|
|
git commit -m "feat(frontend): settings page with dark mode and defaults"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 22: Resume active session prompt
|
|
|
|
**Files:**
|
|
- Modify: `packages/frontend/src/pages/Dashboard.tsx`
|
|
|
|
- [ ] **Step 1: On mount, check for active session**
|
|
|
|
Add to `DashboardPage`:
|
|
|
|
```tsx
|
|
import { sessionsApi } from '../api/sessions.js';
|
|
import type { SessionRow } from '@flashcard/shared';
|
|
import { useNavigate } from 'react-router-dom';
|
|
// ...inside component:
|
|
const [active, setActive] = useState<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:
|
|
|
|
```tsx
|
|
import { sessionsApi } from '../api/sessions.js';
|
|
import { useSession } from '../stores/sessionStore.js';
|
|
// inside PracticePage, before the existing "if no session redirect" effect:
|
|
useEffect(() => {
|
|
if (useSession.getState().session) return;
|
|
(async () => {
|
|
const active = await sessionsApi.active();
|
|
if (!active || String(active.lessonId) !== lessonId) return;
|
|
const state = await sessionsApi.state(active.id);
|
|
const nx = await sessionsApi.next(active.id);
|
|
useSession.setState({
|
|
session: active,
|
|
current: nx.done ? null : nx.item,
|
|
done: nx.done,
|
|
showAnswer: false,
|
|
shownAt: Date.now(),
|
|
});
|
|
})();
|
|
}, [lessonId]);
|
|
```
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add -A
|
|
git commit -m "feat(frontend): resume active session prompt"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 23: Playwright E2E smoke
|
|
|
|
**Files:**
|
|
- Create: `packages/frontend/playwright.config.ts`
|
|
- Create: `e2e/smoke.spec.ts`
|
|
- Modify: root `package.json` (add `e2e` script)
|
|
|
|
- [ ] **Step 1: Install Playwright**
|
|
|
|
Run: `npm i -D -w @flashcard/frontend @playwright/test`
|
|
Run: `npx -w @flashcard/frontend playwright install chromium`
|
|
|
|
- [ ] **Step 2: Create `playwright.config.ts`**
|
|
|
|
```ts
|
|
import { defineConfig } from '@playwright/test';
|
|
|
|
export default defineConfig({
|
|
testDir: '../../e2e',
|
|
webServer: [
|
|
{ command: 'npm run dev', cwd: '../..', port: 3000, reuseExistingServer: true, env: { DB_PATH: '../../data/e2e.db' } },
|
|
{ command: 'npm run dev:fe', cwd: '../..', port: 5173, reuseExistingServer: true },
|
|
],
|
|
use: { baseURL: 'http://localhost:5173' },
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 3: Create `e2e/smoke.spec.ts`**
|
|
|
|
```ts
|
|
import { test, expect } from '@playwright/test';
|
|
|
|
test('create lesson, add card, practice once', async ({ page }) => {
|
|
await page.goto('/admin');
|
|
await page.getByPlaceholder('Nieuwe wortel-les...').fill('E2E les');
|
|
await page.getByRole('button', { name: 'Toevoegen' }).click();
|
|
await page.getByText('E2E les').click();
|
|
await page.getByPlaceholder('Nieuwe vraag').fill('q1');
|
|
await page.getByPlaceholder('Antwoord').fill('a1');
|
|
await page.getByRole('button', { name: '+' }).click();
|
|
await page.getByRole('link', { name: /Start oefenen/ }).click();
|
|
await page.getByRole('button', { name: 'Start' }).click();
|
|
await page.getByRole('button', { name: 'Toon antwoord' }).click();
|
|
await page.getByRole('button', { name: 'Goed' }).click();
|
|
await expect(page.getByText(/Sessie klaar!/)).toBeVisible();
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 4: Add root script**
|
|
|
|
Add to root `package.json` scripts:
|
|
```json
|
|
"e2e": "playwright test --config packages/frontend/playwright.config.ts"
|
|
```
|
|
|
|
- [ ] **Step 5: Run**
|
|
|
|
Run: `npm run e2e`
|
|
Expected: smoke test passes.
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```bash
|
|
git add -A
|
|
git commit -m "test(e2e): playwright smoke for create→practice flow"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 24: README & developer docs
|
|
|
|
**Files:**
|
|
- Create: `README.md`
|
|
|
|
- [ ] **Step 1: Write `README.md`**
|
|
|
|
```markdown
|
|
# Flashcard
|
|
|
|
Single-user lokale flashcard webapp met hiërarchische lessen, spaced repetition (Leitner), Excel import/export en statistiek.
|
|
|
|
## Snelstart
|
|
|
|
```bash
|
|
npm install
|
|
npm run db:migrate
|
|
npm run db:seed # optioneel, voegt demo data toe
|
|
npm run dev # backend op :3000, frontend op :5173
|
|
```
|
|
|
|
Open http://localhost:5173.
|
|
|
|
## Build (productie)
|
|
|
|
```bash
|
|
npm run build
|
|
npm start # serveert frontend + backend op :3000
|
|
```
|
|
|
|
## Tests
|
|
|
|
```bash
|
|
npm test # unit (backend + frontend)
|
|
npm run e2e # playwright smoke
|
|
```
|
|
|
|
## Excel-formaat
|
|
|
|
Eén werkblad met header-rij. Kolommen:
|
|
- `question` (verplicht)
|
|
- `answer` (verplicht)
|
|
- `hint` (optioneel)
|
|
- `lesson_path` (optioneel, bv. `Spaans/Begroetingen`)
|
|
```
|
|
|
|
- [ ] **Step 2: Commit**
|
|
|
|
```bash
|
|
git add -A
|
|
git commit -m "docs: readme with quickstart"
|
|
```
|
|
|
|
---
|
|
|
|
## Spec coverage check
|
|
|
|
| Spec section | Implemented in task |
|
|
|---|---|
|
|
| 3.1 Lessenstructuur (CRUD, move, bidirectional, cascade) | 7 |
|
|
| 3.2 Flashcards CRUD + position | 8 |
|
|
| 3.3 Oefensessie incl. descendants, settings, flip/animations | 9, 10, 19 |
|
|
| 3.4 Leitner + intra-sessie reinsert | 5, 9 |
|
|
| 3.5 Statistieken per kaart/les/sessie/globaal | 11, 20 |
|
|
| 3.6 Admin + Excel import/export | 17, 18, 12 |
|
|
| 3.7 Sessie hervatten | 22 |
|
|
| 3.8 Daily streak | 11, 20 |
|
|
| Tech stack | 1-4, 14 |
|
|
| Foutafhandeling (Zod, ApiError, toasts) | 3 (errors), 15 (client) |
|
|
| Test-strategie (TDD core + smoke E2E) | 5, 7, 8, 9, 11, 12, 23 |
|
|
| Deployment scripts | 1, 13, 24 |
|
|
|
|
---
|
|
|
|
## Execution Handoff
|
|
|
|
**Plan complete and saved to `docs/superpowers/plans/2026-05-20-flashcard-app.md`. Two execution options:**
|
|
|
|
**1. Subagent-Driven (recommended)** - I dispatch a fresh subagent per task, review between tasks, fast iteration
|
|
|
|
**2. Inline Execution** - Execute tasks in this session using executing-plans, batch execution with checkpoints
|
|
|
|
**Which approach?**
|