DocDoeAI / src /lib /api /learn-anything-roadmap.ts
asnannp's picture
deploy: sync backend to Space root (learn-lesson HF cache fix)
65d68f5
Raw History Blame Contribute Delete
6.05 kB
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<RoadmapCommand, "client_command_id">,
): Promise<void> {
await apiPost<RoadmapRemote, RoadmapCommand>(
`/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<void> {
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<boolean> {
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<RoadmapRemote[]>("/learn-anything-roadmaps");
let remote = remoteRows.find((row) => row.roadmap_id === roadmap.id)?.data ?? null;
if (!remote) {
const initial = cleanInitialRoadmap(roadmap);
await apiPost<RoadmapRemote, { roadmap_id: string; data: LearnAnythingRoadmap }>(
"/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<LearnAnythingRoadmap | null> {
if (!hasLiveSession()) return null;
try {
const remote = await apiGet<RoadmapRemote>(`/learn-anything-roadmaps/${encodeURIComponent(id)}`);
return remote?.data ?? null;
} catch {
return null;
}
}
export async function listRoadmapsRemote(): Promise<LearnAnythingRoadmap[]> {
if (!hasLiveSession()) return [];
try {
const rows = await apiGet<RoadmapRemote[]>("/learn-anything-roadmaps");
return (rows ?? []).map((row) => row.data).filter(Boolean);
} catch {
return [];
}
}