feat(backend): stats service and routes
This commit is contained in:
@@ -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) {
|
||||
|
||||
23
packages/backend/src/routes/stats.ts
Normal file
23
packages/backend/src/routes/stats.ts
Normal file
@@ -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;
|
||||
}
|
||||
50
packages/backend/src/services/stats.test.ts
Normal file
50
packages/backend/src/services/stats.test.ts
Normal file
@@ -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<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);
|
||||
});
|
||||
});
|
||||
166
packages/backend/src/services/stats.ts
Normal file
166
packages/backend/src/services/stats.ts
Normal file
@@ -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<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;
|
||||
score: number;
|
||||
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);
|
||||
|
||||
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<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;
|
||||
|
||||
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<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 }));
|
||||
}
|
||||
Reference in New Issue
Block a user