diff --git a/packages/backend/src/services/sessions.test.ts b/packages/backend/src/services/sessions.test.ts new file mode 100644 index 0000000..7c8115a --- /dev/null +++ b/packages/backend/src/services/sessions.test.ts @@ -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; +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(); + }); +}); diff --git a/packages/backend/src/services/sessions.ts b/packages/backend/src/services/sessions.ts new file mode 100644 index 0000000..b4d7581 --- /dev/null +++ b/packages/backend/src/services/sessions.ts @@ -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(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 { + 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(); + 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 { + 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 { + 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 { + 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 { + 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 { + 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 }; +}