"use client"; export const REVISION_LOOP_STORAGE_KEY = "docdoe:tuition-revision-loop:v1"; const DAY_MS = 24 * 60 * 60 * 1000; export type RevisionQueueItem = { id: string; sourceMissionId: string; subject: string; chapter: string; missionTitle: string; title: string; reason: string; dueAt: string; status: "pending" | "done"; priority: "low" | "medium" | "high"; source: "save_revision" | "quiz_mistake" | "weak_area"; weakAreaTags: string[]; flashcards: Array<{ front: string; back: string }>; notebookPoints: string[]; quizPrompts: Array<{ question: string; expectedAnswer: string; explanation?: string }>; commonMistakes: string[]; completedAt?: string; }; export type QuizHistoryItem = { id: string; sourceMissionId: string; subject: string; chapter: string; missionTitle: string; completedAt: string; questions: Array<{ question: string; expectedAnswer: string; explanation?: string }>; mistakes: Array<{ question: string; expectedAnswer: string; explanation: string; weakAreaTag?: string }>; }; export type WeakAreaSummaryItem = { tag: string; count: number; lastSeenAt: string; subjects: string[]; chapters: string[]; sourceMissionIds: string[]; }; export type RevisionLoopState = { queue: RevisionQueueItem[]; quizHistory: QuizHistoryItem[]; weakAreas: WeakAreaSummaryItem[]; lastSavedClass?: { sourceMissionId: string; subject: string; chapter: string; missionTitle: string; savedAt: string; }; }; export type RevisionRuntimeInput = { sourceMissionId: string; subject: string; chapter: string; missionTitle: string; weakAreaTags: string[]; flashcards?: Array<{ front: string; back: string }>; notebookPoints?: string[]; quizPrompts?: Array<{ question: string; expectedAnswer: string; explanation?: string }>; commonMistakes?: string[]; reason?: string; }; export type QuizMistakeInput = { sourceMissionId: string; subject: string; chapter: string; missionTitle: string; question: string; expectedAnswer: string; explanation: string; weakAreaTag?: string; }; type WriteOptions = { storage?: Storage | null; now?: number; }; function emptyState(): RevisionLoopState { return { queue: [], quizHistory: [], weakAreas: [], }; } function browserStorage(): Storage | null { if (typeof window === "undefined") return null; return window.localStorage; } function safeString(value: unknown): string { return typeof value === "string" ? value.trim() : ""; } function compactStrings(values: unknown): string[] { if (!Array.isArray(values)) return []; return Array.from(new Set(values.map(safeString).filter(Boolean))); } function compactFlashcards(values: unknown): Array<{ front: string; back: string }> { if (!Array.isArray(values)) return []; return values .map((value) => { if (!value || typeof value !== "object") return null; const card = value as Record; const front = safeString(card.front); const back = safeString(card.back); return front && back ? { front, back } : null; }) .filter((card): card is { front: string; back: string } => Boolean(card)); } function compactQuizPrompts(values: unknown): Array<{ question: string; expectedAnswer: string; explanation?: string }> { if (!Array.isArray(values)) return []; return values .map((value) => { if (!value || typeof value !== "object") return null; const prompt = value as Record; const question = safeString(prompt.question); const expectedAnswer = safeString(prompt.expectedAnswer); const explanation = safeString(prompt.explanation); if (!question || !expectedAnswer) return null; return explanation ? { question, expectedAnswer, explanation } : { question, expectedAnswer }; }) .filter((prompt): prompt is { question: string; expectedAnswer: string; explanation?: string } => Boolean(prompt)); } function normalizeQueueItem(value: unknown): RevisionQueueItem | null { if (!value || typeof value !== "object") return null; const item = value as Record; const id = safeString(item.id); const sourceMissionId = safeString(item.sourceMissionId); const subject = safeString(item.subject); const chapter = safeString(item.chapter); const missionTitle = safeString(item.missionTitle); const title = safeString(item.title); const dueAt = safeString(item.dueAt); if (!id || !sourceMissionId || !subject || !chapter || !missionTitle || !title || !dueAt) return null; const status = item.status === "done" ? "done" : "pending"; const priority = item.priority === "high" || item.priority === "low" ? item.priority : "medium"; const source = item.source === "quiz_mistake" || item.source === "weak_area" ? item.source : "save_revision"; return { id, sourceMissionId, subject, chapter, missionTitle, title, reason: safeString(item.reason) || "Saved from today's class.", dueAt, status, priority, source, weakAreaTags: compactStrings(item.weakAreaTags), flashcards: compactFlashcards(item.flashcards), notebookPoints: compactStrings(item.notebookPoints), quizPrompts: compactQuizPrompts(item.quizPrompts), commonMistakes: compactStrings(item.commonMistakes), completedAt: safeString(item.completedAt) || undefined, }; } function normalizeQuizHistoryItem(value: unknown): QuizHistoryItem | null { if (!value || typeof value !== "object") return null; const item = value as Record; const id = safeString(item.id); const sourceMissionId = safeString(item.sourceMissionId); const subject = safeString(item.subject); const chapter = safeString(item.chapter); const missionTitle = safeString(item.missionTitle); const completedAt = safeString(item.completedAt); if (!id || !sourceMissionId || !subject || !chapter || !missionTitle || !completedAt) return null; const mistakes = Array.isArray(item.mistakes) ? item.mistakes .map((value) => { if (!value || typeof value !== "object") return null; const mistake = value as Record; const question = safeString(mistake.question); const expectedAnswer = safeString(mistake.expectedAnswer); const explanation = safeString(mistake.explanation); const weakAreaTag = safeString(mistake.weakAreaTag); if (!question || !expectedAnswer || !explanation) return null; return weakAreaTag ? { question, expectedAnswer, explanation, weakAreaTag } : { question, expectedAnswer, explanation }; }) .filter((mistake): mistake is { question: string; expectedAnswer: string; explanation: string; weakAreaTag?: string } => Boolean(mistake), ) : []; return { id, sourceMissionId, subject, chapter, missionTitle, completedAt, questions: compactQuizPrompts(item.questions), mistakes, }; } function normalizeWeakArea(value: unknown): WeakAreaSummaryItem | null { if (!value || typeof value !== "object") return null; const item = value as Record; const tag = safeString(item.tag); const lastSeenAt = safeString(item.lastSeenAt); if (!tag || !lastSeenAt) return null; return { tag, count: typeof item.count === "number" && item.count > 0 ? item.count : 1, lastSeenAt, subjects: compactStrings(item.subjects), chapters: compactStrings(item.chapters), sourceMissionIds: compactStrings(item.sourceMissionIds), }; } function normalizeState(value: unknown): RevisionLoopState { if (!value || typeof value !== "object") return emptyState(); const state = value as Record; const lastSavedClass = state.lastSavedClass && typeof state.lastSavedClass === "object" ? (() => { const last = state.lastSavedClass as Record; const sourceMissionId = safeString(last.sourceMissionId); const subject = safeString(last.subject); const chapter = safeString(last.chapter); const missionTitle = safeString(last.missionTitle); const savedAt = safeString(last.savedAt); return sourceMissionId && subject && chapter && missionTitle && savedAt ? { sourceMissionId, subject, chapter, missionTitle, savedAt } : undefined; })() : undefined; return { queue: Array.isArray(state.queue) ? state.queue.map(normalizeQueueItem).filter((item): item is RevisionQueueItem => Boolean(item)) : [], quizHistory: Array.isArray(state.quizHistory) ? state.quizHistory.map(normalizeQuizHistoryItem).filter((item): item is QuizHistoryItem => Boolean(item)) : [], weakAreas: Array.isArray(state.weakAreas) ? state.weakAreas.map(normalizeWeakArea).filter((item): item is WeakAreaSummaryItem => Boolean(item)) : [], lastSavedClass, }; } function slug(value: string): string { return value .toLowerCase() .replace(/[^a-z0-9]+/g, "-") .replace(/^-|-$/g, "") .slice(0, 80); } function isoAfter(now: number, days: number): string { return new Date(now + days * DAY_MS).toISOString(); } function upsertQueueItem(queue: RevisionQueueItem[], item: RevisionQueueItem): RevisionQueueItem[] { const index = queue.findIndex((existing) => existing.id === item.id); if (index === -1) return [item, ...queue]; const next = [...queue]; next[index] = { ...next[index], ...item, status: next[index].status === "done" ? "pending" : item.status }; return next; } function updateWeakAreas( weakAreas: WeakAreaSummaryItem[], tags: string[], context: { subject: string; chapter: string; sourceMissionId: string; seenAt: string }, ): WeakAreaSummaryItem[] { const next = [...weakAreas]; for (const tag of tags) { const index = next.findIndex((item) => item.tag.toLowerCase() === tag.toLowerCase()); if (index === -1) { next.push({ tag, count: 1, lastSeenAt: context.seenAt, subjects: [context.subject], chapters: [context.chapter], sourceMissionIds: [context.sourceMissionId], }); continue; } const current = next[index]; next[index] = { ...current, count: current.count + 1, lastSeenAt: context.seenAt, subjects: Array.from(new Set([...current.subjects, context.subject])), chapters: Array.from(new Set([...current.chapters, context.chapter])), sourceMissionIds: Array.from(new Set([...current.sourceMissionIds, context.sourceMissionId])), }; } return getWeakAreaSummary({ queue: [], quizHistory: [], weakAreas: next }); } function buildMistakeCards(input: RevisionRuntimeInput): QuizHistoryItem["mistakes"] { const mistakes = compactStrings(input.commonMistakes); const weakTags = compactStrings(input.weakAreaTags); return mistakes.map((mistake, index) => { const weakAreaTag = weakTags[index % Math.max(weakTags.length, 1)]; const card = { question: `Review common mistake ${index + 1}`, expectedAnswer: mistake, explanation: "Use this as a mistake card before the next class.", }; return weakAreaTag ? { ...card, weakAreaTag } : card; }); } export function loadRevisionLoopState(storage: Storage | null = browserStorage()): RevisionLoopState { if (!storage) return emptyState(); try { const raw = storage.getItem(REVISION_LOOP_STORAGE_KEY); if (!raw) return emptyState(); return normalizeState(JSON.parse(raw)); } catch { return emptyState(); } } export function saveRevisionLoopState( state: RevisionLoopState, storage: Storage | null = browserStorage(), ): RevisionLoopState { const normalized = normalizeState(state); if (!storage) return normalized; try { storage.setItem(REVISION_LOOP_STORAGE_KEY, JSON.stringify(normalized)); } catch { // Keep the UI usable when storage is unavailable or full. } return normalized; } export function addRevisionFromRuntime(input: RevisionRuntimeInput, options: WriteOptions = {}): RevisionLoopState { const now = options.now ?? Date.now(); const savedAt = new Date(now).toISOString(); const weakAreaTags = compactStrings(input.weakAreaTags); const quizPrompts = compactQuizPrompts(input.quizPrompts); const commonMistakes = compactStrings(input.commonMistakes); const sourceMissionId = safeString(input.sourceMissionId) || "unknown-mission"; const subject = safeString(input.subject) || "Tuition"; const chapter = safeString(input.chapter) || "Today's class"; const missionTitle = safeString(input.missionTitle) || "Saved class"; const state = loadRevisionLoopState(options.storage); const item: RevisionQueueItem = { id: `save:${slug(sourceMissionId)}`, sourceMissionId, subject, chapter, missionTitle, title: `${missionTitle} revision`, reason: safeString(input.reason) || "Saved from today's class.", dueAt: isoAfter(now, 1), status: "pending", priority: weakAreaTags.length > 0 || commonMistakes.length > 0 ? "high" : "medium", source: "save_revision", weakAreaTags, flashcards: compactFlashcards(input.flashcards), notebookPoints: compactStrings(input.notebookPoints), quizPrompts, commonMistakes, }; const quizHistoryItem: QuizHistoryItem | null = quizPrompts.length > 0 || commonMistakes.length > 0 ? { id: `quiz-review:${slug(sourceMissionId)}`, sourceMissionId, subject, chapter, missionTitle, completedAt: savedAt, questions: quizPrompts, mistakes: buildMistakeCards({ ...input, weakAreaTags, commonMistakes }), } : null; const nextState: RevisionLoopState = { queue: upsertQueueItem(state.queue, item), quizHistory: quizHistoryItem ? [quizHistoryItem, ...state.quizHistory.filter((history) => history.id !== quizHistoryItem.id)] : state.quizHistory, weakAreas: updateWeakAreas(state.weakAreas, weakAreaTags, { subject, chapter, sourceMissionId, seenAt: savedAt }), lastSavedClass: { sourceMissionId, subject, chapter, missionTitle, savedAt, }, }; return saveRevisionLoopState(nextState, options.storage); } export function addQuizMistake(input: QuizMistakeInput, options: WriteOptions = {}): RevisionLoopState { const now = options.now ?? Date.now(); const savedAt = new Date(now).toISOString(); const sourceMissionId = safeString(input.sourceMissionId) || "unknown-mission"; const subject = safeString(input.subject) || "Tuition"; const chapter = safeString(input.chapter) || "Today's class"; const missionTitle = safeString(input.missionTitle) || "Quiz practice"; const question = safeString(input.question) || "Review this quiz question."; const expectedAnswer = safeString(input.expectedAnswer) || "Review the model answer."; const explanation = safeString(input.explanation) || "Revise this before the next class."; const weakAreaTags = compactStrings([input.weakAreaTag]); const id = `mistake:${slug(sourceMissionId)}:${slug(question)}`; const state = loadRevisionLoopState(options.storage); const item: RevisionQueueItem = { id, sourceMissionId, subject, chapter, missionTitle, title: `Fix: ${question}`, reason: "Saved from a quiz mistake.", dueAt: isoAfter(now, 1), status: "pending", priority: "high", source: "quiz_mistake", weakAreaTags, flashcards: [], notebookPoints: [], quizPrompts: [{ question, expectedAnswer, explanation }], commonMistakes: [explanation], }; const historyItem: QuizHistoryItem = { id: `quiz-mistake:${slug(sourceMissionId)}:${slug(question)}`, sourceMissionId, subject, chapter, missionTitle, completedAt: savedAt, questions: [{ question, expectedAnswer, explanation }], mistakes: [ weakAreaTags[0] ? { question, expectedAnswer, explanation, weakAreaTag: weakAreaTags[0] } : { question, expectedAnswer, explanation }, ], }; const nextState: RevisionLoopState = { queue: upsertQueueItem(state.queue, item), quizHistory: [historyItem, ...state.quizHistory.filter((history) => history.id !== historyItem.id)], weakAreas: updateWeakAreas(state.weakAreas, weakAreaTags, { subject, chapter, sourceMissionId, seenAt: savedAt }), lastSavedClass: state.lastSavedClass, }; return saveRevisionLoopState(nextState, options.storage); } export function markRevisionDone(id: string, options: WriteOptions = {}): RevisionLoopState { const now = options.now ?? Date.now(); const state = loadRevisionLoopState(options.storage); const nextState: RevisionLoopState = { ...state, queue: state.queue.map((item) => item.id === id ? { ...item, status: "done", completedAt: new Date(now).toISOString() } : item, ), }; return saveRevisionLoopState(nextState, options.storage); } export function getTomorrowRevisionPlan(state: RevisionLoopState, now: number = Date.now()): RevisionQueueItem[] { const tomorrowEnd = now + DAY_MS + 60 * 1000; return state.queue .filter((item) => item.status === "pending") .filter((item) => Date.parse(item.dueAt) <= tomorrowEnd) .sort((a, b) => { const priority = { high: 0, medium: 1, low: 2 }; return priority[a.priority] - priority[b.priority] || Date.parse(a.dueAt) - Date.parse(b.dueAt); }) .slice(0, 5); } export function getDueToday(state: RevisionLoopState, now: number = Date.now()): RevisionQueueItem[] { return state.queue .filter((item) => item.status === "pending") .filter((item) => Date.parse(item.dueAt) <= now) .sort((a, b) => Date.parse(a.dueAt) - Date.parse(b.dueAt)); } export function getWeakAreaSummary(state: RevisionLoopState): WeakAreaSummaryItem[] { return [...state.weakAreas].sort((a, b) => b.count - a.count || b.lastSeenAt.localeCompare(a.lastSeenAt)); }