Download src/lib/study-progress.ts from asnannp/DocDoeAI: direct link, hf CLI and curl.
- Browser
- Download file 12.3 kB
-
https://huggingface.co/spaces/asnannp/DocDoeAI/resolve/main/src/lib/study-progress.ts
- Command line
-
hf download hf://spaces/asnannp/DocDoeAI/src/lib/study-progress.ts
-
curl -L -o study-progress.ts https://huggingface.co/spaces/asnannp/DocDoeAI/resolve/main/src/lib/study-progress.ts
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 []; | |
| } | |