feat(stats): lessons-progress and due-overview services

This commit is contained in:
2026-05-21 06:58:30 +02:00
parent f9912a7a8d
commit fb25f48f04
2 changed files with 190 additions and 3 deletions

View File

@@ -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<typeof makeTestDb>;
@@ -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);
});
});

View File

@@ -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<LessonsProgressResult> {
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<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)) 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<DueOverview> {
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<number | null, typeof allLessons>();
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<number>();
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 };
}