diff --git a/packages/backend/src/services/stats.test.ts b/packages/backend/src/services/stats.test.ts index ac6652d..ac68298 100644 --- a/packages/backend/src/services/stats.test.ts +++ b/packages/backend/src/services/stats.test.ts @@ -1,9 +1,10 @@ import { describe, it, expect, beforeEach } from 'vitest'; -import { makeTestDb, createUserDirect } from '../tests/dbHelper.js'; +import { makeTestDb, createUserDirect, createLessonOwnedBy } 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'; +import { getCardStats, getLessonStats, getOverview, getLessonsProgress, getDueOverview } from './stats.js'; +import { cardProgress } from '../db/schema.js'; import type { UserRow } from '../db/schema.js'; let env: ReturnType; @@ -53,3 +54,67 @@ describe('stats', () => { expect(ov.streakDays).toBeGreaterThanOrEqual(1); }); }); + +describe('lessonsProgress', () => { + it('returns root lessons of the user with mastered + total counts', async () => { + const u = await createUserDirect(env.db, { email: 'u@example.com' }); + const root = await createLesson(env.db, u.id, { name: 'Root' }); + const child = await createLesson(env.db, u.id, { name: 'Child', parentId: root.id }); + const c1 = await createCard(env.db, u.id, root.id, { question: 'q1', answer: 'a' }); + await createCard(env.db, u.id, child.id, { question: 'q2', answer: 'a' }); + + env.db.insert(cardProgress).values({ + cardId: c1.id, direction: 'forward', userId: u.id, box: 4, nextDueAt: 0, + }).run(); + + const r = await getLessonsProgress(env.db, u.id); + expect(r.rows).toHaveLength(1); + expect(r.rows[0]!.lessonId).toBe(root.id); + expect(r.rows[0]!.totalCards).toBe(2); + expect(r.rows[0]!.masteredCards).toBe(1); + }); + + it('excludes subscribed roots (only owned)', async () => { + const o = await createUserDirect(env.db, { email: 'o@example.com' }); + const u = await createUserDirect(env.db, { email: 'u@example.com' }); + await createLesson(env.db, u.id, { name: 'Own' }); + await createLessonOwnedBy(env.db, o.id, { name: 'Theirs', visibility: 'shared' }); + const r = await getLessonsProgress(env.db, u.id); + expect(r.rows.map((x) => x.name)).toEqual(['Own']); + }); +}); + +describe('dueOverview', () => { + it('counts cards into overdue/today/tomorrow/thisWeek buckets', async () => { + const u = await createUserDirect(env.db, { email: 'u@example.com' }); + const l = await createLesson(env.db, u.id, { name: 'L' }); + const c1 = await createCard(env.db, u.id, l.id, { question: 'q1', answer: 'a' }); + const c2 = await createCard(env.db, u.id, l.id, { question: 'q2', answer: 'a' }); + const c3 = await createCard(env.db, u.id, l.id, { question: 'q3', answer: 'a' }); + + const now = Math.floor(Date.now() / 1000); + const day = 24 * 60 * 60; + env.db.insert(cardProgress).values([ + { cardId: c1.id, direction: 'forward', userId: u.id, box: 1, nextDueAt: now - 100 }, + { cardId: c2.id, direction: 'forward', userId: u.id, box: 1, nextDueAt: now + 3600 }, + { cardId: c3.id, direction: 'forward', userId: u.id, box: 1, nextDueAt: now + day + 3600 }, + ]).run(); + + const r = await getDueOverview(env.db, u.id); + expect(r.overdue).toBe(1); + expect(r.today).toBeGreaterThanOrEqual(1); + expect(r.thisWeek).toBeGreaterThanOrEqual(3); + }); + + it('ignores progress on cards user cannot read', async () => { + const o = await createUserDirect(env.db, { email: 'o@example.com' }); + const u = await createUserDirect(env.db, { email: 'u@example.com' }); + const l = await createLessonOwnedBy(env.db, o.id, { name: 'Theirs', visibility: 'private' }); + const card = await createCard(env.db, o.id, l.id, { question: 'q', answer: 'a' }); + env.db.insert(cardProgress).values({ + cardId: card.id, direction: 'forward', userId: u.id, box: 1, nextDueAt: 0, + }).run(); + const r = await getDueOverview(env.db, u.id); + expect(r.overdue).toBe(0); + }); +}); diff --git a/packages/backend/src/services/stats.ts b/packages/backend/src/services/stats.ts index 9c2d15b..9f64e95 100644 --- a/packages/backend/src/services/stats.ts +++ b/packages/backend/src/services/stats.ts @@ -1,6 +1,6 @@ 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 { attempts, cardProgress, cards, lessons, lessonSubscriptions, sessions } from '../db/schema.js'; import { ApiError } from '../lib/errors.js'; import { getDescendantLessonIds } from './lessons.js'; import { canReadLesson } from './permissions.js'; @@ -198,3 +198,125 @@ export async function getHeatmap(db: Db, userId: number, weeks: number): Promise } return Array.from(map.entries()).map(([day, v]) => ({ day, ...v })); } + +export interface LessonsProgressRow { + lessonId: number; + name: string; + totalCards: number; + masteredCards: number; + scorePct: number; + lastSessionAt: number | null; +} +export interface LessonsProgressResult { rows: LessonsProgressRow[]; } + +export async function getLessonsProgress(db: Db, userId: number): Promise { + const ownLessons = db.select().from(lessons).where(eq(lessons.ownerId, userId)).all(); + const ownIds = new Set(ownLessons.map((l) => l.id)); + const roots = ownLessons.filter((l) => l.parentId === null || !ownIds.has(l.parentId)); + + const rows: LessonsProgressRow[] = []; + for (const root of roots) { + const ids = await getDescendantLessonIds(db, root.id); + 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 masteredCards = 0; + let scoreSum = 0; + let scoreCount = 0; + if (cardIds.length > 0) { + const prog = db.select().from(cardProgress).where(and( + inArray(cardProgress.cardId, cardIds), + eq(cardProgress.userId, userId), + )).all(); + 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)) masteredCards += 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 >= 3) { scoreSum += correct / total; scoreCount += 1; } + } + } + const scorePct = scoreCount === 0 ? 0 : Math.round((scoreSum / scoreCount) * 100); + + const lastSess = db.select({ startedAt: sessions.startedAt }).from(sessions) + .where(and( + inArray(sessions.lessonId, ids), + eq(sessions.userId, userId), + eq(sessions.status, 'completed'), + )) + .orderBy(desc(sessions.startedAt)).limit(1).get(); + + rows.push({ + lessonId: root.id, name: root.name, totalCards, masteredCards, scorePct, + lastSessionAt: lastSess?.startedAt ?? null, + }); + } + + rows.sort((a, b) => b.scorePct - a.scorePct || a.name.localeCompare(b.name)); + return { rows }; +} + +export interface DueOverview { + overdue: number; + today: number; + tomorrow: number; + thisWeek: number; +} + +export async function getDueOverview(db: Db, userId: number): Promise { + const ownerLessons = db.select({ id: lessons.id }).from(lessons).where(eq(lessons.ownerId, userId)).all(); + const subRoots = db.select({ id: lessonSubscriptions.lessonId }).from(lessonSubscriptions) + .where(eq(lessonSubscriptions.userId, userId)).all(); + const curatedRoots = db.select({ id: lessons.id }).from(lessons) + .where(and(eq(lessons.visibility, 'shared'), eq(lessons.isCurated, true))).all(); + + const allLessons = db.select({ id: lessons.id, parentId: lessons.parentId }).from(lessons).all(); + const byParent = new Map(); + for (const l of allLessons) { + const k = l.parentId ?? null; + if (!byParent.has(k)) byParent.set(k, []); + byParent.get(k)!.push(l); + } + const readableIds = new Set(); + for (const l of ownerLessons) readableIds.add(l.id); + const stack: number[] = []; + for (const sr of subRoots) stack.push(sr.id); + for (const cr of curatedRoots) stack.push(cr.id); + while (stack.length) { + const cur = stack.pop()!; + if (readableIds.has(cur)) continue; + readableIds.add(cur); + for (const c of byParent.get(cur) ?? []) stack.push(c.id); + } + + if (readableIds.size === 0) return { overdue: 0, today: 0, tomorrow: 0, thisWeek: 0 }; + + const cardRows = db.select({ id: cards.id }).from(cards) + .where(inArray(cards.lessonId, Array.from(readableIds))).all(); + const cardIds = cardRows.map((r) => r.id); + if (cardIds.length === 0) return { overdue: 0, today: 0, tomorrow: 0, thisWeek: 0 }; + + const progress = db.select({ nextDueAt: cardProgress.nextDueAt }).from(cardProgress) + .where(and(eq(cardProgress.userId, userId), inArray(cardProgress.cardId, cardIds))) + .all(); + + const now = Math.floor(Date.now() / 1000); + const dayInSec = 24 * 60 * 60; + const endOfToday = now + dayInSec; + const endOfTomorrow = now + 2 * dayInSec; + const endOfWeek = now + 7 * dayInSec; + + let overdue = 0, today = 0, tomorrow = 0, thisWeek = 0; + for (const p of progress) { + if (p.nextDueAt < now) { overdue += 1; thisWeek += 1; continue; } + if (p.nextDueAt < endOfToday) { today += 1; thisWeek += 1; continue; } + if (p.nextDueAt < endOfTomorrow) { tomorrow += 1; thisWeek += 1; continue; } + if (p.nextDueAt < endOfWeek) { thisWeek += 1; } + } + return { overdue, today, tomorrow, thisWeek }; +}