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