feat(backend): leitner algorithm with tests
This commit is contained in:
26
packages/backend/src/services/leitner.test.ts
Normal file
26
packages/backend/src/services/leitner.test.ts
Normal file
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
49
packages/backend/src/services/leitner.ts
Normal file
49
packages/backend/src/services/leitner.ts
Normal file
@@ -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<number, number> = {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user