feat(backend): session engine with Leitner integration

This commit is contained in:
2026-05-20 20:56:32 +02:00
parent 5468b7c172
commit 9ed5fc39bd
2 changed files with 301 additions and 0 deletions

View File

@@ -0,0 +1,86 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { makeTestDb } from '../tests/dbHelper.js';
import { createLesson } from './lessons.js';
import { createCard } from './cards.js';
import { startSession, getNextItem, recordAttempt, endSession, getActiveSession } from './sessions.js';
let env: ReturnType<typeof makeTestDb>;
beforeEach(() => { env = makeTestDb(); });
async function seedLesson(name = 'L', cards = 3, bidi = false) {
const lesson = await createLesson(env.db, { name, bidirectional: bidi });
for (let i = 0; i < cards; i++) {
await createCard(env.db, lesson.id, { question: `q${i}`, answer: `a${i}` });
}
return lesson;
}
describe('session engine', () => {
it('starts a session and queues all cards from lesson + descendants', async () => {
const root = await createLesson(env.db, { name: 'R' });
const child = await createLesson(env.db, { name: 'C', parentId: root.id });
await createCard(env.db, root.id, { question: 'r1', answer: 'a' });
await createCard(env.db, child.id, { question: 'c1', answer: 'a' });
const s = await startSession(env.db, { lessonId: root.id, shuffle: false });
expect(s.queue).toHaveLength(2);
});
it('returns items in queue order and marks done when exhausted', async () => {
const l = await seedLesson('L', 2);
const s = await startSession(env.db, { lessonId: l.id, shuffle: false });
const a = await getNextItem(env.db, s.session.id);
expect(a).not.toBeNull();
await recordAttempt(env.db, s.session.id, { cardId: a!.cardId, direction: 'forward', result: 'correct' });
const b = await getNextItem(env.db, s.session.id);
expect(b).not.toBeNull();
await recordAttempt(env.db, s.session.id, { cardId: b!.cardId, direction: 'forward', result: 'correct' });
const done = await getNextItem(env.db, s.session.id);
expect(done).toBeNull();
});
it('reinserts an incorrect card later in the same session', async () => {
const l = await seedLesson('L', 4);
const s = await startSession(env.db, { lessonId: l.id, shuffle: false });
const first = await getNextItem(env.db, s.session.id);
await recordAttempt(env.db, s.session.id, { cardId: first!.cardId, direction: 'forward', result: 'incorrect' });
const seen: number[] = [first!.cardId];
for (let i = 0; i < 4; i++) {
const nx = await getNextItem(env.db, s.session.id);
if (!nx) break;
seen.push(nx.cardId);
await recordAttempt(env.db, s.session.id, { cardId: nx.cardId, direction: 'forward', result: 'correct' });
}
expect(seen.filter((c) => c === first!.cardId).length).toBeGreaterThanOrEqual(2);
});
it('respects maxCards limit', async () => {
const l = await seedLesson('L', 10);
const s = await startSession(env.db, { lessonId: l.id, shuffle: false, maxCards: 3 });
expect(s.queue).toHaveLength(3);
});
it('tracks counters on session and ends with duration', async () => {
const l = await seedLesson('L', 2);
const s = await startSession(env.db, { lessonId: l.id, shuffle: false });
const a = await getNextItem(env.db, s.session.id);
await recordAttempt(env.db, s.session.id, { cardId: a!.cardId, direction: 'forward', result: 'correct' });
const b = await getNextItem(env.db, s.session.id);
await recordAttempt(env.db, s.session.id, { cardId: b!.cardId, direction: 'forward', result: 'incorrect' });
const ended = await endSession(env.db, s.session.id);
expect(ended.cardsCorrect).toBe(1);
expect(ended.cardsIncorrect).toBe(1);
expect(ended.cardsShown).toBe(2);
expect(ended.status).toBe('completed');
expect(ended.durationSeconds).not.toBeNull();
});
it('returns active session if one exists', async () => {
const l = await seedLesson('L', 1);
const s = await startSession(env.db, { lessonId: l.id });
const active = await getActiveSession(env.db);
expect(active?.id).toBe(s.session.id);
await endSession(env.db, s.session.id);
const after = await getActiveSession(env.db);
expect(after).toBeNull();
});
});

