import { apiGet, apiPost } from "@/lib/api/client"; import { hasLiveSession } from "@/lib/api/mode"; import type { LearnAnythingRoadmap } from "@/lib/phase3-learning"; /** * Backend persistence for Learn Anything roadmaps. localStorage is an offline * cache/draft; the authenticated backend owns saved roadmap progress and lets * /learn-anything/[roadmapId] resume it on another device. No-op when logged * out, so an anonymous draft never becomes account state by accident. */ type RoadmapRemote = { roadmap_id: string; data: LearnAnythingRoadmap; updated_at: string }; type RoadmapCommand = { client_command_id: string; command: | "set_topic_status" | "record_diagnostic" | "complete_lesson" | "record_quiz" | "review_flashcard"; topic_key?: string | null; status?: "covered" | null; known_topic_keys?: string[]; title?: string | null; correct?: number | null; total?: number | null; missed_questions?: string[]; card_front?: string | null; remembered?: boolean | null; }; function commandId(roadmapId: string): string { const random = typeof crypto !== "undefined" && "randomUUID" in crypto ? crypto.randomUUID() : `${Date.now()}-${Math.random().toString(36).slice(2)}`; return `roadmap:${roadmapId}:${random}`.slice(0, 160); } async function sendCommand( roadmapId: string, command: Omit, ): Promise { await apiPost( `/learn-anything-roadmaps/${encodeURIComponent(roadmapId)}/commands`, { client_command_id: commandId(roadmapId), ...command }, ); } function same(left: unknown, right: unknown): boolean { return JSON.stringify(left) === JSON.stringify(right); } async function syncProgressCommands( previous: LearnAnythingRoadmap, next: LearnAnythingRoadmap, ): Promise { const previousStatuses = previous.topicStatus ?? {}; const nextStatuses = next.topicStatus ?? {}; const newLessonKeys = new Set( (next.completedLessons ?? []) .filter( (lesson) => lesson.lessonId !== "today" && !(previous.completedLessons ?? []).some( (previousLesson) => previousLesson.lessonId === lesson.lessonId, ), ) .map((lesson) => lesson.lessonId), ); const diagnosticKeys = !previous.diagnosticDone && next.diagnosticDone ? Object.keys(nextStatuses).filter( (key) => !previousStatuses[key] && Boolean(nextStatuses[key]), ) : []; const statusOwnedByExplicitAction = new Set([...newLessonKeys, ...diagnosticKeys]); for (const key of new Set([ ...Object.keys(previousStatuses), ...Object.keys(nextStatuses), ])) { if ( previousStatuses[key] === nextStatuses[key] || statusOwnedByExplicitAction.has(key) ) { continue; } await sendCommand(next.id, { command: "set_topic_status", topic_key: key, // Legacy self-marked "mastered" never becomes authoritative mastery. status: nextStatuses[key] ? "covered" : null, }); } if (!previous.diagnosticDone && next.diagnosticDone) { await sendCommand(next.id, { command: "record_diagnostic", known_topic_keys: diagnosticKeys, }); } for (const lesson of next.completedLessons ?? []) { if (!newLessonKeys.has(lesson.lessonId)) continue; await sendCommand(next.id, { command: "complete_lesson", topic_key: lesson.lessonId, title: lesson.title, }); } if (!same(previous.quizResult, next.quizResult) && next.quizResult) { await sendCommand(next.id, { command: "record_quiz", correct: next.quizResult.correct, total: next.quizResult.total, missed_questions: next.quizResult.missedQuestions, }); } for (const card of next.flashcards ?? []) { const before = previous.srState?.[card.front]; const after = next.srState?.[card.front]; if (same(before, after) || !after) continue; await sendCommand(next.id, { command: "review_flashcard", card_front: card.front, remembered: after.box > 0, }); } } function cleanInitialRoadmap( roadmap: LearnAnythingRoadmap, ): LearnAnythingRoadmap { const clean = { ...roadmap, completedWeeks: [], topicStatus: {}, diagnosticDone: false, srState: {}, completedLessons: [], progress: { ...roadmap.progress, completed: 0 }, }; delete clean.quizResult; return clean; } export async function saveRoadmapRemote(roadmap: LearnAnythingRoadmap): Promise { if (!hasLiveSession()) return false; try { // A newly generated roadmap has no detail resource yet. Resolve its // existence from the already-owned collection so first save does not // intentionally hit a 404 (which browsers surface as a console error), // and so the UI cannot race a detail-page load against the initial POST. const remoteRows = await apiGet("/learn-anything-roadmaps"); let remote = remoteRows.find((row) => row.roadmap_id === roadmap.id)?.data ?? null; if (!remote) { const initial = cleanInitialRoadmap(roadmap); await apiPost( "/learn-anything-roadmaps", { roadmap_id: roadmap.id, data: initial }, ); remote = initial; } await syncProgressCommands(remote, roadmap); return true; } catch { return false; } } export async function getRoadmapRemote(id: string): Promise { if (!hasLiveSession()) return null; try { const remote = await apiGet(`/learn-anything-roadmaps/${encodeURIComponent(id)}`); return remote?.data ?? null; } catch { return null; } } export async function listRoadmapsRemote(): Promise { if (!hasLiveSession()) return []; try { const rows = await apiGet("/learn-anything-roadmaps"); return (rows ?? []).map((row) => row.data).filter(Boolean); } catch { return []; } }