File size: 6,047 Bytes
65d68f5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
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 [];
  }
}