// ─── Chapter progress — saved on this device ────────────────────────────────── // Lightweight, on-device progress for curriculum chapters. Powers the // progress-aware behaviour of the intelligent teach engine and /teach UI. // // SSR-safe: every accessor guards `window`. Corruption-safe: reads never throw, // they fall back to an empty store. Follows the app's existing local-store // conventions (docdoe_* key, docdoe:*-updated event). import { getSubject, getChapter } from "./curriculum/index"; import type { ChapterMeta } from "./curriculum/types"; // ─── Public types ─────────────────────────────────────────────────────────── export type ChapterStatus = | "not_started" | "started" | "completed" | "revised" | "weak"; export type ConfidenceLevel = "low" | "medium" | "high"; export type ChapterProgressEntry = { key: string; // `${level}::${subjectSlug}::${chapterSlug}` level: string; subjectSlug: string; chapterSlug: string; status: ChapterStatus; studyCount: number; quizScore: number | null; // most recent quiz %, 0–100 quizAttempts: number; weakTopics: string[]; confidence: ConfidenceLevel | null; startedAt: string | null; // ISO lastStudied: string | null; // ISO completedAt: string | null; // ISO }; export type RecommendationType = "revision" | "next" | "easy_win"; export type Recommendation = { level: string; subjectSlug: string; subjectName: string; chapter: ChapterMeta; reason: string; type: RecommendationType; }; // ─── Storage plumbing ───────────────────────────────────────────────────────── const STORAGE_KEY = "docdoe_chapter_progress"; export const PROGRESS_EVENT = "docdoe:chapter-progress-updated"; type ProgressStore = Record; function canUseStorage(): boolean { return typeof window !== "undefined" && Boolean(window.localStorage); } function keyOf(level: string, subjectSlug: string, chapterSlug: string): string { return `${level}::${subjectSlug}::${chapterSlug}`; } function nowIso(): string { return new Date().toISOString(); } function readStore(): ProgressStore { if (!canUseStorage()) return {}; try { const raw = window.localStorage.getItem(STORAGE_KEY); if (!raw) return {}; const parsed = JSON.parse(raw); if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {}; return parsed as ProgressStore; } catch { return {}; } } function writeStore(store: ProgressStore): void { if (!canUseStorage()) return; try { window.localStorage.setItem(STORAGE_KEY, JSON.stringify(store)); window.dispatchEvent(new Event(PROGRESS_EVENT)); } catch { // storage full / blocked — progress is best-effort, never crash the UI } } function emptyEntry(level: string, subjectSlug: string, chapterSlug: string): ChapterProgressEntry { return { key: keyOf(level, subjectSlug, chapterSlug), level, subjectSlug, chapterSlug, status: "not_started", studyCount: 0, quizScore: null, quizAttempts: 0, weakTopics: [], confidence: null, startedAt: null, lastStudied: null, completedAt: null, }; } function mutate( level: string, subjectSlug: string, chapterSlug: string, apply: (entry: ChapterProgressEntry) => void, ): ChapterProgressEntry { const store = readStore(); const key = keyOf(level, subjectSlug, chapterSlug); const entry = store[key] ?? emptyEntry(level, subjectSlug, chapterSlug); apply(entry); store[key] = entry; writeStore(store); return entry; } // ─── Reads ──────────────────────────────────────────────────────────────────── export function getChapterEntry( level: string, subjectSlug: string, chapterSlug: string, ): ChapterProgressEntry | null { const store = readStore(); return store[keyOf(level, subjectSlug, chapterSlug)] ?? null; } export function getProgress(): ChapterProgressEntry[] { return Object.values(readStore()); } export function statusLabel(status: ChapterStatus): string { switch (status) { case "not_started": return "Not started"; case "started": return "In progress"; case "completed": return "Completed"; case "revised": return "Revised"; case "weak": return "Needs revision"; } } // ─── Writes ─────────────────────────────────────────────────────────────────── export function markChapterStarted( level: string, subjectSlug: string, chapterSlug: string, ): ChapterProgressEntry { return mutate(level, subjectSlug, chapterSlug, (e) => { e.studyCount += 1; e.lastStudied = nowIso(); if (!e.startedAt) e.startedAt = nowIso(); // Don't downgrade a completed/revised chapter just because it was reopened. if (e.status === "not_started") e.status = "started"; }); } export function markChapterCompleted( level: string, subjectSlug: string, chapterSlug: string, ): ChapterProgressEntry { return mutate(level, subjectSlug, chapterSlug, (e) => { e.status = "completed"; e.completedAt = nowIso(); e.lastStudied = nowIso(); if (!e.startedAt) e.startedAt = nowIso(); }); } export function markChapterRevised( level: string, subjectSlug: string, chapterSlug: string, ): ChapterProgressEntry { return mutate(level, subjectSlug, chapterSlug, (e) => { e.status = "revised"; e.lastStudied = nowIso(); if (!e.startedAt) e.startedAt = nowIso(); }); } export function markChapterWeak( level: string, subjectSlug: string, chapterSlug: string, weakTopics?: string[], ): ChapterProgressEntry { return mutate(level, subjectSlug, chapterSlug, (e) => { e.status = "weak"; e.lastStudied = nowIso(); if (!e.startedAt) e.startedAt = nowIso(); if (weakTopics && weakTopics.length > 0) { const merged = new Set([...e.weakTopics, ...weakTopics]); e.weakTopics = Array.from(merged).slice(0, 8); } }); } export function saveQuizScore( level: string, subjectSlug: string, chapterSlug: string, score: number, ): ChapterProgressEntry { const clamped = Math.max(0, Math.min(100, Math.round(score))); return mutate(level, subjectSlug, chapterSlug, (e) => { e.quizScore = clamped; e.quizAttempts += 1; e.lastStudied = nowIso(); if (!e.startedAt) e.startedAt = nowIso(); if (clamped < 50) { e.status = "weak"; } else if (e.status === "not_started" || e.status === "started") { // A solid score on an unfinished chapter counts as a pass-through revise. e.status = clamped >= 80 ? "revised" : "started"; } e.confidence = clamped >= 80 ? "high" : clamped >= 50 ? "medium" : "low"; }); } export function resetChapter( level: string, subjectSlug: string, chapterSlug: string, ): void { const store = readStore(); delete store[keyOf(level, subjectSlug, chapterSlug)]; writeStore(store); } // ─── Subscriptions ────────────────────────────────────────────────────────── /** Subscribe to progress changes on this device. Returns an unsubscribe fn. */ export function subscribeToProgress(listener: () => void): () => void { if (typeof window === "undefined") return () => {}; const handler = () => listener(); window.addEventListener(PROGRESS_EVENT, handler); window.addEventListener("storage", handler); return () => { window.removeEventListener(PROGRESS_EVENT, handler); window.removeEventListener("storage", handler); }; } // ─── Recommendations ────────────────────────────────────────────────────────── function isUnfinished(status: ChapterStatus): boolean { return status === "not_started" || status === "started"; } /** * Suggest what to study next, blending three signals: * - revision: chapters marked weak or with a low last quiz * - next: the next unfinished chapter in the preferred subject's sequence * - easy_win: exam-relevant, easy chapters not yet completed * * When a subject is given, lead with the next chapter in that subject; otherwise * lead with revision. Deduped by chapter, capped at `n`. */ export function getRecommendedNextChapters( preferLevel?: string | null, preferSubject?: string | null, n = 3, ): Recommendation[] { const store = readStore(); const entries = Object.values(store); const revisionRecs: Recommendation[] = []; for (const e of entries) { const isWeak = e.status === "weak"; const lowQuiz = e.quizScore !== null && e.quizScore < 50; if (!isWeak && !lowQuiz) continue; const subject = getSubject(e.level, e.subjectSlug); const chapter = getChapter(e.level, e.subjectSlug, e.chapterSlug); if (!subject || !chapter) continue; const reason = isWeak ? "You marked this weak — revise it before moving on" : `Last quiz was ${e.quizScore}% — a revision will help`; revisionRecs.push({ level: e.level, subjectSlug: e.subjectSlug, subjectName: subject.name, chapter, reason, type: "revision", }); } const nextRecs: Recommendation[] = []; if (preferLevel && preferSubject) { const subject = getSubject(preferLevel, preferSubject); if (subject) { for (const chapter of subject.chapters) { const entry = store[keyOf(preferLevel, preferSubject, chapter.slug)]; const status: ChapterStatus = entry?.status ?? "not_started"; if (!isUnfinished(status)) continue; nextRecs.push({ level: preferLevel, subjectSlug: preferSubject, subjectName: subject.name, chapter, reason: status === "started" ? `Continue your ${subject.name} sequence` : `Next up in ${subject.name}`, type: "next", }); break; // only the single next chapter in sequence } } } const easyWinRecs: Recommendation[] = []; { const level = preferLevel ?? null; const subjectSlug = preferSubject ?? null; const subjects = subjectsToScan(level, subjectSlug); for (const ctx of subjects) { for (const chapter of ctx.subject.chapters) { const entry = store[keyOf(ctx.level, ctx.subjectSlug, chapter.slug)]; const status: ChapterStatus = entry?.status ?? "not_started"; if (status === "completed" || status === "revised") continue; const highYield = chapter.pyqFrequency === "very_high" || chapter.pyqFrequency === "high"; if (highYield && chapter.difficulty === "easy") { easyWinRecs.push({ level: ctx.level, subjectSlug: ctx.subjectSlug, subjectName: ctx.subject.name, chapter, reason: "Quick to learn — exam-relevant", type: "easy_win", }); } } } } const ordered = preferSubject ? [...nextRecs, ...revisionRecs, ...easyWinRecs] : [...revisionRecs, ...nextRecs, ...easyWinRecs]; const seen = new Set(); const deduped: Recommendation[] = []; for (const rec of ordered) { const id = keyOf(rec.level, rec.subjectSlug, rec.chapter.slug); if (seen.has(id)) continue; seen.add(id); deduped.push(rec); if (deduped.length >= n) break; } return deduped; } type SubjectScanContext = { level: string; subjectSlug: string; subject: { name: string; chapters: ChapterMeta[] } }; function subjectsToScan(level: string | null, subjectSlug: string | null): SubjectScanContext[] { if (level && subjectSlug) { const subject = getSubject(level, subjectSlug); return subject ? [{ level, subjectSlug, subject }] : []; } return []; }