diff --git a/packages/backend/src/app.ts b/packages/backend/src/app.ts index 2d5db9e..2405378 100644 --- a/packages/backend/src/app.ts +++ b/packages/backend/src/app.ts @@ -5,6 +5,7 @@ import { ApiError } from './lib/errors.js'; import { cardsRouter } from './routes/cards.js'; import { lessonsRouter } from './routes/lessons.js'; import { sessionsRouter } from './routes/sessions.js'; +import { statsRouter } from './routes/stats.js'; export function createApp(db: Db): Express { const app = express(); @@ -14,6 +15,7 @@ export function createApp(db: Db): Express { app.use('/api/lessons', lessonsRouter(db)); app.use('/api', cardsRouter(db)); app.use('/api/sessions', sessionsRouter(db)); + app.use('/api/stats', statsRouter(db)); app.use((err: unknown, _req: Request, res: Response, _next: NextFunction) => { if (err instanceof ZodError) { diff --git a/packages/backend/src/routes/stats.ts b/packages/backend/src/routes/stats.ts new file mode 100644 index 0000000..537b00c --- /dev/null +++ b/packages/backend/src/routes/stats.ts @@ -0,0 +1,23 @@ +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; +} diff --git a/packages/backend/src/services/stats.test.ts b/packages/backend/src/services/stats.test.ts new file mode 100644 index 0000000..99699c8 --- /dev/null +++ b/packages/backend/src/services/stats.test.ts @@ -0,0 +1,50 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { makeTestDb } from '../tests/dbHelper.js'; +import { createLesson } from './lessons.js'; +import { createCard } from './cards.js'; +import { startSession, recordAttempt, getNextItem, endSession } from './sessions.js'; +import { getCardStats, getLessonStats, getOverview } from './stats.js'; + +let env: ReturnType; +beforeEach(() => { env = makeTestDb(); }); + +describe('stats', () => { + it('computes per-card attempts and box', async () => { + const l = await createLesson(env.db, { name: 'L' }); + const c = await createCard(env.db, l.id, { question: 'q', answer: 'a' }); + const s = await startSession(env.db, { lessonId: l.id, shuffle: false }); + const item = await getNextItem(env.db, s.session.id); + await recordAttempt(env.db, s.session.id, { cardId: item!.cardId, direction: 'forward', result: 'correct' }); + await endSession(env.db, s.session.id); + const stats = await getCardStats(env.db, c.id); + expect(stats.attempts).toBe(1); + expect(stats.correct).toBe(1); + expect(stats.box.forward).toBe(2); + }); + + it('aggregates lesson stats with descendants', async () => { + const root = await createLesson(env.db, { name: 'R' }); + const child = await createLesson(env.db, { name: 'C', parentId: root.id }); + await createCard(env.db, child.id, { question: 'q', answer: 'a' }); + const s = await startSession(env.db, { lessonId: root.id, shuffle: false }); + const it = await getNextItem(env.db, s.session.id); + await recordAttempt(env.db, s.session.id, { cardId: it!.cardId, direction: 'forward', result: 'correct' }); + await endSession(env.db, s.session.id); + const ls = await getLessonStats(env.db, root.id); + expect(ls.totalCards).toBe(1); + expect(ls.sessions).toBe(1); + expect(ls.totalDurationSeconds).toBeGreaterThanOrEqual(0); + }); + + it('overview returns streak >= 1 after a session today', async () => { + const l = await createLesson(env.db, { name: 'L' }); + await createCard(env.db, l.id, { question: 'q', answer: 'a' }); + const s = await startSession(env.db, { lessonId: l.id }); + const it = await getNextItem(env.db, s.session.id); + await recordAttempt(env.db, s.session.id, { cardId: it!.cardId, direction: 'forward', result: 'correct' }); + await endSession(env.db, s.session.id); + const ov = await getOverview(env.db); + expect(ov.totalSessions).toBe(1); + expect(ov.streakDays).toBeGreaterThanOrEqual(1); + }); +}); diff --git a/packages/backend/src/services/stats.ts b/packages/backend/src/services/stats.ts new file mode 100644 index 0000000..e5e4081 --- /dev/null +++ b/packages/backend/src/services/stats.ts @@ -0,0 +1,166 @@ +import { and, desc, eq, inArray, sql } from 'drizzle-orm'; +import type { Db } from '../db/client.js'; +import { attempts, cardProgress, cards, lessons, sessions } from '../db/schema.js'; +import { ApiError } from '../lib/errors.js'; +import { getDescendantLessonIds } from './lessons.js'; + +const MIN_ATTEMPTS_FOR_SCORE = 3; + +export interface CardStats { + cardId: number; + attempts: number; + correct: number; + incorrect: number; + box: { forward: number; backward: number | null }; + lastShownAt: number | null; + nextDueAt: number; + history: { shownAt: number; result: 'correct' | 'incorrect'; direction: 'forward' | 'backward' }[]; +} + +export async function getCardStats(db: Db, cardId: number): Promise { + const card = db.select().from(cards).where(eq(cards.id, cardId)).get(); + if (!card) throw ApiError.notFound('Card'); + const prog = db.select().from(cardProgress).where(eq(cardProgress.cardId, cardId)).all(); + const history = db.select({ + shownAt: attempts.shownAt, result: attempts.result, direction: attempts.direction, + }).from(attempts).where(eq(attempts.cardId, cardId)).orderBy(desc(attempts.shownAt)).all(); + + const forward = prog.find((p) => p.direction === 'forward'); + const backward = prog.find((p) => p.direction === 'backward'); + const correct = history.filter((h) => h.result === 'correct').length; + return { + cardId, + attempts: history.length, + correct, + incorrect: history.length - correct, + box: { forward: forward?.box ?? 1, backward: backward?.box ?? null }, + lastShownAt: forward?.lastShownAt ?? null, + nextDueAt: forward?.nextDueAt ?? 0, + history, + }; +} + +export interface LessonStats { + lessonId: number; + totalCards: number; + mastered: number; + score: number; + sessions: number; + totalDurationSeconds: number; + attempts: number; + correct: number; + incorrect: number; +} + +export async function getLessonStats(db: Db, lessonId: number): Promise { + const lesson = db.select().from(lessons).where(eq(lessons.id, lessonId)).get(); + if (!lesson) throw ApiError.notFound('Lesson'); + const ids = await getDescendantLessonIds(db, lessonId); + const cardRows = db.select({ id: cards.id }).from(cards).where(inArray(cards.lessonId, ids)).all(); + const cardIds = cardRows.map((c) => c.id); + + const totalCards = cardIds.length; + let mastered = 0; + let attemptsTotal = 0; + let correctTotal = 0; + let score = 0; + let countedForScore = 0; + + if (cardIds.length > 0) { + const prog = db.select().from(cardProgress).where(inArray(cardProgress.cardId, cardIds)).all(); + const att = db.select().from(attempts).where(inArray(attempts.cardId, cardIds)).all(); + attemptsTotal = att.length; + correctTotal = att.filter((a) => a.result === 'correct').length; + const byCard = new Map(); + for (const p of prog) { + if (!byCard.has(p.cardId)) byCard.set(p.cardId, []); + byCard.get(p.cardId)!.push(p); + } + for (const id of cardIds) { + const ps = byCard.get(id) ?? []; + if (ps.some((p) => p.box >= 4)) mastered += 1; + const total = ps.reduce((s, p) => s + p.correctCount + p.incorrectCount, 0); + const correct = ps.reduce((s, p) => s + p.correctCount, 0); + if (total >= MIN_ATTEMPTS_FOR_SCORE) { + score += correct / total; + countedForScore += 1; + } + } + score = countedForScore === 0 ? 0 : score / countedForScore; + } + + const sessRows = db.select({ + id: sessions.id, duration: sessions.durationSeconds, + }).from(sessions).where(and(inArray(sessions.lessonId, ids), eq(sessions.status, 'completed'))).all(); + const totalDurationSeconds = sessRows.reduce((s, r) => s + (r.duration ?? 0), 0); + + return { + lessonId, totalCards, mastered, score, + sessions: sessRows.length, totalDurationSeconds, + attempts: attemptsTotal, correct: correctTotal, incorrect: attemptsTotal - correctTotal, + }; +} + +export interface Overview { + totalSessions: number; + totalDurationSeconds: number; + totalAttempts: number; + streakDays: number; + recentSessions: { id: number; lessonId: number; startedAt: number; durationSeconds: number | null; cardsShown: number; cardsCorrect: number }[]; +} + +function dayKeyUTC(unixSec: number): string { + const d = new Date(unixSec * 1000); + return `${d.getUTCFullYear()}-${d.getUTCMonth()}-${d.getUTCDate()}`; +} + +export async function getOverview(db: Db): Promise { + const sessRows = db.select().from(sessions).where(eq(sessions.status, 'completed')).all(); + const totalDurationSeconds = sessRows.reduce((s, r) => s + (r.durationSeconds ?? 0), 0); + const totalAttempts = db.select({ c: sql`count(*)`.as('c') }).from(attempts).get()?.c ?? 0; + + const days = new Set(sessRows.map((s) => dayKeyUTC(s.startedAt))); + let streak = 0; + const cursor = new Date(); + for (;;) { + const k = dayKeyUTC(Math.floor(cursor.getTime() / 1000)); + if (days.has(k)) { + streak += 1; + cursor.setUTCDate(cursor.getUTCDate() - 1); + } else break; + } + + const recent = db.select({ + id: sessions.id, lessonId: sessions.lessonId, startedAt: sessions.startedAt, + durationSeconds: sessions.durationSeconds, cardsShown: sessions.cardsShown, cardsCorrect: sessions.cardsCorrect, + }).from(sessions).where(eq(sessions.status, 'completed')).orderBy(desc(sessions.startedAt)).limit(10).all(); + + return { + totalSessions: sessRows.length, + totalDurationSeconds, + totalAttempts: Number(totalAttempts), + streakDays: streak, + recentSessions: recent.map((r) => ({ ...r, durationSeconds: r.durationSeconds ?? null })), + }; +} + +export interface HeatmapPoint { day: string; sessions: number; attempts: number; } +export async function getHeatmap(db: Db, weeks: number): Promise { + const since = Math.floor(Date.now() / 1000) - weeks * 7 * 24 * 60 * 60; + const sessRows = db.select({ startedAt: sessions.startedAt }).from(sessions) + .where(and(eq(sessions.status, 'completed'), sql`${sessions.startedAt} >= ${since}`)).all(); + const attRows = db.select({ shownAt: attempts.shownAt }).from(attempts) + .where(sql`${attempts.shownAt} >= ${since}`).all(); + const map = new Map(); + for (const s of sessRows) { + const k = dayKeyUTC(s.startedAt); + const m = map.get(k) ?? { sessions: 0, attempts: 0 }; + m.sessions += 1; map.set(k, m); + } + for (const a of attRows) { + const k = dayKeyUTC(a.shownAt); + const m = map.get(k) ?? { sessions: 0, attempts: 0 }; + m.attempts += 1; map.set(k, m); + } + return Array.from(map.entries()).map(([day, v]) => ({ day, ...v })); +}