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/web/public/app.js CHANGED
@@ -1,430 +1,21 @@
1
- import { safeHttpUrl, imageSrc } from "./spa-logic.js";
2
- import { markdownFragment } from "./spa-render.js";
3
-
4
- const $ = (id) => document.getElementById(id);
5
-
6
- const el = {
7
- app: $("app"),
8
- certLine: $("cert-line"),
9
- certSelect: $("cert-select"),
10
- certNew: $("cert-new"),
11
- phasePill: $("phase-pill"),
12
- readiness: $("readiness"),
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
- const s = await api("/api/state");
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
- render(data.state || await api("/api/state"));
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 renderMarkdownInto(node, text) {
447
- node.replaceChildren(markdownFragment(document, text));
448
- void renderMermaidInto(node);
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 res = await api("/api/prompt", { text });
597
- if (res.disposition === "handled") {
598
- chatBusy = false;
599
- el.chatStop.hidden = true;
600
- el.chatSend.disabled = false;
601
- setChatStatus("live", "ready");
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 abortChat() {
57
+ async function nextQuestion() {
58
+ if (!store.state || !["feedback", "teach"].includes(store.state.phase)) return;
614
59
  try {
615
- await api("/api/abort", {});
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 setLayout(name) {
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/status");
632
- const d = data.dashboard || {};
633
- const p = data.plan || {};
634
- const r = d.readiness || {};
635
- const readiness = Math.round((r.value || 0) * 100);
636
- const coverage = Math.round((r.coverage || 0) * 100);
637
- el.cmdReadiness.textContent = `${readiness}%`;
638
- el.cmdCoverage.textContent = `${coverage}%`;
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
- /* command strip is optional */
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) render(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
- chatEnabled = Boolean(data.chat?.enabled);
961
- chatReason = data.chat?.status?.error || "";
962
- el.chatInput.disabled = !chatEnabled;
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
- render(data.state);
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
- for (const b of el.layoutButtons) b.addEventListener("click", () => setLayout(b.dataset.appLayout));
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
  }