DocDoeAI / src /lib /study-progress.ts
asnannp's picture
deploy: sync backend to Space root (learn-lesson HF cache fix)
65d68f5
Raw History Blame Contribute Delete
12.3 kB
// ─── 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<string, ChapterProgressEntry>;
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<string>();
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 [];
}