feat(stats): per-user filtering across all aggregations
This commit is contained in:
@@ -1,49 +1,54 @@
|
|||||||
import { describe, it, expect, beforeEach } from 'vitest';
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
import { makeTestDb } from '../tests/dbHelper.js';
|
import { makeTestDb, createUserDirect } 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 } from './stats.js';
|
||||||
|
import type { UserRow } from '../db/schema.js';
|
||||||
|
|
||||||
let env: ReturnType<typeof makeTestDb>;
|
let env: ReturnType<typeof makeTestDb>;
|
||||||
beforeEach(() => { env = makeTestDb(); });
|
let owner: UserRow;
|
||||||
|
beforeEach(async () => {
|
||||||
|
env = makeTestDb();
|
||||||
|
owner = await createUserDirect(env.db, { email: 'owner@example.com' });
|
||||||
|
});
|
||||||
|
|
||||||
describe('stats', () => {
|
describe('stats', () => {
|
||||||
it('computes per-card attempts and box', async () => {
|
it('computes per-card attempts and box', async () => {
|
||||||
const l = await createLesson(env.db, { name: 'L' });
|
const l = await createLesson(env.db, owner.id, { name: 'L' });
|
||||||
const c = await createCard(env.db, l.id, { question: 'q', answer: 'a' });
|
const c = await createCard(env.db, owner.id, l.id, { question: 'q', answer: 'a' });
|
||||||
const s = await startSession(env.db, { lessonId: l.id, shuffle: false });
|
const s = await startSession(env.db, owner.id, { lessonId: l.id, shuffle: false });
|
||||||
const item = await getNextItem(env.db, s.session.id);
|
const item = await getNextItem(env.db, owner.id, s.session.id);
|
||||||
await recordAttempt(env.db, s.session.id, { cardId: item!.cardId, direction: 'forward', result: 'correct' });
|
await recordAttempt(env.db, owner.id, s.session.id, { cardId: item!.cardId, direction: 'forward', result: 'correct' });
|
||||||
await endSession(env.db, s.session.id);
|
await endSession(env.db, owner.id, s.session.id);
|
||||||
const stats = await getCardStats(env.db, c.id);
|
const stats = await getCardStats(env.db, owner.id, c.id);
|
||||||
expect(stats.attempts).toBe(1);
|
expect(stats.attempts).toBe(1);
|
||||||
expect(stats.correct).toBe(1);
|
expect(stats.correct).toBe(1);
|
||||||
expect(stats.box.forward).toBe(2);
|
expect(stats.box.forward).toBe(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('aggregates lesson stats with descendants', async () => {
|
it('aggregates lesson stats with descendants', async () => {
|
||||||
const root = await createLesson(env.db, { name: 'R' });
|
const root = await createLesson(env.db, owner.id, { name: 'R' });
|
||||||
const child = await createLesson(env.db, { name: 'C', parentId: root.id });
|
const child = await createLesson(env.db, owner.id, { name: 'C', parentId: root.id });
|
||||||
await createCard(env.db, child.id, { question: 'q', answer: 'a' });
|
await createCard(env.db, owner.id, child.id, { question: 'q', answer: 'a' });
|
||||||
const s = await startSession(env.db, { lessonId: root.id, shuffle: false });
|
const s = await startSession(env.db, owner.id, { lessonId: root.id, shuffle: false });
|
||||||
const it = await getNextItem(env.db, s.session.id);
|
const it = await getNextItem(env.db, owner.id, s.session.id);
|
||||||
await recordAttempt(env.db, s.session.id, { cardId: it!.cardId, direction: 'forward', result: 'correct' });
|
await recordAttempt(env.db, owner.id, s.session.id, { cardId: it!.cardId, direction: 'forward', result: 'correct' });
|
||||||
await endSession(env.db, s.session.id);
|
await endSession(env.db, owner.id, s.session.id);
|
||||||
const ls = await getLessonStats(env.db, root.id);
|
const ls = await getLessonStats(env.db, owner.id, root.id);
|
||||||
expect(ls.totalCards).toBe(1);
|
expect(ls.totalCards).toBe(1);
|
||||||
expect(ls.sessions).toBe(1);
|
expect(ls.sessions).toBe(1);
|
||||||
expect(ls.totalDurationSeconds).toBeGreaterThanOrEqual(0);
|
expect(ls.totalDurationSeconds).toBeGreaterThanOrEqual(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('overview returns streak >= 1 after a session today', async () => {
|
it('overview returns streak >= 1 after a session today', async () => {
|
||||||
const l = await createLesson(env.db, { name: 'L' });
|
const l = await createLesson(env.db, owner.id, { name: 'L' });
|
||||||
await createCard(env.db, l.id, { question: 'q', answer: 'a' });
|
await createCard(env.db, owner.id, l.id, { question: 'q', answer: 'a' });
|
||||||
const s = await startSession(env.db, { lessonId: l.id });
|
const s = await startSession(env.db, owner.id, { lessonId: l.id });
|
||||||
const it = await getNextItem(env.db, s.session.id);
|
const it = await getNextItem(env.db, owner.id, s.session.id);
|
||||||
await recordAttempt(env.db, s.session.id, { cardId: it!.cardId, direction: 'forward', result: 'correct' });
|
await recordAttempt(env.db, owner.id, s.session.id, { cardId: it!.cardId, direction: 'forward', result: 'correct' });
|
||||||
await endSession(env.db, s.session.id);
|
await endSession(env.db, owner.id, s.session.id);
|
||||||
const ov = await getOverview(env.db);
|
const ov = await getOverview(env.db, owner.id);
|
||||||
expect(ov.totalSessions).toBe(1);
|
expect(ov.totalSessions).toBe(1);
|
||||||
expect(ov.streakDays).toBeGreaterThanOrEqual(1);
|
expect(ov.streakDays).toBeGreaterThanOrEqual(1);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { Db } from '../db/client.js';
|
|||||||
import { attempts, cardProgress, cards, lessons, sessions } from '../db/schema.js';
|
import { attempts, cardProgress, cards, lessons, 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';
|
||||||
|
|
||||||
const MIN_ATTEMPTS_FOR_SCORE = 3;
|
const MIN_ATTEMPTS_FOR_SCORE = 3;
|
||||||
|
|
||||||
@@ -17,26 +18,35 @@ export interface CardStats {
|
|||||||
history: { shownAt: number; result: 'correct' | 'incorrect'; direction: 'forward' | 'backward' }[];
|
history: { shownAt: number; result: 'correct' | 'incorrect'; direction: 'forward' | 'backward' }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getCardStats(db: Db, cardId: number): Promise<CardStats> {
|
export async function getCardStats(db: Db, userId: number, cardId: number): Promise<CardStats> {
|
||||||
const card = db.select().from(cards).where(eq(cards.id, cardId)).get();
|
const card = db.select().from(cards).where(eq(cards.id, cardId)).get();
|
||||||
if (!card) throw ApiError.notFound('Card');
|
if (!card) throw ApiError.notFound('Card');
|
||||||
const prog = db.select().from(cardProgress).where(eq(cardProgress.cardId, cardId)).all();
|
if (!(await canReadLesson(db, userId, card.lessonId))) {
|
||||||
const history = db.select({
|
throw new ApiError(403, 'FORBIDDEN_LESSON', 'Cannot read this card');
|
||||||
shownAt: attempts.shownAt, result: attempts.result, direction: attempts.direction,
|
}
|
||||||
}).from(attempts).where(eq(attempts.cardId, cardId)).orderBy(desc(attempts.shownAt)).all();
|
const prog = db.select().from(cardProgress)
|
||||||
|
.where(and(eq(cardProgress.cardId, cardId), eq(cardProgress.userId, userId))).all();
|
||||||
|
const historyJoined = db.select({
|
||||||
|
shownAt: attempts.shownAt,
|
||||||
|
result: attempts.result,
|
||||||
|
direction: attempts.direction,
|
||||||
|
}).from(attempts)
|
||||||
|
.innerJoin(sessions, eq(sessions.id, attempts.sessionId))
|
||||||
|
.where(and(eq(attempts.cardId, cardId), eq(sessions.userId, userId)))
|
||||||
|
.orderBy(desc(attempts.shownAt)).all();
|
||||||
|
|
||||||
const forward = prog.find((p) => p.direction === 'forward');
|
const forward = prog.find((p) => p.direction === 'forward');
|
||||||
const backward = prog.find((p) => p.direction === 'backward');
|
const backward = prog.find((p) => p.direction === 'backward');
|
||||||
const correct = history.filter((h) => h.result === 'correct').length;
|
const correct = historyJoined.filter((h) => h.result === 'correct').length;
|
||||||
return {
|
return {
|
||||||
cardId,
|
cardId,
|
||||||
attempts: history.length,
|
attempts: historyJoined.length,
|
||||||
correct,
|
correct,
|
||||||
incorrect: history.length - correct,
|
incorrect: historyJoined.length - correct,
|
||||||
box: { forward: forward?.box ?? 1, backward: backward?.box ?? null },
|
box: { forward: forward?.box ?? 1, backward: backward?.box ?? null },
|
||||||
lastShownAt: forward?.lastShownAt ?? null,
|
lastShownAt: forward?.lastShownAt ?? null,
|
||||||
nextDueAt: forward?.nextDueAt ?? 0,
|
nextDueAt: forward?.nextDueAt ?? 0,
|
||||||
history,
|
history: historyJoined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,14 +62,17 @@ export interface LessonStats {
|
|||||||
incorrect: number;
|
incorrect: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getLessonStats(db: Db, lessonId: number): Promise<LessonStats> {
|
export async function getLessonStats(db: Db, userId: number, lessonId: number): Promise<LessonStats> {
|
||||||
const lesson = db.select().from(lessons).where(eq(lessons.id, lessonId)).get();
|
const lesson = db.select().from(lessons).where(eq(lessons.id, lessonId)).get();
|
||||||
if (!lesson) throw ApiError.notFound('Lesson');
|
if (!lesson) throw ApiError.notFound('Lesson');
|
||||||
|
if (!(await canReadLesson(db, userId, lessonId))) {
|
||||||
|
throw new ApiError(403, 'FORBIDDEN_LESSON', 'Cannot read this lesson');
|
||||||
|
}
|
||||||
const ids = await getDescendantLessonIds(db, lessonId);
|
const ids = await getDescendantLessonIds(db, lessonId);
|
||||||
const cardRows = db.select({ id: cards.id }).from(cards).where(inArray(cards.lessonId, ids)).all();
|
const cardRows = db.select({ id: cards.id }).from(cards).where(inArray(cards.lessonId, ids)).all();
|
||||||
const cardIds = cardRows.map((c) => c.id);
|
const cardIds = cardRows.map((c) => c.id);
|
||||||
|
|
||||||
const totalCards = cardIds.length;
|
let totalCards = cardIds.length;
|
||||||
let mastered = 0;
|
let mastered = 0;
|
||||||
let attemptsTotal = 0;
|
let attemptsTotal = 0;
|
||||||
let correctTotal = 0;
|
let correctTotal = 0;
|
||||||
@@ -67,8 +80,16 @@ export async function getLessonStats(db: Db, lessonId: number): Promise<LessonSt
|
|||||||
let countedForScore = 0;
|
let countedForScore = 0;
|
||||||
|
|
||||||
if (cardIds.length > 0) {
|
if (cardIds.length > 0) {
|
||||||
const prog = db.select().from(cardProgress).where(inArray(cardProgress.cardId, cardIds)).all();
|
const prog = db.select().from(cardProgress).where(and(
|
||||||
const att = db.select().from(attempts).where(inArray(attempts.cardId, cardIds)).all();
|
inArray(cardProgress.cardId, cardIds),
|
||||||
|
eq(cardProgress.userId, userId),
|
||||||
|
)).all();
|
||||||
|
const att = db.select({ result: attempts.result }).from(attempts)
|
||||||
|
.innerJoin(sessions, eq(sessions.id, attempts.sessionId))
|
||||||
|
.where(and(
|
||||||
|
inArray(attempts.cardId, cardIds),
|
||||||
|
eq(sessions.userId, userId),
|
||||||
|
)).all();
|
||||||
attemptsTotal = att.length;
|
attemptsTotal = att.length;
|
||||||
correctTotal = att.filter((a) => a.result === 'correct').length;
|
correctTotal = att.filter((a) => a.result === 'correct').length;
|
||||||
const byCard = new Map<number, typeof prog[number][]>();
|
const byCard = new Map<number, typeof prog[number][]>();
|
||||||
@@ -91,7 +112,11 @@ export async function getLessonStats(db: Db, lessonId: number): Promise<LessonSt
|
|||||||
|
|
||||||
const sessRows = db.select({
|
const sessRows = db.select({
|
||||||
id: sessions.id, duration: sessions.durationSeconds,
|
id: sessions.id, duration: sessions.durationSeconds,
|
||||||
}).from(sessions).where(and(inArray(sessions.lessonId, ids), eq(sessions.status, 'completed'))).all();
|
}).from(sessions).where(and(
|
||||||
|
inArray(sessions.lessonId, ids),
|
||||||
|
eq(sessions.status, 'completed'),
|
||||||
|
eq(sessions.userId, userId),
|
||||||
|
)).all();
|
||||||
const totalDurationSeconds = sessRows.reduce((s, r) => s + (r.duration ?? 0), 0);
|
const totalDurationSeconds = sessRows.reduce((s, r) => s + (r.duration ?? 0), 0);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -114,26 +139,30 @@ function dayKeyUTC(unixSec: number): string {
|
|||||||
return `${d.getUTCFullYear()}-${d.getUTCMonth()}-${d.getUTCDate()}`;
|
return `${d.getUTCFullYear()}-${d.getUTCMonth()}-${d.getUTCDate()}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getOverview(db: Db): Promise<Overview> {
|
export async function getOverview(db: Db, userId: number): Promise<Overview> {
|
||||||
const sessRows = db.select().from(sessions).where(eq(sessions.status, 'completed')).all();
|
const sessRows = db.select().from(sessions)
|
||||||
|
.where(and(eq(sessions.status, 'completed'), eq(sessions.userId, userId))).all();
|
||||||
const totalDurationSeconds = sessRows.reduce((s, r) => s + (r.durationSeconds ?? 0), 0);
|
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 totalAttempts = db.select({ c: sql<number>`count(*)`.as('c') })
|
||||||
|
.from(attempts)
|
||||||
|
.innerJoin(sessions, eq(sessions.id, attempts.sessionId))
|
||||||
|
.where(eq(sessions.userId, userId)).get()?.c ?? 0;
|
||||||
|
|
||||||
const days = new Set(sessRows.map((s) => dayKeyUTC(s.startedAt)));
|
const days = new Set(sessRows.map((s) => dayKeyUTC(s.startedAt)));
|
||||||
let streak = 0;
|
let streak = 0;
|
||||||
const cursor = new Date();
|
const cursor = new Date();
|
||||||
for (;;) {
|
for (;;) {
|
||||||
const k = dayKeyUTC(Math.floor(cursor.getTime() / 1000));
|
const k = dayKeyUTC(Math.floor(cursor.getTime() / 1000));
|
||||||
if (days.has(k)) {
|
if (days.has(k)) { streak += 1; cursor.setUTCDate(cursor.getUTCDate() - 1); }
|
||||||
streak += 1;
|
else break;
|
||||||
cursor.setUTCDate(cursor.getUTCDate() - 1);
|
|
||||||
} else break;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const recent = db.select({
|
const recent = db.select({
|
||||||
id: sessions.id, lessonId: sessions.lessonId, startedAt: sessions.startedAt,
|
id: sessions.id, lessonId: sessions.lessonId, startedAt: sessions.startedAt,
|
||||||
durationSeconds: sessions.durationSeconds, cardsShown: sessions.cardsShown, cardsCorrect: sessions.cardsCorrect,
|
durationSeconds: sessions.durationSeconds, cardsShown: sessions.cardsShown, cardsCorrect: sessions.cardsCorrect,
|
||||||
}).from(sessions).where(eq(sessions.status, 'completed')).orderBy(desc(sessions.startedAt)).limit(10).all();
|
}).from(sessions)
|
||||||
|
.where(and(eq(sessions.status, 'completed'), eq(sessions.userId, userId)))
|
||||||
|
.orderBy(desc(sessions.startedAt)).limit(10).all();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
totalSessions: sessRows.length,
|
totalSessions: sessRows.length,
|
||||||
@@ -145,12 +174,17 @@ export async function getOverview(db: Db): Promise<Overview> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface HeatmapPoint { day: string; sessions: number; attempts: number; }
|
export interface HeatmapPoint { day: string; sessions: number; attempts: number; }
|
||||||
export async function getHeatmap(db: Db, weeks: number): Promise<HeatmapPoint[]> {
|
export async function getHeatmap(db: Db, userId: number, weeks: number): Promise<HeatmapPoint[]> {
|
||||||
const since = Math.floor(Date.now() / 1000) - weeks * 7 * 24 * 60 * 60;
|
const since = Math.floor(Date.now() / 1000) - weeks * 7 * 24 * 60 * 60;
|
||||||
const sessRows = db.select({ startedAt: sessions.startedAt }).from(sessions)
|
const sessRows = db.select({ startedAt: sessions.startedAt }).from(sessions)
|
||||||
.where(and(eq(sessions.status, 'completed'), sql`${sessions.startedAt} >= ${since}`)).all();
|
.where(and(
|
||||||
|
eq(sessions.status, 'completed'),
|
||||||
|
eq(sessions.userId, userId),
|
||||||
|
sql`${sessions.startedAt} >= ${since}`,
|
||||||
|
)).all();
|
||||||
const attRows = db.select({ shownAt: attempts.shownAt }).from(attempts)
|
const attRows = db.select({ shownAt: attempts.shownAt }).from(attempts)
|
||||||
.where(sql`${attempts.shownAt} >= ${since}`).all();
|
.innerJoin(sessions, eq(sessions.id, attempts.sessionId))
|
||||||
|
.where(and(eq(sessions.userId, userId), sql`${attempts.shownAt} >= ${since}`)).all();
|
||||||
const map = new Map<string, { sessions: number; attempts: number }>();
|
const map = new Map<string, { sessions: number; attempts: number }>();
|
||||||
for (const s of sessRows) {
|
for (const s of sessRows) {
|
||||||
const k = dayKeyUTC(s.startedAt);
|
const k = dayKeyUTC(s.startedAt);
|
||||||
|
|||||||
Reference in New Issue
Block a user