freezmo/lib/history.ts
Martin Dvorak e4a8e0ba01 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.
2026-10-10 14:15:21 +00:00

252 lines
8.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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([], []);
}