feat(phase3): learning helpers — summary, glossary, flashcards (SM-2), quizzes
CI / test (push) Successful in 16s
CI / build-apk (push) Has been skipped
CI / deploy-web (push) Successful in 30s

Deterministic, on-device, no model:
- src/lib/learn pure modules (tokenize, summary [TextRank-ish], glossary
  [definition-pattern + frequency], flashcards [cloze/Q-A], srs [SM-2], quiz
  [MCQ with distractors]) — 37 unit tests.
- Flashcard persistence: Dexie v4 + native v4 `flashcards` table; create/list/
  listDue/updateSrs/delete/counts; cascades (transcript delete, course->Unsorted).
- UI: transcript "Study aids" (generate summary+glossary, click-to-seek; create
  flashcards), Study screen (SM-2 review + Anki CSV export), per-lecture Quiz,
  library Study link with due-count badge.

215 tests green, 0 tsc errors, web export builds.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-14 15:37:42 +02:00
parent 6d2f583136
commit 40858e0025
23 changed files with 2238 additions and 24 deletions
+82
View File
@@ -0,0 +1,82 @@
import { describe, it, expect } from 'vitest';
import { initialSrs, review } from './srs';
const MS_PER_DAY = 86_400_000;
const NOW = 1_700_000_000_000;
describe('initialSrs', () => {
it('creates a neutral, immediately-due card', () => {
const s = initialSrs(NOW);
expect(s).toEqual({
ease: 2.5,
intervalDays: 0,
reps: 0,
lapses: 0,
due: NOW,
});
});
});
describe('review', () => {
it('does not mutate the input state', () => {
const s = initialSrs(NOW);
const frozen = { ...s };
review(s, 2, NOW);
expect(s).toEqual(frozen);
});
it('produces monotonically increasing intervals on repeated "good"', () => {
let s = initialSrs(NOW);
const intervals: number[] = [];
let t = NOW;
for (let i = 0; i < 5; i++) {
s = review(s, 2, t);
intervals.push(s.intervalDays);
t += s.intervalDays * MS_PER_DAY;
}
// 1 day, 6 days, then *ease each time -> strictly increasing.
for (let i = 1; i < intervals.length; i++) {
expect(intervals[i]!).toBeGreaterThan(intervals[i - 1]!);
}
expect(intervals[0]).toBe(1);
expect(intervals[1]).toBe(6);
});
it('increments reps on passing grades and resets on "again"', () => {
let s = initialSrs(NOW);
s = review(s, 2, NOW); // good
s = review(s, 2, NOW); // good
expect(s.reps).toBe(2);
s = review(s, 0, NOW); // again
expect(s.reps).toBe(0);
expect(s.lapses).toBe(1);
});
it('floors ease at 1.3 even after many "again" reviews', () => {
let s = initialSrs(NOW);
for (let i = 0; i < 20; i++) s = review(s, 0, NOW);
expect(s.ease).toBe(1.3);
});
it('lowers ease on hard, raises it on easy', () => {
const good = review(initialSrs(NOW), 2, NOW);
expect(good.ease).toBe(2.5);
const hard = review(initialSrs(NOW), 1, NOW);
expect(hard.ease).toBeCloseTo(2.35, 5);
const easy = review(initialSrs(NOW), 3, NOW);
expect(easy.ease).toBeCloseTo(2.65, 5);
});
it('computes due = now + intervalDays * 86400000 and sets lastReviewed', () => {
const s = review(initialSrs(NOW), 2, NOW);
expect(s.intervalDays).toBe(1);
expect(s.due).toBe(NOW + 1 * MS_PER_DAY);
expect(s.lastReviewed).toBe(NOW);
});
it('gives "easy" a longer interval than "good" at the same step', () => {
const good = review(initialSrs(NOW), 2, NOW);
const easy = review(initialSrs(NOW), 3, NOW);
expect(easy.intervalDays).toBeGreaterThan(good.intervalDays);
});
});