View File

@@ -0,0 +1,215 @@
import { and, 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 type {
AttemptCreateInput, QueueItem, SessionRow, SessionStartInput,
} from '@flashcard/shared';
import { applyResult } from './leitner.js';
import { getDescendantLessonIds } from './lessons.js';
const REINSERT_OFFSET = 3;
function nowSec() { return Math.floor(Date.now() / 1000); }
function shuffleInPlace<T>(arr: T[]): T[] {
for (let i = arr.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[arr[i], arr[j]] = [arr[j]!, arr[i]!];
}
return arr;
}
export interface StartedSession {
session: SessionRow;
queue: QueueItem[];
}
export async function startSession(db: Db, input: SessionStartInput): Promise<StartedSession> {
const lesson = db.select().from(lessons).where(eq(lessons.id, input.lessonId)).get();
if (!lesson) throw ApiError.notFound('Lesson');
const lessonIds = await getDescendantLessonIds(db, input.lessonId);
const lessonRows = db.select().from(lessons).where(inArray(lessons.id, lessonIds)).all();
const bidirById = new Map(lessonRows.map((l) => [l.id, l.bidirectional]));
const allCards = lessonIds.length === 0
? []
: db.select().from(cards).where(inArray(cards.lessonId, lessonIds)).all();
const direction = input.direction ?? 'forward';
const candidates: QueueItem[] = [];
for (const c of allCards) {
const isBidi = bidirById.get(c.lessonId) === true;
if (direction === 'forward' || direction === 'both') {
candidates.push({ cardId: c.id, direction: 'forward' });
}
if ((direction === 'backward' || direction === 'both') && isBidi) {
candidates.push({ cardId: c.id, direction: 'backward' });
}
}
for (const item of candidates) {
const existing = db.select().from(cardProgress)
.where(and(eq(cardProgress.cardId, item.cardId), eq(cardProgress.direction, item.direction)))
.get();
if (!existing) {
db.insert(cardProgress).values({
cardId: item.cardId, direction: item.direction, box: 1, nextDueAt: 0,
}).run();
}
}
const progressRows = allCards.length === 0
? []
: db.select().from(cardProgress).where(inArray(cardProgress.cardId, allCards.map((c) => c.id))).all();
const progByKey = new Map<string, typeof progressRows[number]>();
for (const p of progressRows) progByKey.set(`${p.cardId}:${p.direction}`, p);
const now = nowSec();
const due: QueueItem[] = [];
const future: QueueItem[] = [];
for (const item of candidates) {
const p = progByKey.get(`${item.cardId}:${item.direction}`)!;
(p.nextDueAt <= now ? due : future).push(item);
}
const shuffle = input.shuffle ?? true;
const sortByBox = (a: QueueItem, b: QueueItem) => {
const pa = progByKey.get(`${a.cardId}:${a.direction}`)!;
const pb = progByKey.get(`${b.cardId}:${b.direction}`)!;
return pa.box - pb.box;
};
if (shuffle) {
shuffleInPlace(due);
shuffleInPlace(future);
}
due.sort(sortByBox);
future.sort(sortByBox);
let queue: QueueItem[] = [...due, ...future];
const max = input.maxCards ?? null;
if (max !== null && max !== undefined) queue = queue.slice(0, max);
const [row] = db.insert(sessions).values({
lessonId: input.lessonId,
queueSnapshot: JSON.stringify({ remaining: queue, index: 0 }),
}).returning().all();
return { session: rowToSession(row!), queue };
}
function rowToSession(r: typeof sessions.$inferSelect): SessionRow {
return {
id: r.id,
lessonId: r.lessonId,
startedAt: r.startedAt,
endedAt: r.endedAt ?? null,
durationSeconds: r.durationSeconds ?? null,
cardsShown: r.cardsShown,
cardsCorrect: r.cardsCorrect,
cardsIncorrect: r.cardsIncorrect,
status: r.status,
};
}
interface QueueState { remaining: QueueItem[]; index: number; }
function readQueue(snapshot: string | null | undefined): QueueState {
if (!snapshot) return { remaining: [], index: 0 };
return JSON.parse(snapshot) as QueueState;
}
export async function getNextItem(db: Db, sessionId: number): Promise<QueueItem | null> {
const row = db.select().from(sessions).where(eq(sessions.id, sessionId)).get();
if (!row) throw ApiError.notFound('Session');
if (row.status !== 'active') return null;
const state = readQueue(row.queueSnapshot);
return state.remaining[state.index] ?? null;
}
export async function recordAttempt(
db: Db,
sessionId: number,
input: AttemptCreateInput
): Promise<void> {
const sess = db.select().from(sessions).where(eq(sessions.id, sessionId)).get();
if (!sess) throw ApiError.notFound('Session');
if (sess.status !== 'active') throw ApiError.validation('Session is not active');
const now = nowSec();
db.insert(attempts).values({
sessionId,
cardId: input.cardId,
direction: input.direction,
result: input.result,
timeToAnswerMs: input.timeToAnswerMs ?? null,
}).run();
const prog = db.select().from(cardProgress)
.where(and(eq(cardProgress.cardId, input.cardId), eq(cardProgress.direction, input.direction)))
.get();
if (prog) {
const delta = applyResult(
{ box: prog.box, correctCount: prog.correctCount, incorrectCount: prog.incorrectCount },
input.result,
now
);
db.update(cardProgress).set({
box: delta.box,
correctCount: delta.correctCount,
incorrectCount: delta.incorrectCount,
nextDueAt: delta.nextDueAt,
lastShownAt: delta.lastShownAt,
}).where(and(eq(cardProgress.cardId, input.cardId), eq(cardProgress.direction, input.direction))).run();
}
const state = readQueue(sess.queueSnapshot);
state.index += 1;
if (input.result === 'incorrect') {
const insertAt = Math.min(state.remaining.length, state.index + REINSERT_OFFSET);
state.remaining.splice(insertAt, 0, { cardId: input.cardId, direction: input.direction });
}
db.update(sessions).set({
queueSnapshot: JSON.stringify(state),
cardsShown: sess.cardsShown + 1,
cardsCorrect: sess.cardsCorrect + (input.result === 'correct' ? 1 : 0),
cardsIncorrect: sess.cardsIncorrect + (input.result === 'incorrect' ? 1 : 0),
}).where(eq(sessions.id, sessionId)).run();
}
export async function endSession(db: Db, sessionId: number): Promise<SessionRow> {
const sess = db.select().from(sessions).where(eq(sessions.id, sessionId)).get();
if (!sess) throw ApiError.notFound('Session');
const endedAt = nowSec();
const duration = endedAt - sess.startedAt;
const [row] = db.update(sessions).set({
status: 'completed',
endedAt,
durationSeconds: duration,
queueSnapshot: null,
}).where(eq(sessions.id, sessionId)).returning().all();
return rowToSession(row!);
}
export async function abandonSession(db: Db, sessionId: number): Promise<SessionRow> {
const sess = db.select().from(sessions).where(eq(sessions.id, sessionId)).get();
if (!sess) throw ApiError.notFound('Session');
const endedAt = nowSec();
const duration = endedAt - sess.startedAt;
const [row] = db.update(sessions).set({
status: 'abandoned', endedAt, durationSeconds: duration,
}).where(eq(sessions.id, sessionId)).returning().all();
return rowToSession(row!);
}
export async function getActiveSession(db: Db): Promise<SessionRow | null> {
const row = db.select().from(sessions).where(eq(sessions.status, 'active')).orderBy(sql`${sessions.startedAt} DESC`).get();
return row ? rowToSession(row) : null;
}
export async function getSessionState(db: Db, sessionId: number): Promise<{ session: SessionRow; queue: QueueItem[]; index: number } | null> {
const row = db.select().from(sessions).where(eq(sessions.id, sessionId)).get();
if (!row) return null;
const state = readQueue(row.queueSnapshot);
return { session: rowToSession(row), queue: state.remaining, index: state.index };
}