Phase 2: learning engine — history, SRS review, mistakes, stats, exam, reminders

Answer history and schedules
- Every answer is recorded (deck, activity, topic, correct/wrong, time)
  in a versioned IndexedDB log (freezmo-learning v1, stores answers +
  srs), mirrored in memory for synchronous React reads and capped at
  20k answers (oldest pruned). StudyFeed records as it grades.
- Light SM-2 schedule per activity: 1 day, 3 days, then interval x
  ease; wrong answers return in ten minutes with the ease nudged down.

Review, mistakes, stats
- Library 'Smart review' card: due count, mistakes count (latest
  answer wrong — clears itself when answered correctly), today's goal
  progress. Review sessions freeze their item list at start.
- New /review (due across decks, ?mistakes=1 global or ?deck= per
  deck) built on a shared SessionFeed with the study feed's snap/keyboard
  mechanics, wake lock and a results slide.
- New /stats: day streak, five-week calendar, accuracy per subject and
  per deck, weakest topics (>=3 answers), daily-goal progress.
- Daily goal configurable in Settings (10/20/30/50).

Exam mode
- New /exam: pick decks, question count, optional timer; no hints
  (a NoHints context hides hint rows and the self-check escape);
  results with score, per-deck and per-topic breakdown; wrong answers
  land in the mistakes set through the normal history.

Reminders (conditional)
- VAPID-gated: GET/POST/DELETE /api/push and POST /api/push/test with
  the standard route pattern, RATE_LIMIT_PUSH, zod, auth before body.
  Subscriptions stored server-side as endpoint + keys only (PUSH_DIR).
  UI hidden entirely without keys; iOS install note; service worker
  push/notificationclick handlers; scripts/send-reminders.mjs for a
  cron/systemd timer; web-push added as the one server dependency.

Verified: new phase-2 suite 42/42 (history/SRS shapes from a real study
run, mistakes clearing, review session, stats math, exam timer and
breakdowns, VAPID-less hidden state plus the configured
subscribe/validate/test/delete path against a second instance) plus
regression 74/74, mobile 52/52, wizard 80/80, declutter 116/116,
animation 8/8, native 35/35, phase1 38/38, i18n 25/25. Key parity
709/709. Lint + build clean. SW freezmo-v26.
This commit is contained in:
Martin Dvořák 2026-10-10 14:15:21 +00:00
commit e4a8e0ba01
33 changed files with 2504 additions and 47 deletions

252
lib/history.ts Normal file
View file

