DB, Collections, Search

This commit is contained in:
Stefan Hardegger
2025-11-21 07:53:37 +01:00
parent c8eb6237c4
commit 8a03edbb88
67 changed files with 17703 additions and 103 deletions

View File

@@ -0,0 +1,773 @@
import { describe, it, expect } from "vitest"
import {
INITIAL_PROGRESS,
calculateCorrectAnswer,
calculateIncorrectAnswer,
Difficulty,
selectCardsForSession,
generateWrongAnswers,
shuffleOptions,
type CardProgress,
type SelectableCard,
type HanziOption,
} from "./sm2"
/**
* Unit tests for SM-2 Algorithm
*
* Tests the spaced repetition algorithm implementation
* following the specification exactly.
*/
describe("SM-2 Algorithm", () => {
describe("INITIAL_PROGRESS", () => {
it("should have correct initial values", () => {
expect(INITIAL_PROGRESS.easeFactor).toBe(2.5)
expect(INITIAL_PROGRESS.interval).toBe(1)
expect(INITIAL_PROGRESS.consecutiveCorrect).toBe(0)
expect(INITIAL_PROGRESS.incorrectCount).toBe(0)
expect(INITIAL_PROGRESS.lastReviewDate).toBeNull()
expect(INITIAL_PROGRESS.nextReviewDate).toBeNull()
})
})
describe("calculateCorrectAnswer", () => {
it("should set interval to 1 for first correct answer", () => {
const progress: CardProgress = {
...INITIAL_PROGRESS,
}
const result = calculateCorrectAnswer(progress, new Date("2025-01-01"))
expect(result.interval).toBe(1)
expect(result.consecutiveCorrect).toBe(1)
expect(result.easeFactor).toBe(2.6) // 2.5 + 0.1
expect(result.nextReviewDate).toEqual(new Date("2025-01-02"))
})
it("should set interval to 6 for second correct answer", () => {
const progress: CardProgress = {
easeFactor: 2.6,
interval: 1,
consecutiveCorrect: 1,
incorrectCount: 0,
lastReviewDate: new Date("2025-01-01"),
nextReviewDate: new Date("2025-01-02"),
}
const result = calculateCorrectAnswer(progress, new Date("2025-01-02"))
expect(result.interval).toBe(6)
expect(result.consecutiveCorrect).toBe(2)
expect(result.easeFactor).toBe(2.7) // 2.6 + 0.1
expect(result.nextReviewDate).toEqual(new Date("2025-01-08"))
})
it("should multiply interval by easeFactor for third+ correct answer", () => {
const progress: CardProgress = {
easeFactor: 2.7,
interval: 6,
consecutiveCorrect: 2,
incorrectCount: 0,
lastReviewDate: new Date("2025-01-02"),
nextReviewDate: new Date("2025-01-08"),
}
const result = calculateCorrectAnswer(progress, new Date("2025-01-08"))
// 6 * 2.7 = 16.2, rounded = 16
expect(result.interval).toBe(16)
expect(result.consecutiveCorrect).toBe(3)
expect(result.easeFactor).toBeCloseTo(2.8) // 2.7 + 0.1
expect(result.nextReviewDate).toEqual(new Date("2025-01-24"))
})
it("should continue increasing ease factor with each correct answer", () => {
const progress: CardProgress = {
easeFactor: 3.0,
interval: 50,
consecutiveCorrect: 5,
incorrectCount: 2,
lastReviewDate: new Date("2025-01-01"),
nextReviewDate: new Date("2025-02-20"),
}
const result = calculateCorrectAnswer(progress, new Date("2025-02-20"))
// 50 * 3.0 = 150
expect(result.interval).toBe(150)
expect(result.consecutiveCorrect).toBe(6)
expect(result.easeFactor).toBe(3.1) // 3.0 + 0.1
expect(result.incorrectCount).toBe(2) // Should not change
})
it("should use current date by default", () => {
const progress: CardProgress = {
...INITIAL_PROGRESS,
}
const before = new Date()
const result = calculateCorrectAnswer(progress)
const after = new Date()
// Next review should be approximately 1 day from now
const expectedMin = new Date(before)
expectedMin.setDate(expectedMin.getDate() + 1)
const expectedMax = new Date(after)
expectedMax.setDate(expectedMax.getDate() + 1)
expect(result.nextReviewDate.getTime()).toBeGreaterThanOrEqual(
expectedMin.getTime()
)
expect(result.nextReviewDate.getTime()).toBeLessThanOrEqual(
expectedMax.getTime()
)
})
it("should handle large intervals correctly", () => {
const progress: CardProgress = {
easeFactor: 2.5,
interval: 365,
consecutiveCorrect: 10,
incorrectCount: 0,
lastReviewDate: new Date("2025-01-01"),
nextReviewDate: new Date("2026-01-01"),
}
const result = calculateCorrectAnswer(progress, new Date("2026-01-01"))
// 365 * 2.5 = 912.5, rounded = 913
expect(result.interval).toBe(913)
expect(result.consecutiveCorrect).toBe(11)
})
})
describe("calculateIncorrectAnswer", () => {
it("should reset interval to 1", () => {
const progress: CardProgress = {
easeFactor: 2.7,
interval: 16,
consecutiveCorrect: 3,
incorrectCount: 0,
lastReviewDate: new Date("2025-01-01"),
nextReviewDate: new Date("2025-01-17"),
}
const result = calculateIncorrectAnswer(progress, new Date("2025-01-17"))
expect(result.interval).toBe(1)
expect(result.nextReviewDate).toEqual(new Date("2025-01-18"))
})
it("should reset consecutiveCorrect to 0", () => {
const progress: CardProgress = {
easeFactor: 2.7,
interval: 16,
consecutiveCorrect: 5,
incorrectCount: 1,
lastReviewDate: new Date("2025-01-01"),
nextReviewDate: new Date("2025-01-17"),
}
const result = calculateIncorrectAnswer(progress, new Date("2025-01-17"))
expect(result.consecutiveCorrect).toBe(0)
})
it("should decrease easeFactor by 0.2", () => {
const progress: CardProgress = {
easeFactor: 2.5,
interval: 6,
consecutiveCorrect: 2,
incorrectCount: 0,
lastReviewDate: new Date("2025-01-01"),
nextReviewDate: new Date("2025-01-07"),
}
const result = calculateIncorrectAnswer(progress, new Date("2025-01-07"))
expect(result.easeFactor).toBe(2.3) // 2.5 - 0.2
})
it("should not decrease easeFactor below 1.3", () => {
const progress: CardProgress = {
easeFactor: 1.4,
interval: 1,
consecutiveCorrect: 0,
incorrectCount: 5,
lastReviewDate: new Date("2025-01-01"),
nextReviewDate: new Date("2025-01-02"),
}
const result = calculateIncorrectAnswer(progress, new Date("2025-01-02"))
// 1.4 - 0.2 = 1.2, but minimum is 1.3
expect(result.easeFactor).toBe(1.3)
})
it("should increment incorrectCount", () => {
const progress: CardProgress = {
easeFactor: 2.5,
interval: 6,
consecutiveCorrect: 2,
incorrectCount: 0,
lastReviewDate: new Date("2025-01-01"),
nextReviewDate: new Date("2025-01-07"),
}
const result = calculateIncorrectAnswer(progress, new Date("2025-01-07"))
expect(result.incorrectCount).toBe(1)
})
it("should use current date by default", () => {
const progress: CardProgress = {
easeFactor: 2.5,
interval: 6,
consecutiveCorrect: 2,
incorrectCount: 0,
lastReviewDate: new Date("2025-01-01"),
nextReviewDate: new Date("2025-01-07"),
}
const before = new Date()
const result = calculateIncorrectAnswer(progress)
const after = new Date()
// Next review should be approximately 1 day from now
const expectedMin = new Date(before)
expectedMin.setDate(expectedMin.getDate() + 1)
const expectedMax = new Date(after)
expectedMax.setDate(expectedMax.getDate() + 1)
expect(result.nextReviewDate.getTime()).toBeGreaterThanOrEqual(
expectedMin.getTime()
)
expect(result.nextReviewDate.getTime()).toBeLessThanOrEqual(
expectedMax.getTime()
)
})
it("should handle multiple consecutive incorrect answers", () => {
let progress: CardProgress = {
easeFactor: 2.5,
interval: 16,
consecutiveCorrect: 3,
incorrectCount: 0,
lastReviewDate: new Date("2025-01-01"),
nextReviewDate: new Date("2025-01-17"),
}
// First incorrect
let result = calculateIncorrectAnswer(progress, new Date("2025-01-17"))
expect(result.easeFactor).toBe(2.3)
expect(result.incorrectCount).toBe(1)
// Second incorrect
progress = {
easeFactor: result.easeFactor,
interval: result.interval,
consecutiveCorrect: result.consecutiveCorrect,
incorrectCount: result.incorrectCount,
lastReviewDate: new Date("2025-01-17"),
nextReviewDate: result.nextReviewDate,
}
result = calculateIncorrectAnswer(progress, new Date("2025-01-18"))
expect(result.easeFactor).toBeCloseTo(2.1) // 2.3 - 0.2
expect(result.incorrectCount).toBe(2)
// Third incorrect
progress = {
easeFactor: result.easeFactor,
interval: result.interval,
consecutiveCorrect: result.consecutiveCorrect,
incorrectCount: result.incorrectCount,
lastReviewDate: new Date("2025-01-18"),
nextReviewDate: result.nextReviewDate,
}
result = calculateIncorrectAnswer(progress, new Date("2025-01-19"))
expect(result.easeFactor).toBeCloseTo(1.9) // 2.1 - 0.2
expect(result.incorrectCount).toBe(3)
})
})
describe("selectCardsForSession", () => {
const now = new Date("2025-01-15T10:00:00Z")
it("should select due cards only", () => {
const cards: SelectableCard[] = [
{
id: "1",
nextReviewDate: new Date("2025-01-14T10:00:00Z"), // Due
incorrectCount: 0,
consecutiveCorrect: 1,
manualDifficulty: Difficulty.NORMAL,
},
{
id: "2",
nextReviewDate: new Date("2025-01-16T10:00:00Z"), // Not due
incorrectCount: 0,
consecutiveCorrect: 1,
manualDifficulty: Difficulty.NORMAL,
},
{
id: "3",
nextReviewDate: new Date("2025-01-13T10:00:00Z"), // Due
incorrectCount: 0,
consecutiveCorrect: 1,
manualDifficulty: Difficulty.NORMAL,
},
]
const selected = selectCardsForSession(cards, 10, now)
expect(selected.length).toBe(2)
expect(selected.map((c) => c.id)).toContain("1")
expect(selected.map((c) => c.id)).toContain("3")
expect(selected.map((c) => c.id)).not.toContain("2")
})
it("should exclude suspended cards", () => {
const cards: SelectableCard[] = [
{
id: "1",
nextReviewDate: new Date("2025-01-14T10:00:00Z"),
incorrectCount: 0,
consecutiveCorrect: 1,
manualDifficulty: Difficulty.NORMAL,
},
{
id: "2",
nextReviewDate: new Date("2025-01-14T10:00:00Z"),
incorrectCount: 0,
consecutiveCorrect: 1,
manualDifficulty: Difficulty.SUSPENDED,
},
]
const selected = selectCardsForSession(cards, 10, now)
expect(selected.length).toBe(1)
expect(selected[0].id).toBe("1")
})
it("should prioritize HARD over NORMAL over EASY", () => {
const cards: SelectableCard[] = [
{
id: "easy",
nextReviewDate: new Date("2025-01-14T10:00:00Z"),
incorrectCount: 0,
consecutiveCorrect: 1,
manualDifficulty: Difficulty.EASY,
},
{
id: "hard",
nextReviewDate: new Date("2025-01-14T10:00:00Z"),
incorrectCount: 0,
consecutiveCorrect: 1,
manualDifficulty: Difficulty.HARD,
},
{
id: "normal",
nextReviewDate: new Date("2025-01-14T10:00:00Z"),
incorrectCount: 0,
consecutiveCorrect: 1,
manualDifficulty: Difficulty.NORMAL,
},
]
const selected = selectCardsForSession(cards, 10, now)
expect(selected[0].id).toBe("hard")
expect(selected[1].id).toBe("normal")
expect(selected[2].id).toBe("easy")
})
it("should sort by nextReviewDate ascending", () => {
const cards: SelectableCard[] = [
{
id: "1",
nextReviewDate: new Date("2025-01-14T10:00:00Z"),
incorrectCount: 0,
consecutiveCorrect: 1,
manualDifficulty: Difficulty.NORMAL,
},
{
id: "2",
nextReviewDate: new Date("2025-01-12T10:00:00Z"),
incorrectCount: 0,
consecutiveCorrect: 1,
manualDifficulty: Difficulty.NORMAL,
},
{
id: "3",
nextReviewDate: new Date("2025-01-13T10:00:00Z"),
incorrectCount: 0,
consecutiveCorrect: 1,
manualDifficulty: Difficulty.NORMAL,
},
]
const selected = selectCardsForSession(cards, 10, now)
expect(selected[0].id).toBe("2") // Oldest
expect(selected[1].id).toBe("3")
expect(selected[2].id).toBe("1") // Newest
})
it("should prioritize higher incorrectCount", () => {
const cards: SelectableCard[] = [
{
id: "1",
nextReviewDate: new Date("2025-01-14T10:00:00Z"),
incorrectCount: 1,
consecutiveCorrect: 1,
manualDifficulty: Difficulty.NORMAL,
},
{
id: "2",
nextReviewDate: new Date("2025-01-14T10:00:00Z"),
incorrectCount: 3,
consecutiveCorrect: 1,
manualDifficulty: Difficulty.NORMAL,
},
{
id: "3",
nextReviewDate: new Date("2025-01-14T10:00:00Z"),
incorrectCount: 2,
consecutiveCorrect: 1,
manualDifficulty: Difficulty.NORMAL,
},
]
const selected = selectCardsForSession(cards, 10, now)
expect(selected[0].id).toBe("2") // incorrectCount: 3
expect(selected[1].id).toBe("3") // incorrectCount: 2
expect(selected[2].id).toBe("1") // incorrectCount: 1
})
it("should prioritize lower consecutiveCorrect", () => {
const cards: SelectableCard[] = [
{
id: "1",
nextReviewDate: new Date("2025-01-14T10:00:00Z"),
incorrectCount: 0,
consecutiveCorrect: 3,
manualDifficulty: Difficulty.NORMAL,
},
{
id: "2",
nextReviewDate: new Date("2025-01-14T10:00:00Z"),
incorrectCount: 0,
consecutiveCorrect: 1,
manualDifficulty: Difficulty.NORMAL,
},
{
id: "3",
nextReviewDate: new Date("2025-01-14T10:00:00Z"),
incorrectCount: 0,
consecutiveCorrect: 2,
manualDifficulty: Difficulty.NORMAL,
},
]
const selected = selectCardsForSession(cards, 10, now)
expect(selected[0].id).toBe("2") // consecutiveCorrect: 1
expect(selected[1].id).toBe("3") // consecutiveCorrect: 2
expect(selected[2].id).toBe("1") // consecutiveCorrect: 3
})
it("should include new cards (null nextReviewDate)", () => {
const cards: SelectableCard[] = [
{
id: "1",
nextReviewDate: null, // New card
incorrectCount: 0,
consecutiveCorrect: 0,
manualDifficulty: Difficulty.NORMAL,
},
{
id: "2",
nextReviewDate: new Date("2025-01-14T10:00:00Z"),
incorrectCount: 0,
consecutiveCorrect: 1,
manualDifficulty: Difficulty.NORMAL,
},
]
const selected = selectCardsForSession(cards, 10, now)
expect(selected.length).toBe(2)
expect(selected[0].id).toBe("1") // New cards first
expect(selected[1].id).toBe("2")
})
it("should limit to cardsPerSession", () => {
const cards: SelectableCard[] = Array.from({ length: 20 }, (_, i) => ({
id: `${i}`,
nextReviewDate: new Date("2025-01-14T10:00:00Z"),
incorrectCount: 0,
consecutiveCorrect: 1,
manualDifficulty: Difficulty.NORMAL,
}))
const selected = selectCardsForSession(cards, 5, now)
expect(selected.length).toBe(5)
})
it("should apply all sorting criteria in correct order", () => {
const cards: SelectableCard[] = [
{
id: "easy-old-high-low",
nextReviewDate: new Date("2025-01-12T10:00:00Z"),
incorrectCount: 5,
consecutiveCorrect: 1,
manualDifficulty: Difficulty.EASY,
},
{
id: "hard-new-low-high",
nextReviewDate: new Date("2025-01-14T10:00:00Z"),
incorrectCount: 1,
consecutiveCorrect: 5,
manualDifficulty: Difficulty.HARD,
},
{
id: "hard-old-low-low",
nextReviewDate: new Date("2025-01-12T10:00:00Z"),
incorrectCount: 1,
consecutiveCorrect: 1,
manualDifficulty: Difficulty.HARD,
},
{
id: "normal-old-high-low",
nextReviewDate: new Date("2025-01-12T10:00:00Z"),
incorrectCount: 5,
consecutiveCorrect: 1,
manualDifficulty: Difficulty.NORMAL,
},
]
const selected = selectCardsForSession(cards, 10, now)
// Expected order:
// 1. HARD difficulty has priority
// 2. Among HARD: older date (2025-01-12) before newer (2025-01-14)
// 3. Then NORMAL difficulty
// 4. Then EASY difficulty
expect(selected[0].id).toBe("hard-old-low-low")
expect(selected[1].id).toBe("hard-new-low-high")
expect(selected[2].id).toBe("normal-old-high-low")
expect(selected[3].id).toBe("easy-old-high-low")
})
it("should handle empty card list", () => {
const selected = selectCardsForSession([], 10, now)
expect(selected.length).toBe(0)
})
it("should handle all cards being suspended", () => {
const cards: SelectableCard[] = [
{
id: "1",
nextReviewDate: new Date("2025-01-14T10:00:00Z"),
incorrectCount: 0,
consecutiveCorrect: 1,
manualDifficulty: Difficulty.SUSPENDED,
},
{
id: "2",
nextReviewDate: new Date("2025-01-14T10:00:00Z"),
incorrectCount: 0,
consecutiveCorrect: 1,
manualDifficulty: Difficulty.SUSPENDED,
},
]
const selected = selectCardsForSession(cards, 10, now)
expect(selected.length).toBe(0)
})
it("should handle all cards not being due", () => {
const cards: SelectableCard[] = [
{
id: "1",
nextReviewDate: new Date("2025-01-16T10:00:00Z"),
incorrectCount: 0,
consecutiveCorrect: 1,
manualDifficulty: Difficulty.NORMAL,
},
{
id: "2",
nextReviewDate: new Date("2025-01-17T10:00:00Z"),
incorrectCount: 0,
consecutiveCorrect: 1,
manualDifficulty: Difficulty.NORMAL,
},
]
const selected = selectCardsForSession(cards, 10, now)
expect(selected.length).toBe(0)
})
})
describe("generateWrongAnswers", () => {
const correctAnswer: HanziOption = {
id: "1",
simplified: "好",
pinyin: "hǎo",
hskLevel: "new-1",
}
it("should generate 3 wrong answers", () => {
const sameHskOptions: HanziOption[] = [
correctAnswer,
{ id: "2", simplified: "你", pinyin: "nǐ", hskLevel: "new-1" },
{ id: "3", simplified: "我", pinyin: "wǒ", hskLevel: "new-1" },
{ id: "4", simplified: "他", pinyin: "tā", hskLevel: "new-1" },
{ id: "5", simplified: "的", pinyin: "de", hskLevel: "new-1" },
]
const wrongAnswers = generateWrongAnswers(correctAnswer, sameHskOptions)
expect(wrongAnswers.length).toBe(3)
expect(wrongAnswers).not.toContain("hǎo") // Should not include correct
})
it("should not include correct answer", () => {
const sameHskOptions: HanziOption[] = [
correctAnswer,
{ id: "2", simplified: "你", pinyin: "nǐ", hskLevel: "new-1" },
{ id: "3", simplified: "我", pinyin: "wǒ", hskLevel: "new-1" },
{ id: "4", simplified: "他", pinyin: "tā", hskLevel: "new-1" },
]
const wrongAnswers = generateWrongAnswers(correctAnswer, sameHskOptions)
expect(wrongAnswers).not.toContain("hǎo")
})
it("should not include duplicate pinyin", () => {
const sameHskOptions: HanziOption[] = [
correctAnswer,
{ id: "2", simplified: "好", pinyin: "hǎo", hskLevel: "new-1" }, // Duplicate pinyin
{ id: "3", simplified: "你", pinyin: "nǐ", hskLevel: "new-1" },
{ id: "4", simplified: "我", pinyin: "wǒ", hskLevel: "new-1" },
{ id: "5", simplified: "他", pinyin: "tā", hskLevel: "new-1" },
{ id: "6", simplified: "的", pinyin: "de", hskLevel: "new-1" },
]
const wrongAnswers = generateWrongAnswers(correctAnswer, sameHskOptions)
expect(wrongAnswers.length).toBe(3)
// Should only have 1 instance of each pinyin
const uniquePinyin = new Set(wrongAnswers)
expect(uniquePinyin.size).toBe(3)
})
it("should throw error if not enough options", () => {
const sameHskOptions: HanziOption[] = [
correctAnswer,
{ id: "2", simplified: "你", pinyin: "nǐ", hskLevel: "new-1" },
{ id: "3", simplified: "我", pinyin: "wǒ", hskLevel: "new-1" },
// Only 2 other options, need 3
]
expect(() =>
generateWrongAnswers(correctAnswer, sameHskOptions)
).toThrow("Not enough wrong answers available")
})
it("should randomize the selection", () => {
const sameHskOptions: HanziOption[] = [
correctAnswer,
{ id: "2", simplified: "你", pinyin: "nǐ", hskLevel: "new-1" },
{ id: "3", simplified: "我", pinyin: "wǒ", hskLevel: "new-1" },
{ id: "4", simplified: "他", pinyin: "tā", hskLevel: "new-1" },
{ id: "5", simplified: "的", pinyin: "de", hskLevel: "new-1" },
{ id: "6", simplified: "是", pinyin: "shì", hskLevel: "new-1" },
{ id: "7", simplified: "在", pinyin: "zài", hskLevel: "new-1" },
{ id: "8", simplified: "有", pinyin: "yǒu", hskLevel: "new-1" },
]
// Run multiple times and check that we get different results
const results = new Set<string>()
for (let i = 0; i < 10; i++) {
const wrongAnswers = generateWrongAnswers(correctAnswer, sameHskOptions)
results.add(wrongAnswers.sort().join(","))
}
// Should have at least 2 different combinations (very likely with 7 options)
expect(results.size).toBeGreaterThan(1)
})
})
describe("shuffleOptions", () => {
it("should return array of same length", () => {
const options = ["a", "b", "c", "d"]
const shuffled = shuffleOptions(options)
expect(shuffled.length).toBe(4)
})
it("should contain all original elements", () => {
const options = ["a", "b", "c", "d"]
const shuffled = shuffleOptions(options)
expect(shuffled).toContain("a")
expect(shuffled).toContain("b")
expect(shuffled).toContain("c")
expect(shuffled).toContain("d")
})
it("should not mutate original array", () => {
const options = ["a", "b", "c", "d"]
const original = [...options]
shuffleOptions(options)
expect(options).toEqual(original)
})
it("should produce different orders", () => {
const options = ["a", "b", "c", "d", "e", "f"]
// Run multiple times and check that we get different results
const results = new Set<string>()
for (let i = 0; i < 20; i++) {
const shuffled = shuffleOptions(options)
results.add(shuffled.join(","))
}
// Should have at least 2 different orderings (very likely with 6 elements)
expect(results.size).toBeGreaterThan(1)
})
it("should handle single element array", () => {
const options = ["a"]
const shuffled = shuffleOptions(options)
expect(shuffled).toEqual(["a"])
})
it("should handle empty array", () => {
const options: string[] = []
const shuffled = shuffleOptions(options)
expect(shuffled).toEqual([])
})
it("should work with different types", () => {
const options = [1, 2, 3, 4, 5]
const shuffled = shuffleOptions(options)
expect(shuffled.length).toBe(5)
expect(shuffled).toContain(1)
expect(shuffled).toContain(2)
expect(shuffled).toContain(3)
expect(shuffled).toContain(4)
expect(shuffled).toContain(5)
})
})
})

