diff --git a/packages/backend/src/services/leitner.test.ts b/packages/backend/src/services/leitner.test.ts new file mode 100644 index 0000000..2dd0d50 --- /dev/null +++ b/packages/backend/src/services/leitner.test.ts @@ -0,0 +1,26 @@ +import { describe, it, expect } from 'vitest'; +import { applyResult, BOX_INTERVALS_SEC, MAX_BOX } from './leitner.js'; + +describe('leitner.applyResult', () => { + const now = 1_000_000; + + it('moves correct answer to next box and schedules due', () => { + const next = applyResult({ box: 1, correctCount: 0, incorrectCount: 0 }, 'correct', now); + expect(next.box).toBe(2); + expect(next.nextDueAt).toBe(now + BOX_INTERVALS_SEC[2]!); + expect(next.correctCount).toBe(1); + }); + + it('caps box at MAX_BOX on correct answer', () => { + const next = applyResult({ box: MAX_BOX, correctCount: 9, incorrectCount: 0 }, 'correct', now); + expect(next.box).toBe(MAX_BOX); + expect(next.nextDueAt).toBe(now + BOX_INTERVALS_SEC[MAX_BOX]!); + }); + + it('resets to box 1 on incorrect', () => { + const next = applyResult({ box: 4, correctCount: 3, incorrectCount: 1 }, 'incorrect', now); + expect(next.box).toBe(1); + expect(next.nextDueAt).toBe(now + BOX_INTERVALS_SEC[1]!); + expect(next.incorrectCount).toBe(2); + }); +}); diff --git a/packages/backend/src/services/leitner.ts b/packages/backend/src/services/leitner.ts new file mode 100644 index 0000000..1c6c586 --- /dev/null +++ b/packages/backend/src/services/leitner.ts @@ -0,0 +1,49 @@ +import type { AttemptResult } from '@flashcard/shared'; + +export const MAX_BOX = 5; +// index 1..5; index 0 unused +export const BOX_INTERVALS_SEC: Record = { + 1: 0, + 2: 1 * 24 * 60 * 60, + 3: 3 * 24 * 60 * 60, + 4: 7 * 24 * 60 * 60, + 5: 14 * 24 * 60 * 60, +}; + +export interface ProgressLike { + box: number; + correctCount: number; + incorrectCount: number; +} + +export interface ProgressDelta { + box: number; + correctCount: number; + incorrectCount: number; + nextDueAt: number; + lastShownAt: number; +} + +export function applyResult( + current: ProgressLike, + result: AttemptResult, + nowSec: number +): ProgressDelta { + let box = current.box; + let correctCount = current.correctCount; + let incorrectCount = current.incorrectCount; + if (result === 'correct') { + box = Math.min(MAX_BOX, current.box + 1); + correctCount += 1; + } else { + box = 1; + incorrectCount += 1; + } + return { + box, + correctCount, + incorrectCount, + nextDueAt: nowSec + BOX_INTERVALS_SEC[box]!, + lastShownAt: nowSec, + }; +}