cert-coach-web 0.1.1 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -0
- package/package.json +2 -2
- package/web/public/app.js +77 -910
- package/web/public/spa-api.js +33 -0
- package/web/public/spa-chat.js +181 -0
- package/web/public/spa-dom.js +89 -0
- package/web/public/spa-mermaid.js +54 -0
- package/web/public/spa-question.js +206 -0
- package/web/public/spa-store.js +100 -0
- package/web/public/spa-ui.js +62 -0
- package/web/public/spa-views.js +279 -0
- package/web/server/http.mjs +7 -8
package/web/public/app.js
CHANGED
|
@@ -1,430 +1,21 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
connection: $("connection"),
|
|
14
|
-
notice: $("notice"),
|
|
15
|
-
loading: $("loading"),
|
|
16
|
-
idle: $("idle-card"),
|
|
17
|
-
idleNote: $("idle-note"),
|
|
18
|
-
start: $("start"),
|
|
19
|
-
startMock: $("start-mock"),
|
|
20
|
-
startProbe: $("start-probe"),
|
|
21
|
-
mockSize: $("mock-size"),
|
|
22
|
-
mockMinutes: $("mock-minutes"),
|
|
23
|
-
question: $("question-card"),
|
|
24
|
-
qmeta: $("qmeta"),
|
|
25
|
-
timer: $("timer"),
|
|
26
|
-
stem: $("stem"),
|
|
27
|
-
questionNote: $("question-note"),
|
|
28
|
-
images: $("images"),
|
|
29
|
-
options: $("options"),
|
|
30
|
-
confidenceButtons: $("confidence-buttons"),
|
|
31
|
-
submit: $("submit"),
|
|
32
|
-
feedback: $("feedback-card"),
|
|
33
|
-
verdict: $("verdict"),
|
|
34
|
-
feedbackMeta: $("feedback-meta"),
|
|
35
|
-
feedbackTitle: $("feedback-title"),
|
|
36
|
-
answerLine: $("answer-line"),
|
|
37
|
-
explanation: $("explanation"),
|
|
38
|
-
source: $("source"),
|
|
39
|
-
next: $("next"),
|
|
40
|
-
teach: $("teach-card"),
|
|
41
|
-
teachMeta: $("teach-meta"),
|
|
42
|
-
teachTitle: $("teach-title"),
|
|
43
|
-
teachSummary: $("teach-summary"),
|
|
44
|
-
teachSection: $("teach-section"),
|
|
45
|
-
teachDoc: $("teach-doc"),
|
|
46
|
-
teachNext: $("teach-next"),
|
|
47
|
-
mock: $("mock-card"),
|
|
48
|
-
mockMeta: $("mock-meta"),
|
|
49
|
-
mockTitle: $("mock-title"),
|
|
50
|
-
mockNote: $("mock-note"),
|
|
51
|
-
mockResults: $("mock-results"),
|
|
52
|
-
mockNew: $("mock-new"),
|
|
53
|
-
mockPractice: $("mock-practice"),
|
|
54
|
-
statusLog: $("status-log"),
|
|
55
|
-
chatLog: $("chat-log"),
|
|
56
|
-
chatForm: $("chat-form"),
|
|
57
|
-
chatInput: $("chat-input"),
|
|
58
|
-
chatSend: $("chat-send"),
|
|
59
|
-
chatStop: $("chat-stop"),
|
|
60
|
-
chatStatus: $("chat-status"),
|
|
61
|
-
nav: document.querySelector(".nav"),
|
|
62
|
-
sideNav: document.querySelector(".side-nav"),
|
|
63
|
-
layoutButtons: [...document.querySelectorAll("[data-app-layout]")],
|
|
64
|
-
cmdReadiness: $("cmd-readiness"),
|
|
65
|
-
cmdCoverage: $("cmd-coverage"),
|
|
66
|
-
cmdDue: $("cmd-due"),
|
|
67
|
-
cmdWeak: $("cmd-weak"),
|
|
68
|
-
cmdWeakNote: $("cmd-weak-note"),
|
|
69
|
-
cmdReadinessBar: $("cmd-readiness-bar"),
|
|
70
|
-
cmdCoverageBar: $("cmd-coverage-bar"),
|
|
71
|
-
cmdDomains: $("cmd-domains"),
|
|
72
|
-
studyView: $("study-view"),
|
|
73
|
-
pathView: $("path-view"),
|
|
74
|
-
pathMeta: $("path-meta"),
|
|
75
|
-
pathRoute: $("path-route"),
|
|
76
|
-
pathDetail: $("path-detail"),
|
|
77
|
-
dashboardView: $("dashboard-view"),
|
|
78
|
-
theoryView: $("theory-view"),
|
|
79
|
-
logsView: $("logs-view"),
|
|
80
|
-
dashboardBody: $("dashboard-body"),
|
|
81
|
-
dashboardMeta: $("dashboard-meta"),
|
|
82
|
-
theoryNodes: $("theory-nodes"),
|
|
83
|
-
theoryBody: $("theory-body"),
|
|
84
|
-
logsBody: $("logs-body"),
|
|
85
|
-
};
|
|
86
|
-
|
|
87
|
-
let state = null;
|
|
88
|
-
let selected = new Set();
|
|
89
|
-
let confidence = 2;
|
|
90
|
-
let startedAt = 0;
|
|
91
|
-
let questionKey = "";
|
|
92
|
-
let timerHandle = null;
|
|
93
|
-
let eventSource = null;
|
|
94
|
-
let chatEnabled = false;
|
|
95
|
-
let chatReason = "";
|
|
96
|
-
let chatBusy = false;
|
|
97
|
-
let chatStream = null;
|
|
98
|
-
let pendingUser = "";
|
|
99
|
-
const toolCards = new Map();
|
|
100
|
-
let mermaidSeq = 0;
|
|
101
|
-
let mermaidLoading = null;
|
|
102
|
-
|
|
103
|
-
function ensureMermaid() {
|
|
104
|
-
if (window.mermaid) return Promise.resolve(window.mermaid);
|
|
105
|
-
if (!mermaidLoading) {
|
|
106
|
-
mermaidLoading = new Promise((resolve, reject) => {
|
|
107
|
-
const script = document.createElement("script");
|
|
108
|
-
script.src = "/vendor/mermaid.min.js";
|
|
109
|
-
script.async = true;
|
|
110
|
-
script.onload = () => {
|
|
111
|
-
if (!window.mermaid) return reject(new Error("Mermaid failed to initialize"));
|
|
112
|
-
window.mermaid.initialize({ startOnLoad: false, securityLevel: "strict", theme: "dark" });
|
|
113
|
-
resolve(window.mermaid);
|
|
114
|
-
};
|
|
115
|
-
script.onerror = () => reject(new Error("Mermaid failed to load"));
|
|
116
|
-
document.head.append(script);
|
|
117
|
-
});
|
|
118
|
-
}
|
|
119
|
-
return mermaidLoading;
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
async function renderMermaidInto(container) {
|
|
123
|
-
if (!container || !container.querySelector("pre.diagram")) return;
|
|
124
|
-
try { await ensureMermaid(); } catch { return; }
|
|
125
|
-
for (const pre of container.querySelectorAll("pre.diagram")) {
|
|
126
|
-
if (pre.dataset.rendered === "1") continue;
|
|
127
|
-
const code = pre.textContent || "";
|
|
128
|
-
if (!code.trim()) continue;
|
|
129
|
-
try {
|
|
130
|
-
const { svg } = await window.mermaid.render("mmd" + (++mermaidSeq), code);
|
|
131
|
-
pre.innerHTML = svg;
|
|
132
|
-
pre.dataset.rendered = "1";
|
|
133
|
-
pre.classList.add("rendered");
|
|
134
|
-
} catch {
|
|
135
|
-
pre.classList.add("mermaid-error");
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
let theoryNodesCache = null;
|
|
140
|
-
let activeTheoryNode = "";
|
|
141
|
-
let activeView = "study";
|
|
142
|
-
let activeLayout = "triptych";
|
|
143
|
-
|
|
144
|
-
function setConnection(kind) {
|
|
145
|
-
el.connection.classList.remove("live", "down");
|
|
146
|
-
if (kind === "live") el.connection.classList.add("live");
|
|
147
|
-
if (kind === "down") el.connection.classList.add("down");
|
|
148
|
-
el.connection.title = kind === "live" ? "live" : kind === "down" ? "reconnecting" : "connecting";
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
function showNotice(text) {
|
|
152
|
-
if (!text) {
|
|
153
|
-
el.notice.hidden = true;
|
|
154
|
-
el.notice.textContent = "";
|
|
155
|
-
return;
|
|
156
|
-
}
|
|
157
|
-
el.notice.hidden = false;
|
|
158
|
-
el.notice.textContent = text;
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
function only(card) {
|
|
162
|
-
for (const c of [el.loading, el.idle, el.question, el.feedback, el.teach, el.mock]) {
|
|
163
|
-
if (c) c.hidden = c !== card;
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
function setPhase(phase) {
|
|
168
|
-
el.phasePill.textContent = phase || "idle";
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
function renderImages(images) {
|
|
172
|
-
el.images.replaceChildren();
|
|
173
|
-
for (const im of images || []) {
|
|
174
|
-
const src = imageSrc(im);
|
|
175
|
-
if (!src) continue;
|
|
176
|
-
const fig = document.createElement("figure");
|
|
177
|
-
fig.className = "exhibit";
|
|
178
|
-
const img = document.createElement("img");
|
|
179
|
-
img.src = src;
|
|
180
|
-
img.alt = im.label || "exhibit";
|
|
181
|
-
img.loading = "lazy";
|
|
182
|
-
img.addEventListener("click", () => window.open(img.src, "_blank", "noopener"));
|
|
183
|
-
const cap = document.createElement("figcaption");
|
|
184
|
-
cap.textContent = im.label || "exhibit";
|
|
185
|
-
fig.append(img, cap);
|
|
186
|
-
el.images.append(fig);
|
|
187
|
-
}
|
|
188
|
-
el.images.hidden = !el.images.children.length;
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
function renderOptions(q, readOnly) {
|
|
192
|
-
el.options.replaceChildren();
|
|
193
|
-
for (const opt of q.options || []) {
|
|
194
|
-
const b = document.createElement("button");
|
|
195
|
-
b.type = "button";
|
|
196
|
-
b.className = "option";
|
|
197
|
-
b.dataset.value = opt.value;
|
|
198
|
-
b.disabled = readOnly;
|
|
199
|
-
b.setAttribute("aria-pressed", selected.has(opt.value) ? "true" : "false");
|
|
200
|
-
if (selected.has(opt.value)) b.classList.add("selected");
|
|
201
|
-
const key = document.createElement("span");
|
|
202
|
-
key.className = "key";
|
|
203
|
-
key.textContent = opt.value;
|
|
204
|
-
const text = document.createElement("span");
|
|
205
|
-
text.textContent = opt.text || "(image option)";
|
|
206
|
-
b.append(key, text);
|
|
207
|
-
b.addEventListener("click", () => toggleOption(opt.value));
|
|
208
|
-
el.options.append(b);
|
|
209
|
-
}
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
function toggleOption(value) {
|
|
213
|
-
if (!state || state.phase !== "question" || !state.q) return;
|
|
214
|
-
if (state.q.multi) {
|
|
215
|
-
if (selected.has(value)) selected.delete(value);
|
|
216
|
-
else selected.add(value);
|
|
217
|
-
} else {
|
|
218
|
-
selected = new Set([value]);
|
|
219
|
-
}
|
|
220
|
-
updateSelectionUI();
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
function updateSelectionUI() {
|
|
224
|
-
const multi = state?.q?.multi === true;
|
|
225
|
-
for (const b of el.options.querySelectorAll(".option")) {
|
|
226
|
-
const on = selected.has(b.dataset.value);
|
|
227
|
-
b.classList.toggle("selected", on);
|
|
228
|
-
b.setAttribute("aria-pressed", on ? "true" : "false");
|
|
229
|
-
}
|
|
230
|
-
for (const b of el.confidenceButtons.querySelectorAll("button")) {
|
|
231
|
-
b.classList.toggle("active", Number(b.dataset.confidence) === confidence);
|
|
232
|
-
}
|
|
233
|
-
el.submit.disabled = !(state?.phase === "question" && selected.size > 0);
|
|
234
|
-
el.submit.textContent = multi ? "Submit selection" : "Submit answer";
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
function startTimer(key) {
|
|
238
|
-
if (timerHandle) clearInterval(timerHandle);
|
|
239
|
-
timerHandle = null;
|
|
240
|
-
if (key !== questionKey) {
|
|
241
|
-
questionKey = key;
|
|
242
|
-
startedAt = Date.now();
|
|
243
|
-
confidence = 2;
|
|
244
|
-
selected = new Set();
|
|
245
|
-
}
|
|
246
|
-
const tick = () => {
|
|
247
|
-
if (!state || state.phase !== "question") return;
|
|
248
|
-
const deadline = Number(state.meta?.mock?.deadline) || 0;
|
|
249
|
-
if (deadline) {
|
|
250
|
-
const left = Math.max(0, Math.floor((deadline - Date.now()) / 1000));
|
|
251
|
-
el.timer.textContent = `${Math.floor(left / 60)}:${String(left % 60).padStart(2, "0")} left`;
|
|
252
|
-
return;
|
|
253
|
-
}
|
|
254
|
-
const s = Math.max(0, Math.floor((Date.now() - startedAt) / 1000));
|
|
255
|
-
el.timer.textContent = `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
|
|
256
|
-
};
|
|
257
|
-
tick();
|
|
258
|
-
timerHandle = setInterval(tick, 1000);
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
function stopTimer() {
|
|
262
|
-
if (timerHandle) clearInterval(timerHandle);
|
|
263
|
-
timerHandle = null;
|
|
264
|
-
if (state) el.timer.textContent = "";
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
function setStemSize(node, text) {
|
|
268
|
-
const len = String(text || "").length;
|
|
269
|
-
node.classList.remove("stem-long", "stem-xlong");
|
|
270
|
-
if (len > 420) node.classList.add("stem-xlong");
|
|
271
|
-
else if (len > 220) node.classList.add("stem-long");
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
function renderQuestion(s, readOnly) {
|
|
275
|
-
only(el.question);
|
|
276
|
-
const q = s.q || {};
|
|
277
|
-
setPhase(s.phase);
|
|
278
|
-
const mockMeta = s.meta?.mock ? `mock ${(s.meta.mock.answered || 0) + 1}/${s.meta.mock.total || "?"}` : "";
|
|
279
|
-
const probeMeta = s.meta?.probe ? `probe ${s.meta.probe.answered || 1}/${s.meta.probe.total || 20}` : "";
|
|
280
|
-
el.qmeta.textContent = [q.domain, q.node, q.index ? `#${q.index}` : "", mockMeta || probeMeta].filter(Boolean).join(" · ");
|
|
281
|
-
el.stem.textContent = q.stem || "";
|
|
282
|
-
setStemSize(el.stem, q.stem || "");
|
|
283
|
-
el.questionNote.hidden = !readOnly;
|
|
284
|
-
el.questionNote.textContent = readOnly ? "Submitted; grading..." : "";
|
|
285
|
-
renderImages(q.images);
|
|
286
|
-
renderOptions(q, readOnly);
|
|
287
|
-
updateSelectionUI();
|
|
288
|
-
if (!readOnly) startTimer(`${q.id}:${s.rev}`);
|
|
289
|
-
else stopTimer();
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
function renderFeedback(s) {
|
|
293
|
-
only(el.feedback);
|
|
294
|
-
setPhase(s.phase);
|
|
295
|
-
stopTimer();
|
|
296
|
-
const f = s.feedback || {};
|
|
297
|
-
el.verdict.textContent = f.correct ? "Correct" : "Incorrect";
|
|
298
|
-
el.verdict.className = "verdict " + (f.correct ? "good" : "bad");
|
|
299
|
-
el.feedbackMeta.textContent = [f.box != null ? `box ${f.box}` : "", f.due ? `due ${String(f.due).slice(0, 10)}` : "", s.meta?.readiness || ""].filter(Boolean).join(" · ");
|
|
300
|
-
el.feedbackTitle.textContent = s.q?.stem || "Feedback";
|
|
301
|
-
setStemSize(el.feedbackTitle, s.q?.stem || "Feedback");
|
|
302
|
-
el.answerLine.textContent = `Answer: ${(f.answer || []).join(", ") || "?"} · you: ${(f.selected || []).join(", ") || "?"}`;
|
|
303
|
-
el.explanation.textContent = f.explanation || "";
|
|
304
|
-
const src = safeHttpUrl(f.source || "");
|
|
305
|
-
el.source.hidden = !src;
|
|
306
|
-
el.source.href = src || "#";
|
|
307
|
-
el.source.textContent = src || "";
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
function renderTeach(s) {
|
|
311
|
-
only(el.teach);
|
|
312
|
-
setPhase(s.phase);
|
|
313
|
-
stopTimer();
|
|
314
|
-
const t = s.teach || {};
|
|
315
|
-
el.teachMeta.textContent = [t.domain, t.reason].filter(Boolean).join(" · ");
|
|
316
|
-
el.teachTitle.textContent = t.title || t.node || "Teach";
|
|
317
|
-
el.teachSummary.textContent = t.summary || "";
|
|
318
|
-
el.teachTitle.classList.remove("stem-long", "stem-xlong");
|
|
319
|
-
el.teachSection.replaceChildren();
|
|
320
|
-
renderMarkdownInto(el.teachSection, t.section || "");
|
|
321
|
-
const doc = safeHttpUrl(t.docUrl || "");
|
|
322
|
-
el.teachDoc.hidden = !doc;
|
|
323
|
-
el.teachDoc.href = doc || "#";
|
|
324
|
-
el.teachDoc.textContent = doc || "";
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
function renderMock(s) {
|
|
328
|
-
only(el.mock);
|
|
329
|
-
setPhase("mock_summary");
|
|
330
|
-
stopTimer();
|
|
331
|
-
const m = s.mock || {};
|
|
332
|
-
el.mockMeta.textContent = `${m.correct || 0}/${m.completed ?? m.total ?? 0} correct · ${m.completed ?? m.total ?? 0}/${m.requested ?? m.total ?? 0} completed`;
|
|
333
|
-
el.mockTitle.textContent = m.score != null ? `Mock score: ${Math.round(m.score * 100)}%` : "Mock complete";
|
|
334
|
-
el.mockNote.textContent = `${m.timedOut ? "Time expired. " : ""}Requested ${m.requested ?? "?"} questions; completed ${m.completed ?? m.total ?? 0}. Feedback is shown now that the run is over. progress.json was not modified.`;
|
|
335
|
-
el.mockResults.replaceChildren();
|
|
336
|
-
for (const a of m.answers || []) {
|
|
337
|
-
const row = document.createElement("div");
|
|
338
|
-
row.className = "mock-row";
|
|
339
|
-
const mark = document.createElement("span");
|
|
340
|
-
mark.className = a.correct ? "ok" : "bad";
|
|
341
|
-
mark.textContent = a.correct ? "OK" : "MISS";
|
|
342
|
-
const q = document.createElement("span");
|
|
343
|
-
q.className = "q";
|
|
344
|
-
q.textContent = `${a.qid} · ${a.domain || ""}/${a.node || ""} · you ${(a.selected || []).join(",")} · answer ${(a.answer || []).join(",")}`;
|
|
345
|
-
const secs = document.createElement("span");
|
|
346
|
-
secs.className = "muted";
|
|
347
|
-
secs.textContent = a.seconds != null ? `${a.seconds}s` : "";
|
|
348
|
-
row.append(mark, q, secs);
|
|
349
|
-
el.mockResults.append(row);
|
|
350
|
-
}
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
function renderIdle(s) {
|
|
354
|
-
only(el.idle);
|
|
355
|
-
setPhase("idle");
|
|
356
|
-
stopTimer();
|
|
357
|
-
el.idleNote.textContent = s.meta?.note || "Start a practice run when you are ready.";
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
function render(s) {
|
|
361
|
-
state = s;
|
|
362
|
-
showNotice("");
|
|
363
|
-
el.certLine.textContent = `${s.cert || "cert"} · ${s.mode || "practice"}`;
|
|
364
|
-
el.readiness.textContent = s.meta?.readiness ? `readiness ${s.meta.readiness}` : "";
|
|
365
|
-
const phase = s.phase || "idle";
|
|
366
|
-
if (phase === "question") renderQuestion(s, false);
|
|
367
|
-
else if (phase === "submitted") renderQuestion(s, true);
|
|
368
|
-
else if (phase === "feedback") renderFeedback(s);
|
|
369
|
-
else if (phase === "teach") renderTeach(s);
|
|
370
|
-
else if (phase === "mock_summary") renderMock(s);
|
|
371
|
-
else renderIdle(s);
|
|
372
|
-
if (activeLayout === "command") void updateCommandStrip();
|
|
373
|
-
}
|
|
374
|
-
|
|
375
|
-
async function api(path, body) {
|
|
376
|
-
const res = await fetch(path, {
|
|
377
|
-
method: body === undefined ? "GET" : "POST",
|
|
378
|
-
credentials: "same-origin",
|
|
379
|
-
headers: body === undefined ? {} : { "content-type": "application/json" },
|
|
380
|
-
body: body === undefined ? undefined : JSON.stringify(body),
|
|
381
|
-
});
|
|
382
|
-
let data = {};
|
|
383
|
-
try { data = await res.json(); } catch { /* non-json */ }
|
|
384
|
-
if (!res.ok) {
|
|
385
|
-
if (res.status === 401) showNotice("Session expired. Reopen the token URL printed by the server.");
|
|
386
|
-
if (res.status === 409) await refresh();
|
|
387
|
-
throw new Error(data.error || `HTTP ${res.status}`);
|
|
388
|
-
}
|
|
389
|
-
return data;
|
|
390
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Study console wiring: boot, fetch, submit/next, event stream, and input
|
|
3
|
+
* binding. Presentation lives in the sibling spa-*.js modules; this file is the
|
|
4
|
+
* only place that talks to the server and the only place that wires listeners.
|
|
5
|
+
*/
|
|
6
|
+
import { el } from "./spa-dom.js";
|
|
7
|
+
import { store } from "./spa-store.js";
|
|
8
|
+
import { api } from "./spa-api.js";
|
|
9
|
+
import { only, setConnection, showNotice, setLayout, appendStatus } from "./spa-ui.js";
|
|
10
|
+
import { renderState, toggleOption, updateSelectionUI } from "./spa-question.js";
|
|
11
|
+
import { setView, updateCommandStrip } from "./spa-views.js";
|
|
12
|
+
import { handleChatEvent, renderChatHistory, sendChat, abortChat, syncChatControls } from "./spa-chat.js";
|
|
391
13
|
|
|
392
14
|
async function refresh() {
|
|
393
|
-
|
|
394
|
-
render(s);
|
|
395
|
-
}
|
|
396
|
-
|
|
397
|
-
async function submitAnswer() {
|
|
398
|
-
if (!state || state.phase !== "question" || !state.q || !selected.size) return;
|
|
399
|
-
el.submit.disabled = true;
|
|
400
|
-
try {
|
|
401
|
-
const seconds = Math.max(1, Math.round((Date.now() - startedAt) / 1000));
|
|
402
|
-
const data = await api("/api/answer", {
|
|
403
|
-
runId: state.runId,
|
|
404
|
-
rev: state.rev,
|
|
405
|
-
qid: state.q.id,
|
|
406
|
-
selected: [...selected],
|
|
407
|
-
confidence,
|
|
408
|
-
seconds,
|
|
409
|
-
});
|
|
410
|
-
render(data.state || await api("/api/state"));
|
|
411
|
-
} catch (e) {
|
|
412
|
-
showNotice(e.message);
|
|
413
|
-
try { await refresh(); } catch { /* keep notice */ }
|
|
414
|
-
}
|
|
415
|
-
}
|
|
416
|
-
|
|
417
|
-
async function nextQuestion() {
|
|
418
|
-
if (!state || !["feedback", "teach"].includes(state.phase)) return;
|
|
419
|
-
try {
|
|
420
|
-
const data = await api("/api/next", { runId: state.runId, rev: state.rev });
|
|
421
|
-
render(data.state || await api("/api/state"));
|
|
422
|
-
} catch (e) {
|
|
423
|
-
showNotice(e.message);
|
|
424
|
-
try { await refresh(); } catch { /* keep notice */ }
|
|
425
|
-
}
|
|
15
|
+
renderState(await api("/api/state"));
|
|
426
16
|
}
|
|
427
17
|
|
|
18
|
+
/** Enter a study mode: practice, probe, or timed mock. */
|
|
428
19
|
async function startPractice(mode = "practice") {
|
|
429
20
|
el.start.disabled = true;
|
|
430
21
|
try {
|
|
@@ -434,7 +25,7 @@ async function startPractice(mode = "practice") {
|
|
|
434
25
|
? { mode: "probe", probe_size: 20 }
|
|
435
26
|
: { mode: "practice" };
|
|
436
27
|
const data = await api("/api/start", body);
|
|
437
|
-
|
|
28
|
+
renderState(data.state || await api("/api/state"));
|
|
438
29
|
} catch (e) {
|
|
439
30
|
showNotice(e.message);
|
|
440
31
|
try { await refresh(); } catch { /* keep notice */ }
|
|
@@ -443,509 +34,74 @@ async function startPractice(mode = "practice") {
|
|
|
443
34
|
}
|
|
444
35
|
}
|
|
445
36
|
|
|
446
|
-
function
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
}
|
|
450
|
-
|
|
451
|
-
function chatScroll(force = false) {
|
|
452
|
-
if (!el.chatLog) return;
|
|
453
|
-
const near = el.chatLog.scrollHeight - el.chatLog.scrollTop - el.chatLog.clientHeight < 80;
|
|
454
|
-
if (force || near) el.chatLog.scrollTop = el.chatLog.scrollHeight;
|
|
455
|
-
}
|
|
456
|
-
|
|
457
|
-
function appendChatMessage(role, text) {
|
|
458
|
-
const div = document.createElement("div");
|
|
459
|
-
div.className = "chat-msg " + (role === "user" ? "user" : role === "system" ? "system" : "assistant");
|
|
460
|
-
if (role === "assistant") renderMarkdownInto(div, text);
|
|
461
|
-
else div.textContent = text;
|
|
462
|
-
el.chatLog.append(div);
|
|
463
|
-
chatScroll();
|
|
464
|
-
return div;
|
|
465
|
-
}
|
|
466
|
-
|
|
467
|
-
function setChatStatus(kind, text) {
|
|
468
|
-
el.chatStatus.className = "chat-status" + (kind === "live" ? " live" : kind === "busy" ? " busy" : "");
|
|
469
|
-
el.chatStatus.textContent = text;
|
|
470
|
-
}
|
|
471
|
-
|
|
472
|
-
function renderChatHistory(messages) {
|
|
473
|
-
el.chatLog.replaceChildren();
|
|
474
|
-
for (const m of messages || []) {
|
|
475
|
-
if (m.role === "user" || m.role === "assistant") appendChatMessage(m.role, m.text);
|
|
476
|
-
}
|
|
477
|
-
if (!el.chatLog.children.length) {
|
|
478
|
-
const empty = document.createElement("div");
|
|
479
|
-
empty.className = "chat-empty";
|
|
480
|
-
empty.textContent = chatEnabled
|
|
481
|
-
? "Ask about the current question, a concept, or why an answer is right. The coach teaches; the quiz card grades."
|
|
482
|
-
: (chatReason || "Teaching chat is disabled. Start the server with --pi <pi|path> or set PI_BIN.");
|
|
483
|
-
el.chatLog.append(empty);
|
|
484
|
-
}
|
|
485
|
-
}
|
|
486
|
-
|
|
487
|
-
function renderTool(event) {
|
|
488
|
-
let card = toolCards.get(event.id);
|
|
489
|
-
if (event.status === "start" || !card) {
|
|
490
|
-
card = document.createElement("div");
|
|
491
|
-
card.className = "chat-tool";
|
|
492
|
-
const head = document.createElement("div");
|
|
493
|
-
head.className = "tool-head";
|
|
494
|
-
const name = document.createElement("span");
|
|
495
|
-
name.textContent = event.name || "tool";
|
|
496
|
-
const state = document.createElement("span");
|
|
497
|
-
state.className = "muted";
|
|
498
|
-
head.append(name, state);
|
|
499
|
-
const body = document.createElement("pre");
|
|
500
|
-
body.className = "tool-body";
|
|
501
|
-
card.append(head, body);
|
|
502
|
-
el.chatLog.append(card);
|
|
503
|
-
toolCards.set(event.id, card);
|
|
504
|
-
}
|
|
505
|
-
const state = card.querySelector(".tool-head .muted");
|
|
506
|
-
const body = card.querySelector(".tool-body");
|
|
507
|
-
if (event.status === "end") {
|
|
508
|
-
state.textContent = event.isError ? "error" : "done";
|
|
509
|
-
if (event.isError) card.classList.add("error");
|
|
510
|
-
body.textContent = (event.text || "").slice(0, 3000);
|
|
511
|
-
} else {
|
|
512
|
-
state.textContent = event.status === "start" ? "running" : "…";
|
|
513
|
-
if (event.partial) body.textContent = JSON.stringify(event.partial).slice(0, 1000);
|
|
514
|
-
else if (event.args) body.textContent = JSON.stringify(event.args).slice(0, 1000);
|
|
515
|
-
}
|
|
516
|
-
chatScroll();
|
|
517
|
-
}
|
|
518
|
-
|
|
519
|
-
function handleChatEvent(event) {
|
|
520
|
-
switch (event.type) {
|
|
521
|
-
case "chat.user": {
|
|
522
|
-
if (pendingUser && pendingUser === event.text) pendingUser = "";
|
|
523
|
-
else appendChatMessage("user", event.text);
|
|
524
|
-
break;
|
|
525
|
-
}
|
|
526
|
-
case "chat.start": {
|
|
527
|
-
chatBusy = true;
|
|
528
|
-
el.chatStop.hidden = false;
|
|
529
|
-
el.chatSend.disabled = true;
|
|
530
|
-
setChatStatus("busy", "thinking");
|
|
531
|
-
chatStream = { div: appendChatMessage("assistant", ""), text: "" };
|
|
532
|
-
break;
|
|
533
|
-
}
|
|
534
|
-
case "chat.delta": {
|
|
535
|
-
if (!chatStream) chatStream = { div: appendChatMessage("assistant", ""), text: "" };
|
|
536
|
-
chatStream.text += event.text || "";
|
|
537
|
-
chatStream.div.textContent = chatStream.text;
|
|
538
|
-
chatScroll();
|
|
539
|
-
break;
|
|
540
|
-
}
|
|
541
|
-
case "chat.message": {
|
|
542
|
-
if (!chatStream) chatStream = { div: appendChatMessage("assistant", ""), text: "" };
|
|
543
|
-
renderMarkdownInto(chatStream.div, event.text || "");
|
|
544
|
-
chatStream = null;
|
|
545
|
-
chatScroll();
|
|
546
|
-
break;
|
|
547
|
-
}
|
|
548
|
-
case "chat.tool": {
|
|
549
|
-
renderTool(event);
|
|
550
|
-
break;
|
|
551
|
-
}
|
|
552
|
-
case "chat.done": {
|
|
553
|
-
chatBusy = false;
|
|
554
|
-
el.chatStop.hidden = true;
|
|
555
|
-
el.chatSend.disabled = !chatEnabled;
|
|
556
|
-
setChatStatus(chatEnabled ? "live" : "off", chatEnabled ? "ready" : "offline");
|
|
557
|
-
el.chatStatus.title = chatEnabled ? "Teaching chat ready" : (chatReason || "Teaching chat disabled");
|
|
558
|
-
if (!chatStream) break;
|
|
559
|
-
chatStream = null;
|
|
560
|
-
break;
|
|
561
|
-
}
|
|
562
|
-
case "chat.error": {
|
|
563
|
-
chatBusy = false;
|
|
564
|
-
el.chatStop.hidden = true;
|
|
565
|
-
el.chatSend.disabled = !chatEnabled;
|
|
566
|
-
setChatStatus("off", "error");
|
|
567
|
-
showNotice(event.error || "Chat failed.");
|
|
568
|
-
break;
|
|
569
|
-
}
|
|
570
|
-
case "chat.rotated": {
|
|
571
|
-
// The server compacted the session before it ran out of context.
|
|
572
|
-
appendChatMessage(
|
|
573
|
-
"system",
|
|
574
|
-
event.reason === "context-overflow"
|
|
575
|
-
? "Context limit reached - earlier turns were summarised. Keep going."
|
|
576
|
-
: "Long session - starting a fresh context with a recap of earlier turns."
|
|
577
|
-
);
|
|
578
|
-
break;
|
|
579
|
-
}
|
|
580
|
-
default:
|
|
581
|
-
break;
|
|
582
|
-
}
|
|
583
|
-
}
|
|
584
|
-
|
|
585
|
-
async function sendChat() {
|
|
586
|
-
const text = el.chatInput.value.trim();
|
|
587
|
-
if (!text || !chatEnabled || chatBusy) return;
|
|
588
|
-
el.chatInput.value = "";
|
|
589
|
-
pendingUser = text;
|
|
590
|
-
appendChatMessage("user", text);
|
|
591
|
-
chatBusy = true;
|
|
592
|
-
el.chatSend.disabled = true;
|
|
593
|
-
el.chatStop.hidden = false;
|
|
594
|
-
setChatStatus("busy", "thinking");
|
|
37
|
+
async function submitAnswer() {
|
|
38
|
+
if (!store.state || store.state.phase !== "question" || !store.state.q || !store.selected.size) return;
|
|
39
|
+
el.submit.disabled = true;
|
|
595
40
|
try {
|
|
596
|
-
const
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
41
|
+
const seconds = Math.max(1, Math.round((Date.now() - store.startedAt) / 1000));
|
|
42
|
+
const data = await api("/api/answer", {
|
|
43
|
+
runId: store.state.runId,
|
|
44
|
+
rev: store.state.rev,
|
|
45
|
+
qid: store.state.q.id,
|
|
46
|
+
selected: [...store.selected],
|
|
47
|
+
confidence: store.confidence,
|
|
48
|
+
seconds,
|
|
49
|
+
});
|
|
50
|
+
renderState(data.state || await api("/api/state"));
|
|
603
51
|
} catch (e) {
|
|
604
|
-
chatBusy = false;
|
|
605
|
-
pendingUser = "";
|
|
606
|
-
el.chatStop.hidden = true;
|
|
607
|
-
el.chatSend.disabled = !chatEnabled;
|
|
608
|
-
setChatStatus("off", "error");
|
|
609
52
|
showNotice(e.message);
|
|
53
|
+
try { await refresh(); } catch { /* keep notice */ }
|
|
610
54
|
}
|
|
611
55
|
}
|
|
612
56
|
|
|
613
|
-
async function
|
|
57
|
+
async function nextQuestion() {
|
|
58
|
+
if (!store.state || !["feedback", "teach"].includes(store.state.phase)) return;
|
|
614
59
|
try {
|
|
615
|
-
await api("/api/
|
|
60
|
+
const data = await api("/api/next", { runId: store.state.runId, rev: store.state.rev });
|
|
61
|
+
renderState(data.state || await api("/api/state"));
|
|
616
62
|
} catch (e) {
|
|
617
63
|
showNotice(e.message);
|
|
64
|
+
try { await refresh(); } catch { /* keep notice */ }
|
|
618
65
|
}
|
|
619
66
|
}
|
|
620
67
|
|
|
621
|
-
function
|
|
622
|
-
activeLayout = ["triptych", "command"].includes(name) ? name : "triptych";
|
|
623
|
-
el.app.dataset.layout = activeLayout;
|
|
624
|
-
for (const b of el.layoutButtons) b.classList.toggle("active", b.dataset.appLayout === activeLayout);
|
|
625
|
-
localStorage.setItem("certCoachLayout", activeLayout);
|
|
626
|
-
if (activeLayout === "command") void updateCommandStrip();
|
|
627
|
-
}
|
|
628
|
-
|
|
629
|
-
async function updateCommandStrip() {
|
|
68
|
+
async function loadCertPicker(current) {
|
|
630
69
|
try {
|
|
631
|
-
const data = await api("/api/
|
|
632
|
-
|
|
633
|
-
const
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
el.cmdDue.textContent = String(d.due || 0);
|
|
640
|
-
const weak = (p.weakNodes || [])[0];
|
|
641
|
-
el.cmdWeak.textContent = weak?.domain || "—";
|
|
642
|
-
el.cmdWeakNote.textContent = weak ? `${weak.title} · ${Math.round((weak.accuracy || 0) * 100)}%` : "No attempts yet";
|
|
643
|
-
el.cmdReadinessBar.style.setProperty("--value", `${readiness}%`);
|
|
644
|
-
el.cmdCoverageBar.style.setProperty("--value", `${coverage}%`);
|
|
645
|
-
el.cmdDomains.replaceChildren();
|
|
646
|
-
for (const dom of p.domains || []) {
|
|
647
|
-
const row = document.createElement("div");
|
|
648
|
-
row.className = "domain-row";
|
|
649
|
-
const name = document.createElement("span");
|
|
650
|
-
name.textContent = dom.name;
|
|
651
|
-
const meter = document.createElement("div");
|
|
652
|
-
meter.className = "domain-meter";
|
|
653
|
-
const fill = document.createElement("i");
|
|
654
|
-
fill.style.setProperty("--value", `${Math.round((dom.accuracy || 0) * 100)}%`);
|
|
655
|
-
meter.append(fill);
|
|
656
|
-
const val = document.createElement("b");
|
|
657
|
-
val.textContent = `${Math.round((dom.accuracy || 0) * 100)}%`;
|
|
658
|
-
row.append(name, meter, val);
|
|
659
|
-
el.cmdDomains.append(row);
|
|
70
|
+
const data = await api("/api/certs");
|
|
71
|
+
el.certSelect.replaceChildren();
|
|
72
|
+
for (const c of data.certs || []) {
|
|
73
|
+
const opt = document.createElement("option");
|
|
74
|
+
opt.value = c.code;
|
|
75
|
+
opt.textContent = c.title ? `${c.code} — ${c.title}` : c.code;
|
|
76
|
+
if (c.code === current) opt.selected = true;
|
|
77
|
+
el.certSelect.append(opt);
|
|
660
78
|
}
|
|
79
|
+
el.certSelect.disabled = (data.certs || []).length < 2;
|
|
661
80
|
} catch {
|
|
662
|
-
|
|
663
|
-
}
|
|
664
|
-
}
|
|
665
|
-
|
|
666
|
-
function setView(name) {
|
|
667
|
-
activeView = name;
|
|
668
|
-
for (const b of document.querySelectorAll(".nav button, .side-nav button")) b.classList.toggle("active", b.dataset.tab === name);
|
|
669
|
-
el.studyView.hidden = name !== "study";
|
|
670
|
-
el.pathView.hidden = name !== "path";
|
|
671
|
-
el.dashboardView.hidden = name !== "dashboard";
|
|
672
|
-
el.theoryView.hidden = name !== "theory";
|
|
673
|
-
el.logsView.hidden = name !== "logs";
|
|
674
|
-
if (name === "path") loadPath();
|
|
675
|
-
if (name === "dashboard") loadDashboard();
|
|
676
|
-
if (name === "theory") loadTheory();
|
|
677
|
-
if (name === "logs") loadLogs();
|
|
678
|
-
}
|
|
679
|
-
|
|
680
|
-
function stat(k, v) {
|
|
681
|
-
const d = document.createElement("div");
|
|
682
|
-
d.className = "stat";
|
|
683
|
-
const kk = document.createElement("div");
|
|
684
|
-
kk.className = "k";
|
|
685
|
-
kk.textContent = k;
|
|
686
|
-
const vv = document.createElement("div");
|
|
687
|
-
vv.className = "v";
|
|
688
|
-
vv.textContent = String(v);
|
|
689
|
-
d.append(kk, vv);
|
|
690
|
-
return d;
|
|
691
|
-
}
|
|
692
|
-
|
|
693
|
-
async function loadPath() {
|
|
694
|
-
el.pathRoute.textContent = "Loading...";
|
|
695
|
-
el.pathDetail.textContent = "";
|
|
696
|
-
try {
|
|
697
|
-
const data = await api("/api/status");
|
|
698
|
-
const p = data.plan || {};
|
|
699
|
-
el.pathMeta.textContent = p.ready
|
|
700
|
-
? "target met"
|
|
701
|
-
: `probe ${Math.min(p.seenQuestions || 0, p.probeSize || 20)}/${p.probeSize || 20} · target ${Math.round((p.target?.readiness || 0.85) * 100)}% readiness / ${Math.round((p.target?.coverage || 0.8) * 100)}% coverage`;
|
|
702
|
-
el.pathRoute.replaceChildren();
|
|
703
|
-
const steps = document.createElement("div");
|
|
704
|
-
steps.className = "route-steps";
|
|
705
|
-
for (const step of p.steps || []) {
|
|
706
|
-
const d = document.createElement("div");
|
|
707
|
-
d.className = "route-step " + (step.state || "");
|
|
708
|
-
d.textContent = `${step.name} · ${step.state || ""}`;
|
|
709
|
-
steps.append(d);
|
|
710
|
-
}
|
|
711
|
-
el.pathRoute.append(steps);
|
|
712
|
-
const callout = document.createElement("div");
|
|
713
|
-
callout.className = "route-callout";
|
|
714
|
-
const phase = document.createElement("h3");
|
|
715
|
-
phase.textContent = p.ready ? "READY" : String(p.phase || "progress").toUpperCase();
|
|
716
|
-
const why = document.createElement("p");
|
|
717
|
-
why.textContent = p.why || "";
|
|
718
|
-
const next = document.createElement("p");
|
|
719
|
-
next.textContent = "Next: " + (p.next || "");
|
|
720
|
-
const criteria = document.createElement("p");
|
|
721
|
-
criteria.className = "muted";
|
|
722
|
-
criteria.textContent = p.criteria || "";
|
|
723
|
-
callout.append(phase, why, next, criteria);
|
|
724
|
-
el.pathRoute.append(callout);
|
|
725
|
-
|
|
726
|
-
el.pathDetail.replaceChildren();
|
|
727
|
-
for (const d of p.domains || []) {
|
|
728
|
-
const section = document.createElement("section");
|
|
729
|
-
section.className = "path-domain";
|
|
730
|
-
const h = document.createElement("h3");
|
|
731
|
-
h.textContent = `${d.name} · ${Math.round((d.weight_min || 0) * 100)}–${Math.round((d.weight_max || 0) * 100)}% of exam`;
|
|
732
|
-
const summary = document.createElement("p");
|
|
733
|
-
summary.className = "muted";
|
|
734
|
-
summary.textContent = `${d.seen}/${d.total} questions seen · ${d.accuracy == null ? "no accuracy yet" : `${Math.round(d.accuracy * 100)}% accuracy`} · coverage ${Math.round((d.coverage || 0) * 100)}%`;
|
|
735
|
-
section.append(h, summary);
|
|
736
|
-
if (d.skills?.length) {
|
|
737
|
-
const skills = document.createElement("ul");
|
|
738
|
-
skills.className = "path-skills";
|
|
739
|
-
for (const s of d.skills) { const li = document.createElement("li"); li.textContent = s; skills.append(li); }
|
|
740
|
-
section.append(skills);
|
|
741
|
-
}
|
|
742
|
-
const table = document.createElement("table");
|
|
743
|
-
table.className = "node-table";
|
|
744
|
-
const head = document.createElement("tr");
|
|
745
|
-
for (const label of ["Node / subdomain", "Status", "Questions", "Accuracy", "Box"]) {
|
|
746
|
-
const th = document.createElement("th"); th.textContent = label; head.append(th);
|
|
747
|
-
}
|
|
748
|
-
table.append(head);
|
|
749
|
-
for (const n of d.nodes || []) {
|
|
750
|
-
const tr = document.createElement("tr");
|
|
751
|
-
const title = document.createElement("td");
|
|
752
|
-
title.className = "path-node-title";
|
|
753
|
-
title.textContent = n.title;
|
|
754
|
-
const small = document.createElement("small");
|
|
755
|
-
small.textContent = n.summary || n.id;
|
|
756
|
-
title.append(small);
|
|
757
|
-
const status = document.createElement("td");
|
|
758
|
-
const tag = document.createElement("span");
|
|
759
|
-
tag.className = "status-tag " + (n.status || "unseen");
|
|
760
|
-
tag.textContent = n.status || "unseen";
|
|
761
|
-
status.append(tag);
|
|
762
|
-
const qs = document.createElement("td");
|
|
763
|
-
qs.className = "num";
|
|
764
|
-
qs.textContent = `${n.seen}/${n.total}`;
|
|
765
|
-
const acc = document.createElement("td");
|
|
766
|
-
acc.className = "num";
|
|
767
|
-
acc.textContent = n.accuracy == null ? "—" : `${Math.round(n.accuracy * 100)}%`;
|
|
768
|
-
const box = document.createElement("td");
|
|
769
|
-
box.className = "num";
|
|
770
|
-
box.textContent = String(n.box ?? 0);
|
|
771
|
-
tr.append(title, status, qs, acc, box);
|
|
772
|
-
table.append(tr);
|
|
773
|
-
}
|
|
774
|
-
section.append(table);
|
|
775
|
-
el.pathDetail.append(section);
|
|
776
|
-
}
|
|
777
|
-
} catch (e) {
|
|
778
|
-
el.pathRoute.textContent = e.message;
|
|
779
|
-
}
|
|
780
|
-
}
|
|
781
|
-
|
|
782
|
-
async function loadDashboard() {
|
|
783
|
-
el.dashboardBody.textContent = "Loading...";
|
|
784
|
-
try {
|
|
785
|
-
const data = await api("/api/status");
|
|
786
|
-
const d = data.dashboard || {};
|
|
787
|
-
const r = d.readiness || {};
|
|
788
|
-
el.dashboardMeta.textContent = `${d.title || ""} · ${d.verified || 0}/${d.total || 0} verified`;
|
|
789
|
-
el.dashboardBody.replaceChildren();
|
|
790
|
-
const grid = document.createElement("div");
|
|
791
|
-
grid.className = "dash-grid";
|
|
792
|
-
grid.append(
|
|
793
|
-
stat("Readiness", `${((r.value || 0) * 100).toFixed(1)}%`),
|
|
794
|
-
stat("Coverage", `${((r.coverage || 0) * 100).toFixed(0)}%`),
|
|
795
|
-
stat("Due now", d.due || 0),
|
|
796
|
-
stat("Gaps / review", `${d.boxes?.[1] || 0} / ${d.boxes?.[2] || 0}`),
|
|
797
|
-
stat("Mastered", d.boxes?.[3] || 0),
|
|
798
|
-
stat("Sessions", d.sessions || 0)
|
|
799
|
-
);
|
|
800
|
-
el.dashboardBody.append(grid);
|
|
801
|
-
|
|
802
|
-
const domains = document.createElement("div");
|
|
803
|
-
for (const [id, v] of Object.entries(r.by_domain || {})) {
|
|
804
|
-
const row = document.createElement("div");
|
|
805
|
-
row.className = "domain-row";
|
|
806
|
-
const name = document.createElement("span");
|
|
807
|
-
name.textContent = id;
|
|
808
|
-
const bar = document.createElement("div");
|
|
809
|
-
bar.className = "bar";
|
|
810
|
-
const fill = document.createElement("span");
|
|
811
|
-
fill.style.width = `${Math.round((v.accuracy || 0) * 100)}%`;
|
|
812
|
-
bar.append(fill);
|
|
813
|
-
const num = document.createElement("span");
|
|
814
|
-
num.className = "muted";
|
|
815
|
-
num.textContent = `${v.seen}/${v.total}${v.accuracy == null ? "" : ` · ${Math.round(v.accuracy * 100)}%`}`;
|
|
816
|
-
row.append(name, bar, num);
|
|
817
|
-
domains.append(row);
|
|
818
|
-
}
|
|
819
|
-
el.dashboardBody.append(domains);
|
|
820
|
-
if (d.weak?.length) {
|
|
821
|
-
const weak = document.createElement("div");
|
|
822
|
-
weak.className = "weak-list";
|
|
823
|
-
weak.textContent = "Weakest domains: " + d.weak.map((x) => `${x.domain} ${Math.round((x.accuracy || 0) * 100)}%`).join(" · ");
|
|
824
|
-
el.dashboardBody.append(weak);
|
|
825
|
-
}
|
|
826
|
-
} catch (e) {
|
|
827
|
-
el.dashboardBody.textContent = e.message;
|
|
828
|
-
}
|
|
829
|
-
}
|
|
830
|
-
|
|
831
|
-
async function loadTheory() {
|
|
832
|
-
try {
|
|
833
|
-
if (!theoryNodesCache) {
|
|
834
|
-
const data = await api("/api/theory");
|
|
835
|
-
theoryNodesCache = data.nodes || [];
|
|
836
|
-
el.theoryNodes.replaceChildren();
|
|
837
|
-
for (const n of theoryNodesCache) {
|
|
838
|
-
const b = document.createElement("button");
|
|
839
|
-
b.type = "button";
|
|
840
|
-
b.textContent = n.title || n.id;
|
|
841
|
-
b.dataset.node = n.id;
|
|
842
|
-
b.addEventListener("click", () => selectTheory(n.id));
|
|
843
|
-
el.theoryNodes.append(b);
|
|
844
|
-
}
|
|
845
|
-
}
|
|
846
|
-
const preferred = state?.q?.node || state?.teach?.node || theoryNodesCache[0]?.id;
|
|
847
|
-
if (preferred) await selectTheory(preferred);
|
|
848
|
-
} catch (e) {
|
|
849
|
-
el.theoryBody.textContent = e.message;
|
|
850
|
-
}
|
|
851
|
-
}
|
|
852
|
-
|
|
853
|
-
async function selectTheory(node) {
|
|
854
|
-
activeTheoryNode = node;
|
|
855
|
-
for (const b of el.theoryNodes.querySelectorAll("button")) b.classList.toggle("active", b.dataset.node === node);
|
|
856
|
-
el.theoryBody.textContent = "Loading...";
|
|
857
|
-
try {
|
|
858
|
-
const t = await api("/api/theory?node=" + encodeURIComponent(node));
|
|
859
|
-
el.theoryBody.replaceChildren();
|
|
860
|
-
const h = document.createElement("h2");
|
|
861
|
-
h.textContent = t.title || node;
|
|
862
|
-
const meta = document.createElement("p");
|
|
863
|
-
meta.className = "muted";
|
|
864
|
-
meta.textContent = [t.domain, t.summary].filter(Boolean).join(" · ");
|
|
865
|
-
el.theoryBody.append(h, meta);
|
|
866
|
-
const body = document.createElement("div");
|
|
867
|
-
renderMarkdownInto(body, t.section || "No theory section yet.");
|
|
868
|
-
el.theoryBody.append(body);
|
|
869
|
-
const doc = safeHttpUrl(t.docUrl || "");
|
|
870
|
-
if (doc) {
|
|
871
|
-
const p = document.createElement("p");
|
|
872
|
-
const a = document.createElement("a");
|
|
873
|
-
a.href = doc;
|
|
874
|
-
a.target = "_blank";
|
|
875
|
-
a.rel = "noreferrer";
|
|
876
|
-
a.textContent = doc;
|
|
877
|
-
p.append(a);
|
|
878
|
-
el.theoryBody.append(p);
|
|
879
|
-
}
|
|
880
|
-
} catch (e) {
|
|
881
|
-
el.theoryBody.textContent = e.message;
|
|
882
|
-
}
|
|
883
|
-
}
|
|
884
|
-
|
|
885
|
-
async function loadLogs() {
|
|
886
|
-
el.logsBody.textContent = "Loading...";
|
|
887
|
-
try {
|
|
888
|
-
const data = await api("/api/log");
|
|
889
|
-
el.logsBody.textContent = data.text || "No study log yet.";
|
|
890
|
-
} catch (e) {
|
|
891
|
-
el.logsBody.textContent = e.message;
|
|
892
|
-
}
|
|
893
|
-
}
|
|
894
|
-
|
|
895
|
-
function appendStatus(lines) {
|
|
896
|
-
for (const item of lines || []) {
|
|
897
|
-
const div = document.createElement("div");
|
|
898
|
-
div.className = "status-line";
|
|
899
|
-
const t = item?.at ? new Date(item.at).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }) : "";
|
|
900
|
-
const strong = document.createElement("strong");
|
|
901
|
-
strong.textContent = t ? `${t} ` : "";
|
|
902
|
-
const span = document.createElement("span");
|
|
903
|
-
span.textContent = item?.line || "";
|
|
904
|
-
div.append(strong, span);
|
|
905
|
-
el.statusLog.prepend(div);
|
|
81
|
+
el.certSelect.hidden = true;
|
|
906
82
|
}
|
|
907
|
-
while (el.statusLog.children.length > 80) el.statusLog.lastChild.remove();
|
|
908
|
-
// Newest just under the "Coach log" title; older entries stay below.
|
|
909
|
-
el.statusLog.parentElement.scrollTop = 0;
|
|
910
83
|
}
|
|
911
84
|
|
|
912
85
|
function connectEvents() {
|
|
913
|
-
if (eventSource) eventSource.close();
|
|
914
|
-
eventSource = new EventSource("/api/events");
|
|
915
|
-
eventSource.onopen = () => setConnection("live");
|
|
916
|
-
eventSource.onerror = () => setConnection("down");
|
|
917
|
-
eventSource.addEventListener("state", (event) => {
|
|
86
|
+
if (store.eventSource) store.eventSource.close();
|
|
87
|
+
store.eventSource = new EventSource("/api/events");
|
|
88
|
+
store.eventSource.onopen = () => setConnection("live");
|
|
89
|
+
store.eventSource.onerror = () => setConnection("down");
|
|
90
|
+
store.eventSource.addEventListener("state", (event) => {
|
|
918
91
|
try {
|
|
919
92
|
const data = JSON.parse(event.data);
|
|
920
|
-
if (data.state)
|
|
93
|
+
if (data.state) renderState(data.state);
|
|
921
94
|
if (data.status?.length) appendStatus(data.status);
|
|
922
95
|
setConnection("live");
|
|
923
96
|
} catch {
|
|
924
97
|
/* ignore malformed event */
|
|
925
98
|
}
|
|
926
99
|
});
|
|
927
|
-
eventSource.addEventListener("chat", (event) => {
|
|
100
|
+
store.eventSource.addEventListener("chat", (event) => {
|
|
928
101
|
try { handleChatEvent(JSON.parse(event.data)); } catch { /* ignore malformed event */ }
|
|
929
102
|
});
|
|
930
103
|
}
|
|
931
104
|
|
|
932
|
-
async function loadCertPicker(current) {
|
|
933
|
-
try {
|
|
934
|
-
const data = await api("/api/certs");
|
|
935
|
-
el.certSelect.replaceChildren();
|
|
936
|
-
for (const c of data.certs || []) {
|
|
937
|
-
const opt = document.createElement("option");
|
|
938
|
-
opt.value = c.code;
|
|
939
|
-
opt.textContent = c.title ? `${c.code} — ${c.title}` : c.code;
|
|
940
|
-
if (c.code === current) opt.selected = true;
|
|
941
|
-
el.certSelect.append(opt);
|
|
942
|
-
}
|
|
943
|
-
el.certSelect.disabled = (data.certs || []).length < 2;
|
|
944
|
-
} catch {
|
|
945
|
-
el.certSelect.hidden = true;
|
|
946
|
-
}
|
|
947
|
-
}
|
|
948
|
-
|
|
949
105
|
async function bootstrap() {
|
|
950
106
|
try {
|
|
951
107
|
// Strip the token from the address bar; the server exchanged it for a cookie.
|
|
@@ -957,15 +113,12 @@ async function bootstrap() {
|
|
|
957
113
|
const data = await api("/api/bootstrap");
|
|
958
114
|
el.certLine.textContent = `${data.cert || "cert"} · ${data.title || ""}`;
|
|
959
115
|
setLayout(localStorage.getItem("certCoachLayout") || "triptych");
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
el.chatSend.disabled = !chatEnabled;
|
|
964
|
-
el.chatSend.textContent = chatEnabled ? "Send" : "Chat off";
|
|
965
|
-
setChatStatus(chatEnabled ? "live" : "off", chatEnabled ? "ready" : "offline");
|
|
116
|
+
store.chat.enabled = Boolean(data.chat?.enabled);
|
|
117
|
+
store.chat.reason = data.chat?.status?.error || "";
|
|
118
|
+
syncChatControls();
|
|
966
119
|
renderChatHistory(data.chat?.messages || []);
|
|
967
120
|
await loadCertPicker(data.cert);
|
|
968
|
-
|
|
121
|
+
renderState(data.state);
|
|
969
122
|
setView("study");
|
|
970
123
|
connectEvents();
|
|
971
124
|
} catch (e) {
|
|
@@ -975,9 +128,11 @@ async function bootstrap() {
|
|
|
975
128
|
}
|
|
976
129
|
}
|
|
977
130
|
|
|
131
|
+
// ── listeners ────────────────────────────────────────────────────────────────
|
|
978
132
|
el.submit.addEventListener("click", submitAnswer);
|
|
979
133
|
el.next.addEventListener("click", nextQuestion);
|
|
980
134
|
el.teachNext.addEventListener("click", nextQuestion);
|
|
135
|
+
|
|
981
136
|
el.certSelect.addEventListener("change", async () => {
|
|
982
137
|
const code = el.certSelect.value;
|
|
983
138
|
try {
|
|
@@ -989,6 +144,7 @@ el.certSelect.addEventListener("change", async () => {
|
|
|
989
144
|
el.certSelect.disabled = false;
|
|
990
145
|
}
|
|
991
146
|
});
|
|
147
|
+
|
|
992
148
|
el.certNew.addEventListener("click", async () => {
|
|
993
149
|
const raw = window.prompt("New certification code (e.g. AZ-104, DP-600, AWS-SAA):");
|
|
994
150
|
if (!raw) return;
|
|
@@ -999,18 +155,27 @@ el.certNew.addEventListener("click", async () => {
|
|
|
999
155
|
showNotice(e.message);
|
|
1000
156
|
}
|
|
1001
157
|
});
|
|
158
|
+
|
|
1002
159
|
el.start.addEventListener("click", () => startPractice("practice"));
|
|
1003
160
|
el.startProbe.addEventListener("click", () => startPractice("probe"));
|
|
1004
161
|
el.startMock.addEventListener("click", () => startPractice("mock"));
|
|
1005
162
|
el.mockNew.addEventListener("click", () => startPractice("mock"));
|
|
1006
163
|
el.mockPractice.addEventListener("click", () => startPractice("practice"));
|
|
164
|
+
|
|
1007
165
|
for (const nav of [el.nav, el.sideNav]) {
|
|
1008
166
|
nav?.addEventListener("click", (e) => {
|
|
1009
167
|
const b = e.target.closest("button[data-tab]");
|
|
1010
168
|
if (b) setView(b.dataset.tab);
|
|
1011
169
|
});
|
|
1012
170
|
}
|
|
1013
|
-
|
|
171
|
+
|
|
172
|
+
for (const b of el.layoutButtons) {
|
|
173
|
+
b.addEventListener("click", () => {
|
|
174
|
+
setLayout(b.dataset.appLayout);
|
|
175
|
+
if (store.activeLayout === "command") void updateCommandStrip();
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
|
|
1014
179
|
el.chatForm.addEventListener("submit", (e) => { e.preventDefault(); sendChat(); });
|
|
1015
180
|
el.chatStop.addEventListener("click", abortChat);
|
|
1016
181
|
el.chatInput.addEventListener("keydown", (e) => {
|
|
@@ -1019,31 +184,33 @@ el.chatInput.addEventListener("keydown", (e) => {
|
|
|
1019
184
|
sendChat();
|
|
1020
185
|
}
|
|
1021
186
|
});
|
|
187
|
+
|
|
1022
188
|
for (const b of el.confidenceButtons.querySelectorAll("button")) {
|
|
1023
189
|
b.addEventListener("click", () => {
|
|
1024
|
-
confidence = Number(b.dataset.confidence);
|
|
190
|
+
store.confidence = Number(b.dataset.confidence);
|
|
1025
191
|
updateSelectionUI();
|
|
1026
192
|
});
|
|
1027
193
|
}
|
|
1028
194
|
|
|
195
|
+
// Keyboard: letter/number picks an option, Enter submits or advances.
|
|
1029
196
|
document.addEventListener("keydown", (e) => {
|
|
1030
|
-
if (!state) return;
|
|
197
|
+
if (!store.state) return;
|
|
1031
198
|
const tag = (e.target?.tagName || "").toLowerCase();
|
|
1032
199
|
if (["input", "textarea", "select"].includes(tag)) return;
|
|
1033
|
-
if (state.phase === "question" && state.q) {
|
|
200
|
+
if (store.state.phase === "question" && store.state.q) {
|
|
1034
201
|
const key = e.key.toUpperCase();
|
|
1035
202
|
if (/^[A-H]$/.test(key) || /^[1-8]$/.test(key)) {
|
|
1036
203
|
const idx = /^[1-8]$/.test(key) ? Number(key) - 1 : key.charCodeAt(0) - 65;
|
|
1037
|
-
const opt = state.q.options?.[idx];
|
|
204
|
+
const opt = store.state.q.options?.[idx];
|
|
1038
205
|
if (opt) {
|
|
1039
206
|
e.preventDefault();
|
|
1040
207
|
toggleOption(opt.value);
|
|
1041
208
|
}
|
|
1042
|
-
} else if (e.key === "Enter" && selected.size) {
|
|
209
|
+
} else if (e.key === "Enter" && store.selected.size) {
|
|
1043
210
|
e.preventDefault();
|
|
1044
211
|
submitAnswer();
|
|
1045
212
|
}
|
|
1046
|
-
} else if (["feedback", "teach"].includes(state.phase) && e.key === "Enter") {
|
|
213
|
+
} else if (["feedback", "teach"].includes(store.state.phase) && e.key === "Enter") {
|
|
1047
214
|
e.preventDefault();
|
|
1048
215
|
nextQuestion();
|
|
1049
216
|
}
|