feat(stats): lessons-progress and due-overview services
This commit is contained in:
@@ -1,9 +1,10 @@
|
|||||||
import { describe, it, expect, beforeEach } from 'vitest';
|
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 { createLesson } from './lessons.js';
|
||||||
import { createCard } from './cards.js';
|
import { createCard } from './cards.js';
|
||||||
import { startSession, recordAttempt, getNextItem, endSession } from './sessions.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';
|
import type { UserRow } from '../db/schema.js';
|
||||||
|
|
||||||
let env: ReturnType<typeof makeTestDb>;
|
let env: ReturnType<typeof makeTestDb>;
|
||||||
@@ -53,3 +54,67 @@ describe('stats', () => {
|
|||||||
expect(ov.streakDays).toBeGreaterThanOrEqual(1);
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { and, desc, eq, inArray, sql } from 'drizzle-orm';
|
import { and, desc, eq, inArray, sql } from 'drizzle-orm';
|
||||||
import type { Db } from '../db/client.js';
|
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 { ApiError } from '../lib/errors.js';
|
||||||
import { getDescendantLessonIds } from './lessons.js';
|
import { getDescendantLessonIds } from './lessons.js';
|
||||||
import { canReadLesson } from './permissions.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 }));
|
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 };
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user