@ -0,0 +1,252 @@
"use client";
/**
* Answer history and the spaced-repetition schedule, kept in IndexedDB
* (unbounded event log — localStorage would fill up). The in-memory mirror
* makes reads synchronous for React (`useSyncExternalStore`); writes update
* the mirror immediately and persist asynchronously.
*
* Versioned DB `freezmo-learning` v1 with stores:
* answers keyPath id (indexes: at, deckId)
* srs keyPath key (index: dueAt) key = `${deckId}:${activityId}`
*/
import { type AnswerRecord, type SrsRecord } from "./schemas";
import { markSyncDirty } from "./sync-state";
export type { AnswerRecord, SrsRecord };
const DB_NAME = "freezmo-learning";
const DB_VERSION = 1;
const ANSWERS = "answers";
const SRS = "srs";
/** The log is pruned beyond this many answers (oldest first). */
export const MAX_ANSWERS = 20_000;
/** A wrong answer comes back after this long, even inside a session. */
export const WRONG_RETRY_MS = 10 * 60_000;
const EMPTY_ANSWERS: AnswerRecord[] = [];
const EMPTY_SRS: SrsRecord[] = [];
type Listener = () => void;
let answers: AnswerRecord[] = [];
let schedules = new Map<string, SrsRecord>();
let cachedAnswers: AnswerRecord[] = EMPTY_ANSWERS;
let cachedSrs: SrsRecord[] = EMPTY_SRS;
const listeners = new Set<Listener>();
let hydrated = false;
let hydration: Promise<void> | null = null;
function notify(): void {
cachedAnswers = answers;
cachedSrs = [...schedules.values()];
listeners.forEach((listener) => listener());
}
function openDb(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onupgradeneeded = () => {
const db = request.result;
if (!db.objectStoreNames.contains(ANSWERS)) {
const store = db.createObjectStore(ANSWERS, { keyPath: "id" });
store.createIndex("at", "at");
store.createIndex("deckId", "deckId");
}
if (!db.objectStoreNames.contains(SRS)) {
const store = db.createObjectStore(SRS, { keyPath: "key" });
store.createIndex("dueAt", "dueAt");
}
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
async function withDb<T>(mode: IDBTransactionMode, run: (db: IDBDatabase) => T): Promise<T> {
const db = await openDb();
try {
return run(db);
} finally {
// Transactions keep the connection alive until they settle; close on the
// next task so pending requests finish first.
window.setTimeout(() => db.close(), 1000);
}
}
function requestToPromise<T>(request: IDBRequest<T>): Promise<T> {
return new Promise((resolve, reject) => {
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
/** Loads IndexedDB into the mirror exactly once. Safe to call repeatedly. */
export function ensureLearningHydrated(): Promise<void> {
if (hydrated) return Promise.resolve();
if (hydration) return hydration;
hydration = (async () => {
if (typeof indexedDB === "undefined") {
hydrated = true;
return;
}
try {
await withDb("readonly", async (db) => {
const transaction = db.transaction([ANSWERS, SRS], "readonly");
const [loadedAnswers, loadedSrs] = await Promise.all([
requestToPromise(transaction.objectStore(ANSWERS).getAll() as IDBRequest<AnswerRecord[]>),
requestToPromise(transaction.objectStore(SRS).getAll() as IDBRequest<SrsRecord[]>),
]);
// Writes that happened before hydration win (dedupe by id/key).
const known = new Set(answers.map((entry) => entry.id));
const merged = [...answers, ...loadedAnswers.filter((entry) => !known.has(entry.id))];
merged.sort((a, b) => b.at - a.at);
answers = merged.slice(0, MAX_ANSWERS);
for (const entry of loadedSrs) {
const current = schedules.get(entry.key);
if (!current || current.lastAt < entry.lastAt) schedules.set(entry.key, entry);
}
});
} catch (error) {
console.warn("[freezmo] could not read the learning history", error);
} finally {
hydrated = true;
notify();
}
})();
return hydration;
}
export const learningStore = {
subscribe(listener: Listener): () => void {
listeners.add(listener);
void ensureLearningHydrated();
return () => listeners.delete(listener);
},
getAnswersSnapshot(): AnswerRecord[] {
return cachedAnswers;
},
getSrsSnapshot(): SrsRecord[] {
return cachedSrs;
},
getServerAnswers(): AnswerRecord[] {
return EMPTY_ANSWERS;
},
getServerSrs(): SrsRecord[] {
return EMPTY_SRS;
},
};
export type AnswerInput = {
deckId: string;
deckTitle: string;
activityId: string;
type: string;
topic?: string;
correct: boolean;
};
/**
* Records one answer and updates its schedule. The schedule is a light SM-2:
* correct → 1 day, then 3 days, then interval × ease; wrong → back in ten
* minutes with the ease nudged down.
*/
export function recordAnswer(input: AnswerInput, at = Date.now()): void {
const record: AnswerRecord = {
id: `${at.toString(36)}-${Math.random().toString(36).slice(2, 8)}`,
deckId: input.deckId,
deckTitle: input.deckTitle,
activityId: input.activityId,
type: input.type,
topic: input.topic,
correct: input.correct,
at,
};
answers = [record, ...answers].slice(0, MAX_ANSWERS);
const key = `${input.deckId}:${input.activityId}`;
const previous = schedules.get(key);
let ease = previous?.ease ?? 2.5;
let reps = previous?.reps ?? 0;
let intervalDays = previous?.intervalDays ?? 0;
let lapses = previous?.lapses ?? 0;
if (input.correct) {
ease = Math.min(3, ease + 0.1);
reps += 1;
intervalDays = reps === 1 ? 1 : reps === 2 ? 3 : Math.min(365, Math.round(Math.max(1, intervalDays) * ease));
} else {
ease = Math.max(1.3, ease - 0.2);
reps = 0;
intervalDays = 0;
lapses += 1;
}
const schedule: SrsRecord = {
key,
deckId: input.deckId,
activityId: input.activityId,
ease,
reps,
intervalDays,
lapses,
dueAt: at + (input.correct ? intervalDays * 86_400_000 : WRONG_RETRY_MS),
lastAt: at,
lastCorrect: input.correct,
};
schedules = new Map(schedules);
schedules.set(key, schedule);
notify();
markSyncDirty();
void (async () => {
try {
await ensureLearningHydrated();
await withDb("readwrite", (db) => {
const transaction = db.transaction([ANSWERS, SRS], "readwrite");
transaction.objectStore(ANSWERS).put(record);
transaction.objectStore(SRS).put(schedule);
});
// Prune the log beyond the cap (oldest first) in a separate pass.
const overflow = answers.length - MAX_ANSWERS;
if (overflow > 0) {
const oldest = answers.slice(MAX_ANSWERS).map((entry) => entry.id);
await withDb("readwrite", (db) => {
const transaction = db.transaction(ANSWERS, "readwrite");
const store = transaction.objectStore(ANSWERS);
for (const id of oldest) store.delete(id);
});
}
} catch (error) {
console.warn("[freezmo] could not save the answer history", error);
}
})();
}
/** Used by backup import and sync pulls. */
export async function replaceLearning(nextAnswers: AnswerRecord[], nextSrs: SrsRecord[]): Promise<void> {
const sorted = [...nextAnswers].sort((a, b) => b.at - a.at).slice(0, MAX_ANSWERS);
answers = sorted;
schedules = new Map(nextSrs.map((entry) => [entry.key, entry]));
notify();
try {
await withDb("readwrite", (db) => {
const transaction = db.transaction([ANSWERS, SRS], "readwrite");
const answersStore = transaction.objectStore(ANSWERS);
const srsStore = transaction.objectStore(SRS);
answersStore.clear();
srsStore.clear();
for (const entry of sorted) answersStore.put(entry);
for (const entry of nextSrs) srsStore.put(entry);
});
} catch (error) {
console.warn("[freezmo] could not replace the learning history", error);
}
}
/** Wipes the log and every schedule (used by the stats screen). */
export async function clearLearning(): Promise<void> {
await replaceLearning([], []);
}