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 })); }