275
src/lib/learning/sm2.ts Normal file
View File

@@ -0,0 +1,275 @@
/**
* SM-2 Algorithm Implementation
*
* Implements the SuperMemo SM-2 spaced repetition algorithm
* as specified in the MemoHanzi specification.
*
* Reference: https://www.supermemo.com/en/archives1990-2015/english/ol/sm2
*/
/**
* Progress data for a single card
*/
export interface CardProgress {
easeFactor: number
interval: number // in days
consecutiveCorrect: number
incorrectCount: number
lastReviewDate: Date | null
nextReviewDate: Date | null
}
/**
* Initial values for a new card
*/
export const INITIAL_PROGRESS: CardProgress = {
easeFactor: 2.5,
interval: 1,
consecutiveCorrect: 0,
incorrectCount: 0,
lastReviewDate: null,
nextReviewDate: null,
}
/**
* Result of calculating the next review
*/
export interface ReviewResult {
easeFactor: number
interval: number
consecutiveCorrect: number
incorrectCount: number
nextReviewDate: Date
}
/**
* Calculate the next review for a correct answer
*
* @param progress Current card progress
* @param reviewDate Date of the review (defaults to now)
* @returns Updated progress values
*/
export function calculateCorrectAnswer(
progress: CardProgress,
reviewDate: Date = new Date()
): ReviewResult {
let newInterval: number
let newEaseFactor: number
let newConsecutiveCorrect: number
// Calculate new interval based on consecutive correct count
if (progress.consecutiveCorrect === 0) {
newInterval = 1
} else if (progress.consecutiveCorrect === 1) {
newInterval = 6
} else {
newInterval = Math.round(progress.interval * progress.easeFactor)
}
// Increase ease factor (making future intervals longer)
newEaseFactor = progress.easeFactor + 0.1
// Increment consecutive correct count
newConsecutiveCorrect = progress.consecutiveCorrect + 1
// Calculate next review date
const nextReviewDate = new Date(reviewDate)
nextReviewDate.setDate(nextReviewDate.getDate() + newInterval)
return {
easeFactor: newEaseFactor,
interval: newInterval,
consecutiveCorrect: newConsecutiveCorrect,
incorrectCount: progress.incorrectCount,
nextReviewDate,
}
}
/**
* Calculate the next review for an incorrect answer
*
* @param progress Current card progress
* @param reviewDate Date of the review (defaults to now)
* @returns Updated progress values
*/
export function calculateIncorrectAnswer(
progress: CardProgress,
reviewDate: Date = new Date()
): ReviewResult {
// Reset interval to 1 day
const newInterval = 1
// Reset consecutive correct count
const newConsecutiveCorrect = 0
// Decrease ease factor (but not below 1.3)
const newEaseFactor = Math.max(1.3, progress.easeFactor - 0.2)
// Increment incorrect count
const newIncorrectCount = progress.incorrectCount + 1
// Calculate next review date (1 day from now)
const nextReviewDate = new Date(reviewDate)
nextReviewDate.setDate(nextReviewDate.getDate() + newInterval)
return {
easeFactor: newEaseFactor,
interval: newInterval,
consecutiveCorrect: newConsecutiveCorrect,
incorrectCount: newIncorrectCount,
nextReviewDate,
}
}
/**
* Difficulty enum matching the Prisma schema
*/
export enum Difficulty {
EASY = "EASY",
NORMAL = "NORMAL",
HARD = "HARD",
SUSPENDED = "SUSPENDED",
}
/**
* Card for selection with progress and metadata
*/
export interface SelectableCard {
id: string
nextReviewDate: Date | null
incorrectCount: number
consecutiveCorrect: number
manualDifficulty: Difficulty
}
/**
* Select cards for a learning session
*
* Algorithm:
* 1. Filter out SUSPENDED cards
* 2. Filter cards that are due (nextReviewDate <= now)
* 3. Apply priority: HARD cards first, NORMAL, then EASY
* 4. Sort by: nextReviewDate ASC, incorrectCount DESC, consecutiveCorrect ASC
* 5. Limit to cardsPerSession
*
* @param cards Available cards
* @param cardsPerSession Maximum number of cards to select
* @param now Current date (defaults to now)
* @returns Selected cards for the session
*/
export function selectCardsForSession(
cards: SelectableCard[],
cardsPerSession: number,
now: Date = new Date()
): SelectableCard[] {
// Filter out suspended cards
const activeCards = cards.filter(
(card) => card.manualDifficulty !== Difficulty.SUSPENDED
)
// Filter cards that are due (nextReviewDate <= now or null for new cards)
const dueCards = activeCards.filter(
(card) => card.nextReviewDate === null || card.nextReviewDate <= now
)
// Apply difficulty priority and sort
const sortedCards = dueCards.sort((a, b) => {
// Priority by difficulty: HARD > NORMAL > EASY
const difficultyPriority = {
[Difficulty.HARD]: 0,
[Difficulty.NORMAL]: 1,
[Difficulty.EASY]: 2,
[Difficulty.SUSPENDED]: 3, // Should not appear due to filter
}
const aPriority = difficultyPriority[a.manualDifficulty]
const bPriority = difficultyPriority[b.manualDifficulty]
if (aPriority !== bPriority) {
return aPriority - bPriority
}
// Sort by nextReviewDate (null = new cards, should come first)
if (a.nextReviewDate === null && b.nextReviewDate !== null) return -1
if (a.nextReviewDate !== null && b.nextReviewDate === null) return 1
if (a.nextReviewDate !== null && b.nextReviewDate !== null) {
const dateCompare = a.nextReviewDate.getTime() - b.nextReviewDate.getTime()
if (dateCompare !== 0) return dateCompare
}
// Sort by incorrectCount DESC (more incorrect = higher priority)
if (a.incorrectCount !== b.incorrectCount) {
return b.incorrectCount - a.incorrectCount
}
// Sort by consecutiveCorrect ASC (fewer correct = higher priority)
return a.consecutiveCorrect - b.consecutiveCorrect
})
// Limit to cardsPerSession
return sortedCards.slice(0, cardsPerSession)
}
/**
* Hanzi option for wrong answer generation
*/
export interface HanziOption {
id: string
simplified: string
pinyin: string
hskLevel: string
}
/**
* Generate wrong answers for a multiple choice question
*
* Selects 3 random incorrect pinyin from the same HSK level,
* ensuring no duplicates.
*
* @param correctAnswer The correct hanzi
* @param sameHskOptions Available hanzi from the same HSK level
* @returns Array of 3 wrong pinyin options
*/
export function generateWrongAnswers(
correctAnswer: HanziOption,
sameHskOptions: HanziOption[]
): string[] {
// Filter out the correct answer and any with duplicate pinyin
const candidates = sameHskOptions.filter(
(option) =>
option.id !== correctAnswer.id && option.pinyin !== correctAnswer.pinyin
)
// If not enough candidates, throw error
if (candidates.length < 3) {
throw new Error(
`Not enough wrong answers available. Need 3, found ${candidates.length}`
)
}
// Fisher-Yates shuffle
const shuffled = [...candidates]
for (let i = shuffled.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1))
;[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]
}
// Take first 3
return shuffled.slice(0, 3).map((option) => option.pinyin)
}
/**
* Shuffle an array of options (for randomizing answer positions)
* Uses Fisher-Yates shuffle algorithm
*
* @param options Array to shuffle
* @returns Shuffled array
*/
export function shuffleOptions<T>(options: T[]): T[] {
const shuffled = [...options]
for (let i = shuffled.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1))
;[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]
}
return shuffled
}