import type { StudyIntent } from "@/lib/study-intents"; import { getPhase3ActivityRemote, savePhase3ActivityRemote } from "@/lib/api/phase3-activity"; import { getRoadmapRemote, listRoadmapsRemote, saveRoadmapRemote } from "@/lib/api/learn-anything-roadmap"; export type StructuredStudyAction = { label: string; intent: StudyIntent; prompt: string; }; export type StructuredStudySection = { title: string; summary: string; points: string[]; action?: StructuredStudyAction; }; export type StructuredStudyResponse = { topic: string; contextNote: string; sections: StructuredStudySection[]; footerActions: StructuredStudyAction[]; }; export type LearnAnythingRoadmapInput = { topic: string; currentLevel: string; timeLimit: string; weeklyCommitment: string; goal: string; learningStyle: string; }; /** * How the roadmap was produced. "starter" = the deterministic offline outline; * "ai" = the personalised course plan generated by the learning backend. */ export type RoadmapGenerationSource = "starter" | "ai"; /** Provenance and honesty metadata carried by AI-generated roadmaps. */ export type RoadmapAiMeta = { planTitle: string; dataSourceLabel: string; confidence: number; prerequisites: string[]; finalOutcome: string; estimatedTotalMinutes: number; trustNotes: string[]; }; export type RoadmapLessonCompletion = { /** Stable roadmap topic key; legacy rows may contain "today". */ lessonId: string; title: string; completedAt: string; }; export type LearnAnythingRoadmap = { id: string; /** How this roadmap was generated, preserved through local and remote saves. */ generationSource: RoadmapGenerationSource; topic: string; currentLevel: string; timeLimit: string; weeklyCommitment: string; goal: string; learningStyle: string; overview: string; todayLesson: { title: string; minutes: number; tasks: string[]; }; weeklyModules: Array<{ week: string; title: string; outcome: string; tasks: string[]; }>; videoNotes: string[]; smartNotes: string[]; flashcards: Array<{ front: string; back: string }>; quiz: Array<{ question: string; answer: string }>; practiceTask: string; progress: { completed: number; total: number; nextCheckpoint: string; }; revision: string[]; /** Weeks the learner marked done (module `week` labels). Progress derives from this. */ completedWeeks?: string[]; /** Per-topic learning state, keyed by roadmapTopicKey(). Absent = not started. */ topicStatus?: Record; /** True once the "see what you already know" diagnostic has run. */ diagnosticDone?: boolean; /** Spaced-repetition state per flashcard, keyed by the card front. */ srState?: Record; /** Last progressive-quiz run on this roadmap. */ quizResult?: RoadmapQuizResult; /** Explicit lesson-complete actions. Opening Study Chat never writes these. */ completedLessons?: RoadmapLessonCompletion[]; /** Present only when generationSource === "ai". */ aiMeta?: RoadmapAiMeta; }; export type FlashcardReviewState = { /** Leitner box 0-3; box 3 answered correctly again = retired. */ box: number; /** ISO timestamp the card becomes due again; absent = due now. */ dueAt?: string; retired?: boolean; }; export type RoadmapQuizResult = { answeredAt: string; correct: number; total: number; missedQuestions: string[]; }; export type RoadmapTopicStatus = "covered" | "mastered"; export type RoadmapTopic = { key: string; week: string; moduleTitle: string; title: string; }; export type RoadmapModuleNote = { week: string; moduleTitle: string; summary: string; keyPoints: string[]; selfCheck: string[]; }; export type TuitionSupportInput = { subject: string; chapter: string; missionTitle: string; goal: string; estimatedMinutes: number; keyNotes: string[]; keywords: string[]; weakAreas: string[]; examQuestion: string; modelAnswer: string[]; memoryLine: string; }; export type WeekendPyqQuestion = { id: string; section: "A" | "B"; label: string; question: string; marks: number; expectedAnswer: string; keywords: string[]; weakArea: string; }; export type WeekendPyqTest = { id: string; title: string; subject: string; chapter: string; weekLabel: string; estimatedMinutes: number; safePatternNote: string; questions: WeekendPyqQuestion[]; }; export type WeekendPyqResult = { score: number; maxScore: number; correctAnswer: string; missingKeywords: string[]; weakAreas: string[]; suggestedRevision: string; }; export type RevisionQueueItem = { id: string; title: string; source: string; dueLabel: string; minutes: number; action: string; }; export type RevisionQueue = { dueToday: RevisionQueueItem[]; savedWeakAreas: string[]; flashcards: Array<{ front: string; back: string }>; quickRecall: { question: string; answer: string }; upcomingReviews: RevisionQueueItem[]; examReadiness: string[]; }; export type WeakAreaRecovery = { topic: string; severity: "High" | "Medium" | "Review"; whyWeak: string; lastMistakeSource: string; recommendedFix: string; actionPrompt: string; }; export type Phase3Activity = { pyqSubmittedAt?: string; pyqScore?: number; pyqMaxScore?: number; weakTopicsAdded?: string[]; revisionSavedTopics?: string[]; recoveryActions?: string[]; /** Explicit completed lessons from Learn Anything. */ roadmapLessonCompletions?: Array; /** Explicit completed quiz runs from Learn Anything. */ roadmapQuizAttempts?: Array; }; export type ProgressSnapshot = { metrics: Array<{ label: string; value: string; note: string }>; weakAreas: string[]; weakAreasIsSample: boolean; nextActions: Array<{ label: string; href: string }>; empty: boolean; }; export const PHASE3_ACTIVITY_KEY = "docdoe:phase3-support-activity"; export const LEARN_ANYTHING_ROADMAP_PREFIX = "docdoe:learn-anything-roadmap:"; export const LEARN_ANYTHING_LAST_ROADMAP_KEY = "docdoe:learn-anything-last-roadmap"; function slugify(value: string) { return value .toLowerCase() .normalize("NFKD") .replace(/[^a-z0-9]+/g, "-") .replace(/(^-|-$)/g, ""); } function titleCase(value: string) { return value .split(/\s+/) .filter(Boolean) .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) .join(" "); } export function extractStudyTopic(prompt: string): string { const cleaned = prompt .replace(/\b(please|teach|explain|me|chapter|full|fully|from|basics|zero|with|notes|pyq|video|plan|make|give)\b/gi, " ") .replace(/[?.!,]+/g, " ") .replace(/\s+/g, " ") .trim(); return titleCase(cleaned || "This Topic"); } export function shouldUseStructuredStudyResponse(prompt: string, intent: StudyIntent): boolean { if (intent !== "teach_topic" && intent !== "explain_source") return false; const value = prompt.toLowerCase(); return ( /\bchapter\b/.test(value) || /\bteach\b.*\bfully\b/.test(value) || /\bfrom\s+(basics|zero|scratch)\b/.test(value) || /\bvideo\s+plan\b.*\bnotes\b.*\bpyq\b/.test(value) ); } export function buildStructuredStudyResponse(prompt: string): StructuredStudyResponse { const topic = extractStudyTopic(prompt); const topicLower = topic.toLowerCase(); const isRenaissance = /renaissance|italy|humanism/.test(topicLower); const overview = isRenaissance ? "Renaissance means rebirth or revival. The exam focus is why Italy became the starting point." : `${topic} is best learned by first understanding the idea, then turning it into practice, notes, and exam-style answers.`; const corePoints = isRenaissance ? ["City-states became wealthy.", "Trade brought new ideas.", "Greek and Roman knowledge was revived.", "Patrons supported artists and scholars."] : [ `Start with the meaning of ${topic}.`, "Identify the key terms that repeat in questions.", "Practise one short answer before reading the model answer.", "Save the weak point for revision.", ]; return { topic, contextNote: "This is a Study Chat plan. It does not replace Today's Tuition unless a future backend handoff explicitly supports that.", sections: [ { title: "Chapter Overview", summary: overview, points: corePoints.slice(0, 3), action: { label: "Practice separately", intent: "generate_quiz", prompt: `Make a compact practice quiz on ${topic}` }, }, { title: "Video Plan", summary: "Turn the topic into a short visual lesson with clear scenes.", points: ["Scene 1: meaning and context", "Scene 2: main concept flow", "Scene 3: exam recap"], action: { label: "Create video plan", intent: "create_video_plan", prompt: `Create a study video plan for ${topic}` }, }, { title: "Concept Breakdown", summary: "Break the chapter into small ideas before solving questions.", points: corePoints, action: { label: "Make it simpler", intent: "teach_topic", prompt: `Explain ${topic} in simpler words` }, }, { title: "Smart Notes", summary: "Short notes for recall and answer writing.", points: corePoints.map((point) => `Note: ${point}`), action: { label: "Generate notes", intent: "generate_notes", prompt: `Make smart notes on ${topic}` }, }, { title: "Flashcards", summary: "Use active recall instead of rereading.", points: [`Front: What is ${topic}?`, "Back: Definition plus one example.", "Front: Which keyword gives marks?"], action: { label: "Generate flashcards", intent: "generate_flashcards", prompt: `Make flashcards for ${topic}` }, }, { title: "PYQ Practice", summary: "Use exam-relevant and frequently asked question types without promising exact exam repeats.", points: ["One 2-mark question", "One 4-mark answer-writing question", "Check missing keywords after writing"], action: { label: "Give PYQ questions", intent: "analyze_pyq", prompt: `Give exam-relevant PYQ practice for ${topic}` }, }, { title: "Answer Correction", summary: "The student should write first, then compare.", points: ["Write the answer.", "Check keywords.", "Improve the final wording."], action: { label: "Correct my answer", intent: "generate_exam_answer", prompt: `Correct my answer on ${topic}` }, }, { title: "Ask Doubt", summary: "Ask another doubt here without changing the official tuition class.", points: ["Ask a smaller question.", "Request Malayalam explanation.", "Ask for one example."], action: { label: "Ask another doubt", intent: "teach_topic", prompt: `I have another doubt in ${topic}: ` }, }, ], footerActions: [ { label: "Practice separately", intent: "generate_quiz", prompt: `Make a 5-question practice quiz on ${topic}` }, { label: "Add to Tuition Plan", intent: "continue_learning", prompt: `TODO backend handoff: add ${topic} to tuition plan if supported` }, { label: "Ask another doubt", intent: "teach_topic", prompt: `I have another doubt in ${topic}: ` }, ], }; } export function createRoadmapId(topic: string) { const base = slugify(topic) || "roadmap"; return `${base}-${Date.now().toString(36)}`; } export function buildLearnAnythingRoadmap( input: LearnAnythingRoadmapInput, id = createRoadmapId(input.topic), ): LearnAnythingRoadmap { const topic = titleCase(input.topic.trim() || "Deep Learning"); const timeLimit = input.timeLimit || "3 months"; const weeklyCommitment = input.weeklyCommitment || "5 hours per week"; const style = input.learningStyle || "Video plus practice"; const modules = [ { week: "Week 1", title: "Orientation and prerequisites", outcome: `Know what ${topic} means and what basics you need.`, tasks: ["Map prerequisites", "Learn the core vocabulary", "Do a small diagnostic quiz"], }, { week: "Week 2", title: "Foundations", outcome: "Build the beginner base without rushing.", tasks: ["Study the first concepts", "Make smart notes", "Complete one guided practice task"], }, { week: "Weeks 3-4", title: "Core concepts", outcome: "Understand the main ideas well enough to explain them.", tasks: ["Watch or read one lesson block", "Create flashcards", "Answer short checks"], }, { week: "Weeks 5-8", title: "Projects and application", outcome: "Use the topic in practical tasks.", tasks: ["Build one mini project", "Review mistakes", "Repeat the hard concepts"], }, { week: "Weeks 9-10", title: "Intermediate depth", outcome: "Move from knowing terms to solving real problems.", tasks: ["Study deeper examples", "Compare methods", "Write a personal cheat sheet"], }, { week: "Weeks 11-12", title: "Final project and revision", outcome: "Finish with evidence that you can use the skill.", tasks: ["Complete final task", "Revise flashcards", "Plan the next roadmap"], }, ]; return { id, generationSource: "starter", topic, currentLevel: input.currentLevel || "Beginner", timeLimit, weeklyCommitment, goal: input.goal || `Learn ${topic} with practical confidence`, learningStyle: style, overview: `${topic} roadmap for ${timeLimit}. Built for ${input.currentLevel || "a beginner"} with ${weeklyCommitment}.`, todayLesson: { title: `Start ${topic}: map the field`, minutes: 35, tasks: ["Write what you already know", "Learn the first five terms", "Finish a 5-question check"], }, weeklyModules: modules, videoNotes: [ "Use short lessons before long lectures.", "Pause after every major idea and write one example.", `Prefer ${style.toLowerCase()} for the first two weeks.`, ], smartNotes: [ `${topic} needs vocabulary, examples, practice, and review.`, "Do not skip prerequisites if the first quiz feels confusing.", "Every week ends with one proof-of-learning task.", ], flashcards: [ { front: `What is the goal of this ${topic} roadmap?`, back: input.goal || "Build practical understanding step by step." }, { front: "When do I move to the next module?", back: "After a lesson, a practice task, and a short recall check." }, { front: "What if I get stuck?", back: "Return to prerequisites and ask in Study Chat." }, ], quiz: [ { question: `What is the first outcome for ${topic}?`, answer: "Understand the field and map prerequisites." }, { question: "What should happen every week?", answer: "A lesson, practice task, and revision checkpoint." }, { question: "Why avoid jumping to advanced topics first?", answer: "Weak prerequisites make later topics feel random." }, ], practiceTask: `Build a one-page learner map for ${topic}: prerequisites, key terms, first project, and one doubt to ask.`, progress: { completed: 0, total: modules.length, nextCheckpoint: "Finish today's lesson and first recall quiz.", }, revision: ["Review today's five terms tomorrow.", "Revise Week 1 cards after three days.", "Do a weekly checkpoint every Sunday."], }; } /** Minimal structural slice of the backend CoursePlanResult this mapper needs. */ export type CoursePlanForRoadmap = { title: string; final_outcome: string; next_action: string; data_source_label: string; confidence: number; trust_notes: string[]; prerequisites: string[]; key_concepts: string[]; lesson_outline: string[]; lessons_total: number; estimated_total_minutes: number; modules: Array<{ title: string; estimated_minutes: number; lessons: Array<{ title: string; duration_minutes: number; task: string }>; }>; practice_tasks: Array<{ title: string; description: string }>; practice_questions: Array<{ question: string; answer_hint: string }>; revision_plan: Array<{ timing: string; task: string }>; revision_checkpoints: Array<{ title: string }>; }; /** Spread backend modules across the learner's chosen timeline. */ export function roadmapWeekLabel( moduleIndex: number, moduleCount: number, timeLimit: string, ): string { const monthMatch = timeLimit.match(/(\d+)\s*months?/i); if (!monthMatch || moduleCount <= 0) return `Week ${moduleIndex + 1}`; const totalWeeks = Math.max(moduleCount, Number(monthMatch[1]) * 4); const start = Math.floor((moduleIndex * totalWeeks) / moduleCount) + 1; const end = Math.max( start, Math.floor(((moduleIndex + 1) * totalWeeks) / moduleCount), ); return start === end ? `Week ${start}` : `Weeks ${start}-${end}`; } /** * Map a validated backend course plan into the roadmap contract every Learn * Anything surface already understands. Additive: all practice/quiz/topic * state helpers keep working; `aiMeta` carries provenance for honest display. */ export function coursePlanToRoadmap( plan: CoursePlanForRoadmap, input: LearnAnythingRoadmapInput, id = createRoadmapId(input.topic), ): LearnAnythingRoadmap { const topic = titleCase(input.topic.trim() || plan.title || "New Skill"); const weeklyModules = plan.modules.map((module, index) => ({ week: roadmapWeekLabel( index, plan.modules.length, input.timeLimit || "3 months", ), title: module.title, outcome: module.lessons[0]?.task || `Finish the ${module.lessons.length} lessons in ${module.title}.`, tasks: module.lessons.map((lesson) => lesson.title), })); const firstModule = plan.modules[0]; const firstLesson = firstModule?.lessons[0]; const quiz = plan.practice_questions .filter((item) => item.question && item.answer_hint) .slice(0, 8) .map((item) => ({ question: item.question, answer: item.answer_hint })); const revision = plan.revision_plan .slice(0, 5) .map((item) => `${item.timing}: ${item.task}`); return { id, generationSource: "ai", topic, currentLevel: input.currentLevel || "Complete beginner", timeLimit: input.timeLimit || "3 months", weeklyCommitment: input.weeklyCommitment || "5 hours per week", goal: input.goal || plan.final_outcome, learningStyle: input.learningStyle || "Balanced theory and practice", overview: plan.final_outcome || plan.title, todayLesson: { title: firstLesson?.title ?? weeklyModules[0]?.title ?? `Start ${topic}`, minutes: Math.max(10, Math.min(90, firstLesson?.duration_minutes ?? 25)), tasks: (firstModule?.lessons ?? []) .slice(0, 3) .map((lesson) => lesson.task || lesson.title) .filter(Boolean), }, weeklyModules, videoNotes: plan.lesson_outline.slice(0, 8), smartNotes: plan.key_concepts.slice(0, 10), flashcards: plan.key_concepts.slice(0, 8).map((concept) => ({ front: concept, back: `Explain "${concept}" in your own words, with one concrete example.`, })), quiz, practiceTask: plan.practice_tasks[0]?.description || plan.next_action || `Apply today's ${topic} lesson in one small hands-on exercise.`, progress: { completed: 0, total: Math.max(plan.lessons_total, weeklyModules.length), nextCheckpoint: plan.revision_checkpoints[0]?.title ?? "Finish today's lesson and first recall quiz.", }, revision: revision.length ? revision : ["Review today's key terms tomorrow.", "Revise Week 1 after three days."], aiMeta: { planTitle: plan.title, dataSourceLabel: plan.data_source_label, confidence: plan.confidence, prerequisites: plan.prerequisites, finalOutcome: plan.final_outcome, estimatedTotalMinutes: plan.estimated_total_minutes, trustNotes: plan.trust_notes, }, }; } /** * Flip one weekly module between done and not-done, recomputing progress from * the set of completed weeks. Pure — the caller persists the returned roadmap. */ export function toggleRoadmapModuleDone(roadmap: LearnAnythingRoadmap, week: string): LearnAnythingRoadmap { const done = new Set(roadmap.completedWeeks ?? []); if (done.has(week)) { done.delete(week); } else { done.add(week); } const completedWeeks = roadmap.weeklyModules.map((module) => module.week).filter((label) => done.has(label)); return { ...roadmap, completedWeeks, progress: { ...roadmap.progress, completed: completedWeeks.length, total: roadmap.weeklyModules.length, }, }; } export function roadmapTopicKey(week: string, index: number): string { return `${week}::${index}`; } /** Every module task is a learnable topic — the clickable unit of the plan. */ export function roadmapTopics(roadmap: LearnAnythingRoadmap): RoadmapTopic[] { return roadmap.weeklyModules.flatMap((module) => module.tasks.map((task, index) => ({ key: roadmapTopicKey(module.week, index), week: module.week, moduleTitle: module.title, title: task, })), ); } /** * Set one topic's status (or clear it with null). Also syncs module-level * completedWeeks: a module counts done once every topic in it is at least * covered, so the existing progress bar keeps meaning something. */ export function setRoadmapTopicStatus( roadmap: LearnAnythingRoadmap, topicKey: string, status: RoadmapTopicStatus | null, ): LearnAnythingRoadmap { const topicStatus: Record = { ...(roadmap.topicStatus ?? {}) }; if (status === null) { delete topicStatus[topicKey]; } else { topicStatus[topicKey] = status; } const completedWeeks = roadmap.weeklyModules .filter((module) => module.tasks.every((_, i) => topicStatus[roadmapTopicKey(module.week, i)])) .map((module) => module.week); return { ...roadmap, topicStatus, completedWeeks, progress: { ...roadmap.progress, completed: completedWeeks.length, total: roadmap.weeklyModules.length, }, }; } /** * Index of the module the learner is currently on: the first not-yet-completed * one (matches the "Now" marker in the roadmap list). Equals the module count * when every module is done. */ export function activeRoadmapModuleIndex(roadmap: LearnAnythingRoadmap): number { const completedWeeks = new Set(roadmap.completedWeeks ?? []); const statusMap = roadmap.topicStatus ?? {}; const index = roadmap.weeklyModules.findIndex( (module) => !completedWeeks.has(module.week) && module.tasks.some( (_task, taskIndex) => !statusMap[roadmapTopicKey(module.week, taskIndex)], ), ); return index === -1 ? roadmap.weeklyModules.length : index; } /** The exact next unfinished lesson/task, including its stable persistence key. */ export function activeRoadmapTopic( roadmap: LearnAnythingRoadmap, ): RoadmapTopic | null { const completedWeeks = new Set(roadmap.completedWeeks ?? []); const statusMap = roadmap.topicStatus ?? {}; return ( roadmapTopics(roadmap).find( (topic) => !completedWeeks.has(topic.week) && !statusMap[topic.key], ) ?? null ); } /** Complete only the lesson the learner actually finished, never a whole module. */ export function completeActiveRoadmapTopic( roadmap: LearnAnythingRoadmap, ): LearnAnythingRoadmap { const active = activeRoadmapTopic(roadmap); return active ? setRoadmapTopicStatus(roadmap, active.key, "covered") : roadmap; } /** * Mark the active module's topics covered — advancing today's session to the * next module and moving both the progress bar (topicStatus) and the week list * (completedWeeks) forward together. No-op once the whole plan is complete. * Pure — the caller persists the returned roadmap. */ export function completeActiveRoadmapModule( roadmap: LearnAnythingRoadmap, ): LearnAnythingRoadmap { const active = roadmap.weeklyModules[activeRoadmapModuleIndex(roadmap)]; if (!active) return roadmap; return active.tasks.reduce( (next, _task, index) => setRoadmapTopicStatus(next, roadmapTopicKey(active.week, index), "covered"), roadmap, ); } /** Diagnostic result: everything the learner already knows becomes covered. */ export function applyRoadmapDiagnostic(roadmap: LearnAnythingRoadmap, knownTopicKeys: string[]): LearnAnythingRoadmap { let next: LearnAnythingRoadmap = { ...roadmap, diagnosticDone: true }; for (const key of knownTopicKeys) { next = setRoadmapTopicStatus(next, key, "covered"); } return next; } export function roadmapTopicStats(roadmap: LearnAnythingRoadmap): { total: number; covered: number; mastered: number } { const topics = roadmapTopics(roadmap); const statusMap = roadmap.topicStatus ?? {}; let covered = 0; let mastered = 0; for (const topic of topics) { const status = statusMap[topic.key]; if (status === "mastered") mastered += 1; else if (status === "covered") covered += 1; } return { total: topics.length, covered, mastered }; } /** * Structured study notes, one per weekly module, derived deterministically * from the roadmap itself (never stored, so they can't drift out of sync * with the plan). The "turn into" actions in the UI convert these into * real flashcards / quizzes / exam notes through Study Chat. */ export function roadmapModuleNotes(roadmap: LearnAnythingRoadmap): RoadmapModuleNote[] { return roadmap.weeklyModules.map((module) => ({ week: module.week, moduleTitle: module.title, summary: `${module.outcome} This is the ${module.week} focus of your ${roadmap.topic} plan at ${roadmap.currentLevel.toLowerCase()} level.`, keyPoints: module.tasks.map((task) => `${task} — capture one example or takeaway in your own words.`), selfCheck: [ `Explain "${module.title}" for ${roadmap.topic} out loud without looking at these notes.`, "Write down the one idea that still feels fuzzy and take it to Study Chat.", ], })); } const SR_BOX_INTERVAL_DAYS = [1, 3, 7]; /** * Leitner review: remembering promotes the card one box (1d -> 3d -> 7d, * then retired); forgetting sends it back to box 0, due immediately. */ export function reviewFlashcard( roadmap: LearnAnythingRoadmap, cardFront: string, remembered: boolean, now: Date = new Date(), ): LearnAnythingRoadmap { const previous = roadmap.srState?.[cardFront]; let next: FlashcardReviewState; if (!remembered) { next = { box: 0 }; } else { const box = Math.min((previous?.box ?? 0) + 1, SR_BOX_INTERVAL_DAYS.length); if ((previous?.box ?? 0) >= SR_BOX_INTERVAL_DAYS.length) { next = { box: SR_BOX_INTERVAL_DAYS.length, retired: true }; } else { const days = SR_BOX_INTERVAL_DAYS[box - 1]; next = { box, dueAt: new Date(now.getTime() + days * 24 * 60 * 60 * 1000).toISOString() }; } } return { ...roadmap, srState: { ...(roadmap.srState ?? {}), [cardFront]: next }, }; } /** Cards due for review now: never seen, sent back to box 0, or past dueAt. */ export function dueFlashcards( roadmap: LearnAnythingRoadmap, now: Date = new Date(), ): Array<{ front: string; back: string }> { return roadmap.flashcards.filter((card) => { const state = roadmap.srState?.[card.front]; if (!state) return true; if (state.retired) return false; if (!state.dueAt) return true; return new Date(state.dueAt).getTime() <= now.getTime(); }); } export function recordRoadmapQuizResult( roadmap: LearnAnythingRoadmap, result: { correct: number; total: number; missedQuestions: string[] }, now: Date = new Date(), ): LearnAnythingRoadmap { return { ...roadmap, quizResult: { ...result, answeredAt: now.toISOString() }, }; } /** * A lesson is recorded only when the learner explicitly marks it complete. * This intentionally does not infer module or topic progress from opening * Study Chat: those states require their own observed actions. */ export function recordRoadmapLessonCompletion( roadmap: LearnAnythingRoadmap, topicKey: string = "today", title: string = roadmap.todayLesson.title, now: Date = new Date(), ): LearnAnythingRoadmap { if (roadmap.completedLessons?.some((lesson) => lesson.lessonId === topicKey)) return roadmap; return { ...roadmap, completedLessons: [ ...(roadmap.completedLessons ?? []), { lessonId: topicKey, title, completedAt: now.toISOString(), }, ], }; } type StoredLearnAnythingRoadmap = Omit & { generationSource?: RoadmapGenerationSource; }; function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } function isStringArray(value: unknown): value is string[] { return Array.isArray(value) && value.every((item) => typeof item === "string"); } function isFiniteNumber(value: unknown): value is number { return typeof value === "number" && Number.isFinite(value); } function isRoadmapModule(value: unknown): boolean { return ( isRecord(value) && typeof value.week === "string" && typeof value.title === "string" && typeof value.outcome === "string" && isStringArray(value.tasks) ); } function isFlashcard(value: unknown): boolean { return isRecord(value) && typeof value.front === "string" && typeof value.back === "string"; } function isQuizQuestion(value: unknown): boolean { return isRecord(value) && typeof value.question === "string" && typeof value.answer === "string"; } function isTopicStatus(value: unknown): boolean { return value === undefined || ( isRecord(value) && Object.values(value).every((status) => status === "covered" || status === "mastered") ); } function isFlashcardState(value: unknown): boolean { if (value === undefined) return true; if (!isRecord(value)) return false; return Object.values(value).every((state) => ( isRecord(state) && isFiniteNumber(state.box) && (state.dueAt === undefined || typeof state.dueAt === "string") && (state.retired === undefined || typeof state.retired === "boolean") )); } function isQuizResult(value: unknown): boolean { return value === undefined || ( isRecord(value) && typeof value.answeredAt === "string" && isFiniteNumber(value.correct) && isFiniteNumber(value.total) && isStringArray(value.missedQuestions) ); } function isLessonCompletions(value: unknown): boolean { return value === undefined || ( Array.isArray(value) && value.every((lesson) => ( isRecord(lesson) && typeof lesson.lessonId === "string" && lesson.lessonId.length > 0 && typeof lesson.title === "string" && typeof lesson.completedAt === "string" )) ); } function isStoredLearnAnythingRoadmap(value: unknown): value is StoredLearnAnythingRoadmap { return ( isRecord(value) && typeof value.id === "string" && value.id.length > 0 && (value.generationSource === undefined || value.generationSource === "starter" || value.generationSource === "ai") && typeof value.topic === "string" && value.topic.length > 0 && typeof value.currentLevel === "string" && typeof value.timeLimit === "string" && typeof value.weeklyCommitment === "string" && typeof value.goal === "string" && typeof value.learningStyle === "string" && typeof value.overview === "string" && isRecord(value.todayLesson) && typeof value.todayLesson.title === "string" && isFiniteNumber(value.todayLesson.minutes) && isStringArray(value.todayLesson.tasks) && Array.isArray(value.weeklyModules) && value.weeklyModules.every(isRoadmapModule) && isStringArray(value.videoNotes) && isStringArray(value.smartNotes) && Array.isArray(value.flashcards) && value.flashcards.every(isFlashcard) && Array.isArray(value.quiz) && value.quiz.every(isQuizQuestion) && typeof value.practiceTask === "string" && isRecord(value.progress) && isFiniteNumber(value.progress.completed) && isFiniteNumber(value.progress.total) && typeof value.progress.nextCheckpoint === "string" && isStringArray(value.revision) && (value.completedWeeks === undefined || isStringArray(value.completedWeeks)) && isTopicStatus(value.topicStatus) && (value.diagnosticDone === undefined || typeof value.diagnosticDone === "boolean") && isFlashcardState(value.srState) && isQuizResult(value.quizResult) && isLessonCompletions(value.completedLessons) ); } function normalizeLearnAnythingRoadmap( value: unknown, ): { roadmap: LearnAnythingRoadmap; migrated: boolean } | null { if (!isStoredLearnAnythingRoadmap(value)) return null; const source: RoadmapGenerationSource = value.generationSource === "ai" ? "ai" : "starter"; return { roadmap: { ...value, generationSource: source }, migrated: value.generationSource === undefined, }; } /** * The signed-in learner's id, so device caches are account-scoped. A shared * computer must never show (or upload) one student's roadmap to another. */ export function learnerScopeId(): string | null { if (typeof window === "undefined") return null; try { const rawUser = window.localStorage.getItem("exam_success_user") ?? window.sessionStorage.getItem("exam_success_user"); if (rawUser) { const parsed = JSON.parse(rawUser) as { id?: unknown }; if (typeof parsed.id === "string" && parsed.id) return parsed.id; } const token = window.localStorage.getItem("exam_success_access_token") ?? window.sessionStorage.getItem("exam_success_access_token"); if (!token) return null; const payload = JSON.parse( atob((token.split(".")[1] ?? "").replace(/-/g, "+").replace(/_/g, "/")), ) as { sub?: unknown }; return typeof payload.sub === "string" ? payload.sub : null; } catch { return null; } } function roadmapStorageKey(id: string): string { const uid = learnerScopeId(); return uid ? `${LEARN_ANYTHING_ROADMAP_PREFIX}${uid}:${id}` : `${LEARN_ANYTHING_ROADMAP_PREFIX}${id}`; } function lastRoadmapStorageKey(): string { const uid = learnerScopeId(); return uid ? `${LEARN_ANYTHING_LAST_ROADMAP_KEY}:${uid}` : LEARN_ANYTHING_LAST_ROADMAP_KEY; } function writeLearnAnythingRoadmapLocal(roadmap: LearnAnythingRoadmap, markAsLast = true): void { if (typeof window === "undefined") return; try { window.localStorage.setItem(roadmapStorageKey(roadmap.id), JSON.stringify(roadmap)); if (markAsLast) window.localStorage.setItem(lastRoadmapStorageKey(), roadmap.id); } catch { // Local fallback only; ignore storage failure. } } export function saveLearnAnythingRoadmap(roadmap: LearnAnythingRoadmap): Promise { writeLearnAnythingRoadmapLocal(roadmap); // Callers that need to make a saved/resume claim can await the boolean. The // local copy is only an offline cache; authenticated progress is confirmed // by the typed server command path above. return saveRoadmapRemote(roadmap); } export function readLearnAnythingRoadmap(id: string): LearnAnythingRoadmap | null { if (typeof window === "undefined") return null; try { // Strict account scoping: a signed-in learner only reads their own scoped // key. Legacy unscoped entries could belong to anyone on this device — // the learner's own roadmaps come back via backend hydration instead. const raw = window.localStorage.getItem(roadmapStorageKey(id)); if (!raw) return null; const normalized = normalizeLearnAnythingRoadmap(JSON.parse(raw)); if (!normalized || normalized.roadmap.id !== id) return null; if (normalized.migrated) writeLearnAnythingRoadmapLocal(normalized.roadmap, false); return normalized.roadmap; } catch { return null; } } /** * All roadmaps saved on this device, most recently saved first (the id * stored under LEARN_ANYTHING_LAST_ROADMAP_KEY leads). This is what lets * /learn-anything greet a returning learner with "continue where you left * off" instead of asking the six intake questions again. */ export function listLearnAnythingRoadmaps(): LearnAnythingRoadmap[] { if (typeof window === "undefined") return []; try { const uid = learnerScopeId(); const scopedPrefix = uid ? `${LEARN_ANYTHING_ROADMAP_PREFIX}${uid}:` : null; const roadmaps: LearnAnythingRoadmap[] = []; for (let i = 0; i < window.localStorage.length; i++) { const key = window.localStorage.key(i); if (!key || !key.startsWith(LEARN_ANYTHING_ROADMAP_PREFIX)) continue; const remainder = key.slice(LEARN_ANYTHING_ROADMAP_PREFIX.length); let storedId: string; if (scopedPrefix) { // Signed in: only this account's scoped entries. if (!key.startsWith(scopedPrefix)) continue; storedId = key.slice(scopedPrefix.length); } else { // Logged out: only unscoped device entries — never another account's cache. if (/^usr_[a-z0-9]+:/i.test(remainder)) continue; storedId = remainder; } const raw = window.localStorage.getItem(key); if (!raw) continue; try { const normalized = normalizeLearnAnythingRoadmap(JSON.parse(raw)); if (!normalized || normalized.roadmap.id !== storedId) continue; if (normalized.migrated) writeLearnAnythingRoadmapLocal(normalized.roadmap, false); roadmaps.push(normalized.roadmap); } catch { // One corrupt entry should not hide the rest. } } const lastId = window.localStorage.getItem(lastRoadmapStorageKey()); roadmaps.sort((a, b) => (a.id === lastId ? -1 : b.id === lastId ? 1 : a.topic.localeCompare(b.topic))); return roadmaps; } catch { return []; } } /** * Merge a signed-in learner's remote tracks into the local list by id. Normal * collisions are remote-wins because remote records are the cross-device saved * value; local-only tracks are retained. Bulk hydration separately reapplies a * local record created or changed while the remote request was pending, so * in-flight learner work wins that collision. The helper is pure so callers * never create a fictional remote record while logged out. */ export function mergeLearnAnythingRoadmaps( local: LearnAnythingRoadmap[], remote: LearnAnythingRoadmap[], ): LearnAnythingRoadmap[] { const merged = new Map(local.map((roadmap) => [roadmap.id, roadmap])); for (const roadmap of remote) merged.set(roadmap.id, roadmap); return Array.from(merged.values()); } function roadmapSnapshotsMatch( first: LearnAnythingRoadmap | undefined, second: LearnAnythingRoadmap, ): boolean { return first !== undefined && JSON.stringify(first) === JSON.stringify(second); } /** * Pull a saved roadmap from the backend into localStorage so /learn-anything/ * [roadmapId] loads it on a fresh device. Returns the roadmap (or null). */ export async function hydrateLearnAnythingRoadmap(id: string): Promise { const remote = await getRoadmapRemote(id); const normalized = normalizeLearnAnythingRoadmap(remote); if (!normalized || normalized.roadmap.id !== id) { // A remote miss (offline, logged out, failed earlier write) must never // hide a roadmap that exists on this device. return readLearnAnythingRoadmap(id); } writeLearnAnythingRoadmapLocal(normalized.roadmap); if (normalized.migrated) void saveRoadmapRemote(normalized.roadmap); return normalized.roadmap; } /** * Bring every remotely saved track onto this device after mount. This only * reads the remote API when there is a live session (the API helper otherwise * returns an empty list), and never changes the current local "last" track. */ export async function hydrateLearnAnythingRoadmaps(): Promise { const localAtRequestStart = new Map( listLearnAnythingRoadmaps().map((roadmap) => [roadmap.id, roadmap]), ); const remoteRecords = (await listRoadmapsRemote()) .map(normalizeLearnAnythingRoadmap) .filter((record): record is NonNullable => record !== null); const remote = remoteRecords.map((record) => record.roadmap); // The remote request may stay pending while the learner generates a new // roadmap. Read storage only after it settles so that in-flight local work // remains part of the merged result. const latestLocal = listLearnAnythingRoadmaps(); const locallyChanged = new Map( latestLocal .filter((roadmap) => !roadmapSnapshotsMatch(localAtRequestStart.get(roadmap.id), roadmap)) .map((roadmap) => [roadmap.id, roadmap]), ); const merged = mergeLearnAnythingRoadmaps(latestLocal, remote).map( (roadmap) => locallyChanged.get(roadmap.id) ?? roadmap, ); for (const roadmap of remote) { writeLearnAnythingRoadmapLocal(locallyChanged.get(roadmap.id) ?? roadmap, false); } for (const record of remoteRecords) { if (record.migrated) void saveRoadmapRemote(record.roadmap); } return merged; } export function buildWeekendPyqTest(input: TuitionSupportInput): WeekendPyqTest { const keywords = input.keywords.length > 0 ? input.keywords : ["direct answer", "keywords", "clear points"]; const weak = input.weakAreas.length > 0 ? input.weakAreas : [keywords[0]]; return { id: `weekend-${slugify(input.subject)}-${slugify(input.chapter)}`, title: "Weekend Exam Practice", subject: input.subject, chapter: input.chapter, weekLabel: "Sample practice", estimatedMinutes: Math.max(25, input.estimatedMinutes), safePatternNote: "This is sample exam practice, not full previous-question coverage. It uses a fixed practice set for this beta, not necessarily your most recent lesson. More previous-question coverage will be added as the beta improves.", questions: [ { id: "a1", section: "A", label: "Section A - Board-style answer", question: input.examQuestion, marks: 2, expectedAnswer: input.modelAnswer.slice(0, 2).join(" "), keywords: keywords.slice(0, 3), weakArea: weak[0], }, { id: "a2", section: "A", label: "Section A - Previous-question style", question: `Write the meaning or main idea of ${input.missionTitle}.`, marks: 2, expectedAnswer: input.goal, keywords: keywords.slice(0, 2), weakArea: weak[0], }, { id: "b1", section: "B", label: "Section B - Exam-relevant practice", question: `Explain ${input.missionTitle} in four clear points.`, marks: 4, expectedAnswer: input.modelAnswer.join(" "), keywords: keywords.slice(0, 5), weakArea: weak[1] ?? weak[0], }, { id: "b2", section: "B", label: "Section B - Exam-relevant practice", question: `Use this memory line in an answer: ${input.memoryLine}`, marks: 4, expectedAnswer: input.modelAnswer.join(" "), keywords: keywords.slice(0, 4), weakArea: weak[2] ?? weak[0], }, ], }; } export function evaluatePyqAnswers(test: WeekendPyqTest, answers: Record): WeekendPyqResult { let score = 0; const missing = new Set(); const weak = new Set(); const firstQuestion = test.questions[0]; for (const question of test.questions) { const answer = (answers[question.id] ?? "").toLowerCase(); const matched = question.keywords.filter((keyword) => answer.includes(keyword.toLowerCase())); score += Math.min(question.marks, matched.length); if (matched.length < Math.min(question.marks, question.keywords.length)) { weak.add(question.weakArea); question.keywords.filter((keyword) => !answer.includes(keyword.toLowerCase())).forEach((keyword) => missing.add(keyword)); } } return { score, maxScore: test.questions.reduce((total, question) => total + question.marks, 0), correctAnswer: firstQuestion.expectedAnswer, missingKeywords: Array.from(missing).slice(0, 6), weakAreas: Array.from(weak).slice(0, 4), suggestedRevision: "Revise the missing keywords, then write one 4-mark answer before the next exam practice.", }; } export function buildRevisionQueue(input: TuitionSupportInput): RevisionQueue { const weakAreas = input.weakAreas.length > 0 ? input.weakAreas : input.keywords.slice(0, 2); const firstWeak = weakAreas[0] ?? input.missionTitle; return { dueToday: [ { id: "recall", title: `Recall: ${input.missionTitle}`, source: "Saved from today's class", dueLabel: "Due today", minutes: 8, action: "Quick recall", }, { id: "weak-keyword", title: `Fix keyword: ${firstWeak}`, source: "Answer correction", dueLabel: "Due today", minutes: 10, action: "Revise notes", }, ], savedWeakAreas: weakAreas, flashcards: input.keywords.slice(0, 4).map((keyword) => ({ front: `What should you remember about ${keyword}?`, back: `Use ${keyword} in the answer and explain it in one sentence.`, })), quickRecall: { question: input.examQuestion, answer: input.modelAnswer.slice(0, 2).join(" "), }, upcomingReviews: [ { id: "weekend-pyq", title: "Weekend exam practice", source: "Revision schedule", dueLabel: "Weekend", minutes: 25, action: "Practice questions", }, ], examReadiness: ["2-mark answer: ready after recall", "4-mark answer: needs one written practice", "Exam practice: include weak keywords"], }; } export function buildWeakAreaRecovery(input: TuitionSupportInput): WeakAreaRecovery[] { const weakAreas = input.weakAreas.length > 0 ? input.weakAreas : input.keywords.slice(0, 3); return weakAreas.map((topic, index) => ({ topic, severity: index === 0 ? "High" : index === 1 ? "Medium" : "Review", whyWeak: index === 0 ? "Missed in answer writing or quiz feedback." : "Needs one more recall and exam-use check.", lastMistakeSource: index === 0 ? "Today's class correction" : "Quiz or saved note", recommendedFix: `Spend 10 minutes on ${topic}, then write one answer using the keyword.`, actionPrompt: `Explain ${topic} from today's tuition class and give one practice question.`, })); } /** Account-scoped phase3 cache key so a shared device never shows one student's * weak topics / revision history in another account's Progress. */ function phase3ActivityStorageKey(): string { const uid = learnerScopeId(); return uid ? `${PHASE3_ACTIVITY_KEY}:${uid}` : PHASE3_ACTIVITY_KEY; } export function readPhase3Activity(): Phase3Activity { if (typeof window === "undefined") return {}; try { const raw = window.localStorage.getItem(phase3ActivityStorageKey()); return raw ? (JSON.parse(raw) as Phase3Activity) : {}; } catch { return {}; } } export function updatePhase3Activity(patch: Phase3Activity): Phase3Activity { const current = readPhase3Activity(); const next: Phase3Activity = { ...current, ...patch, weakTopicsAdded: Array.from(new Set([...(current.weakTopicsAdded ?? []), ...(patch.weakTopicsAdded ?? [])])), revisionSavedTopics: Array.from(new Set([...(current.revisionSavedTopics ?? []), ...(patch.revisionSavedTopics ?? [])])), recoveryActions: Array.from(new Set([...(current.recoveryActions ?? []), ...(patch.recoveryActions ?? [])])), roadmapLessonCompletions: mergeRoadmapActivity( current.roadmapLessonCompletions, patch.roadmapLessonCompletions, (event) => `${event.roadmapId}:${event.lessonId}`, ), roadmapQuizAttempts: mergeRoadmapActivity( current.roadmapQuizAttempts, patch.roadmapQuizAttempts, (event) => `${event.roadmapId}:${event.answeredAt}`, ), }; if (typeof window !== "undefined") { try { window.localStorage.setItem(phase3ActivityStorageKey(), JSON.stringify(next)); } catch { // Local fallback only; ignore storage failure. } } // Write-through to the backend (best-effort, no-op when logged out). void savePhase3ActivityRemote(next); return next; } function mergeRoadmapActivity( current: T[] | undefined, patch: T[] | undefined, key: (event: T) => string, ): T[] | undefined { if (!current && !patch) return undefined; const merged = new Map(); for (const event of [...(current ?? []), ...(patch ?? [])]) merged.set(key(event), event); return Array.from(merged.values()); } /** * Pull the saved activity aggregate from the backend into localStorage so the * progress / revision / weak-area pages reflect real history on a fresh device. */ export async function hydratePhase3Activity(): Promise { const remote = await getPhase3ActivityRemote(); if (remote && typeof window !== "undefined") { try { window.localStorage.setItem(phase3ActivityStorageKey(), JSON.stringify(remote)); } catch { // Local fallback only; ignore storage failure. } } return remote; } export function buildProgressSnapshot(input: TuitionSupportInput, activity: Phase3Activity): ProgressSnapshot { const hasActivity = Boolean( activity.pyqSubmittedAt || activity.weakTopicsAdded?.length || activity.revisionSavedTopics?.length || activity.recoveryActions?.length, ); return { empty: !hasActivity, metrics: [ { label: "Today's class", value: input.missionTitle, note: "Sample mission — open Today's Tuition to see your real one", }, { label: "Exam practice", value: activity.pyqSubmittedAt && typeof activity.pyqScore === "number" && typeof activity.pyqMaxScore === "number" ? `${activity.pyqScore}/${activity.pyqMaxScore}` : "Not submitted", note: activity.pyqSubmittedAt ? "Stored locally from exam practice" : "No local practice result yet", }, { label: "Weak topics saved", value: String(activity.weakTopicsAdded?.length ?? input.weakAreas.length), note: hasActivity ? "Local recovery list" : "From today's tuition fallback", }, { label: "Revision queue", value: String(activity.revisionSavedTopics?.length ?? 0), note: "Local queue until backend contract is wired", }, ], weakAreas: activity.weakTopicsAdded?.length ? activity.weakTopicsAdded : input.weakAreas, weakAreasIsSample: !activity.weakTopicsAdded?.length, nextActions: [ { label: "Start revision", href: "/tuition/revision" }, { label: "Practice questions", href: "/tuition/pyq-test" }, { label: "Ask in Study Chat", href: "/study-chat" }, ], }; }