"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(); let cachedAnswers: AnswerRecord[] = EMPTY_ANSWERS; let cachedSrs: SrsRecord[] = EMPTY_SRS; const listeners = new Set(); let hydrated = false; let hydration: Promise | null = null; function notify(): void { cachedAnswers = answers; cachedSrs = [...schedules.values()]; listeners.forEach((listener) => listener()); } function openDb(): Promise { 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(_mode: IDBTransactionMode, run: (db: IDBDatabase) => T | Promise): Promise { const db = await openDb(); try { return await 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(request: IDBRequest): Promise { 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 { 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), requestToPromise(transaction.objectStore(SRS).getAll() as IDBRequest), ]); // 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. */ /** * Puts specific cards on the review schedule, due right now — Teach it back's * "add the missed cards to review". A card that was already scheduled keeps * its ease and lapse count; only the due date is pulled forward. Returns how * many cards were scheduled. */ export function scheduleForReview(deckId: string, activityIds: string[], at = Date.now()): number { const changed: SrsRecord[] = []; for (const activityId of activityIds) { const key = `${deckId}:${activityId}`; const previous = schedules.get(key); changed.push({ key, deckId, activityId, ease: previous?.ease ?? 2.5, reps: previous?.reps ?? 0, intervalDays: 0, lapses: previous?.lapses ?? 0, dueAt: at, lastAt: at, lastCorrect: previous?.lastCorrect ?? false, }); } if (changed.length === 0) return 0; schedules = new Map(schedules); for (const schedule of changed) schedules.set(schedule.key, schedule); notify(); markSyncDirty(); void (async () => { try { await ensureLearningHydrated(); await withDb("readwrite", (db) => { const transaction = db.transaction(SRS, "readwrite"); const store = transaction.objectStore(SRS); for (const schedule of changed) store.put(schedule); }); } catch (error) { console.warn("[freezmo] could not schedule the review cards", error); } })(); return changed.length; } export async function replaceLearning(nextAnswers: AnswerRecord[], nextSrs: SrsRecord[]): Promise { 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 { await replaceLearning([], []); }