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
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Leaf UI helpers: connection dot, notice bar, card switching, phase pill,
|
|
3
|
+
* layout toggle, and the coach status log.
|
|
4
|
+
*
|
|
5
|
+
* No feature logic lives here, so every module can depend on these freely.
|
|
6
|
+
*/
|
|
7
|
+
import { el } from "./spa-dom.js";
|
|
8
|
+
import { store } from "./spa-store.js";
|
|
9
|
+
|
|
10
|
+
export function setConnection(kind) {
|
|
11
|
+
el.connection.classList.remove("live", "down");
|
|
12
|
+
if (kind === "live") el.connection.classList.add("live");
|
|
13
|
+
if (kind === "down") el.connection.classList.add("down");
|
|
14
|
+
el.connection.title = kind === "live" ? "live" : kind === "down" ? "reconnecting" : "connecting";
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function showNotice(text) {
|
|
18
|
+
if (!text) {
|
|
19
|
+
el.notice.hidden = true;
|
|
20
|
+
el.notice.textContent = "";
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
el.notice.hidden = false;
|
|
24
|
+
el.notice.textContent = text;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Show exactly one of the study cards. `null` shows none. */
|
|
28
|
+
export function only(card) {
|
|
29
|
+
for (const c of [el.loading, el.idle, el.question, el.feedback, el.teach, el.mock]) {
|
|
30
|
+
if (c) c.hidden = c !== card;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function setPhase(phase) {
|
|
35
|
+
el.phasePill.textContent = phase || "idle";
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function setLayout(name) {
|
|
39
|
+
store.activeLayout = ["triptych", "command"].includes(name) ? name : "triptych";
|
|
40
|
+
el.app.dataset.layout = store.activeLayout;
|
|
41
|
+
for (const b of el.layoutButtons) b.classList.toggle("active", b.dataset.appLayout === store.activeLayout);
|
|
42
|
+
localStorage.setItem("certCoachLayout", store.activeLayout);
|
|
43
|
+
return store.activeLayout;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Prepend coach log lines (newest first) and cap the list at 80. */
|
|
47
|
+
export function appendStatus(lines) {
|
|
48
|
+
for (const item of lines || []) {
|
|
49
|
+
const div = document.createElement("div");
|
|
50
|
+
div.className = "status-line";
|
|
51
|
+
const t = item?.at ? new Date(item.at).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }) : "";
|
|
52
|
+
const strong = document.createElement("strong");
|
|
53
|
+
strong.textContent = t ? `${t} ` : "";
|
|
54
|
+
const span = document.createElement("span");
|
|
55
|
+
span.textContent = item?.line || "";
|
|
56
|
+
div.append(strong, span);
|
|
57
|
+
el.statusLog.prepend(div);
|
|
58
|
+
}
|
|
59
|
+
while (el.statusLog.children.length > 80) el.statusLog.lastChild.remove();
|
|
60
|
+
// Newest just under the "Coach log" title; older entries stay below.
|
|
61
|
+
el.statusLog.parentElement.scrollTop = 0;
|
|
62
|
+
}
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The non-study views: Path (blueprint route), Dashboard (readiness), Theory
|
|
3
|
+
* (per-node reference) and Logs (study_log.md), plus the command-center strip.
|
|
4
|
+
*
|
|
5
|
+
* Every loader reads the same /api/status or /api/theory endpoints and never
|
|
6
|
+
* mutates progress.
|
|
7
|
+
*/
|
|
8
|
+
import { el } from "./spa-dom.js";
|
|
9
|
+
import { store } from "./spa-store.js";
|
|
10
|
+
import { api } from "./spa-api.js";
|
|
11
|
+
import { renderMarkdownInto } from "./spa-mermaid.js";
|
|
12
|
+
import { safeHttpUrl } from "./spa-logic.js";
|
|
13
|
+
|
|
14
|
+
function stat(k, v) {
|
|
15
|
+
const d = document.createElement("div");
|
|
16
|
+
d.className = "stat";
|
|
17
|
+
const kk = document.createElement("div");
|
|
18
|
+
kk.className = "k";
|
|
19
|
+
kk.textContent = k;
|
|
20
|
+
const vv = document.createElement("div");
|
|
21
|
+
vv.className = "v";
|
|
22
|
+
vv.textContent = String(v);
|
|
23
|
+
d.append(kk, vv);
|
|
24
|
+
return d;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Compact readiness/domains strip shown in the command-center layout. */
|
|
28
|
+
export async function updateCommandStrip() {
|
|
29
|
+
try {
|
|
30
|
+
const data = await api("/api/status");
|
|
31
|
+
const d = data.dashboard || {};
|
|
32
|
+
const p = data.plan || {};
|
|
33
|
+
const r = d.readiness || {};
|
|
34
|
+
const readiness = Math.round((r.value || 0) * 100);
|
|
35
|
+
const coverage = Math.round((r.coverage || 0) * 100);
|
|
36
|
+
el.cmdReadiness.textContent = `${readiness}%`;
|
|
37
|
+
el.cmdCoverage.textContent = `${coverage}%`;
|
|
38
|
+
el.cmdDue.textContent = String(d.due || 0);
|
|
39
|
+
const weak = (p.weakNodes || [])[0];
|
|
40
|
+
el.cmdWeak.textContent = weak?.domain || "—";
|
|
41
|
+
el.cmdWeakNote.textContent = weak ? `${weak.title} · ${Math.round((weak.accuracy || 0) * 100)}%` : "No attempts yet";
|
|
42
|
+
el.cmdReadinessBar.style.setProperty("--value", `${readiness}%`);
|
|
43
|
+
el.cmdCoverageBar.style.setProperty("--value", `${coverage}%`);
|
|
44
|
+
el.cmdDomains.replaceChildren();
|
|
45
|
+
for (const dom of p.domains || []) {
|
|
46
|
+
const row = document.createElement("div");
|
|
47
|
+
row.className = "domain-row";
|
|
48
|
+
const name = document.createElement("span");
|
|
49
|
+
name.textContent = dom.name;
|
|
50
|
+
const meter = document.createElement("div");
|
|
51
|
+
meter.className = "domain-meter";
|
|
52
|
+
const fill = document.createElement("i");
|
|
53
|
+
fill.style.setProperty("--value", `${Math.round((dom.accuracy || 0) * 100)}%`);
|
|
54
|
+
meter.append(fill);
|
|
55
|
+
const val = document.createElement("b");
|
|
56
|
+
val.textContent = `${Math.round((dom.accuracy || 0) * 100)}%`;
|
|
57
|
+
row.append(name, meter, val);
|
|
58
|
+
el.cmdDomains.append(row);
|
|
59
|
+
}
|
|
60
|
+
} catch {
|
|
61
|
+
/* command strip is optional */
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function setView(name) {
|
|
66
|
+
store.activeView = name;
|
|
67
|
+
for (const b of document.querySelectorAll(".nav button, .side-nav button")) b.classList.toggle("active", b.dataset.tab === name);
|
|
68
|
+
el.studyView.hidden = name !== "study";
|
|
69
|
+
el.pathView.hidden = name !== "path";
|
|
70
|
+
el.dashboardView.hidden = name !== "dashboard";
|
|
71
|
+
el.theoryView.hidden = name !== "theory";
|
|
72
|
+
el.logsView.hidden = name !== "logs";
|
|
73
|
+
if (name === "path") void loadPath();
|
|
74
|
+
if (name === "dashboard") void loadDashboard();
|
|
75
|
+
if (name === "theory") void loadTheory();
|
|
76
|
+
if (name === "logs") void loadLogs();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export async function loadPath() {
|
|
80
|
+
el.pathRoute.textContent = "Loading...";
|
|
81
|
+
el.pathDetail.textContent = "";
|
|
82
|
+
try {
|
|
83
|
+
const data = await api("/api/status");
|
|
84
|
+
const p = data.plan || {};
|
|
85
|
+
el.pathMeta.textContent = p.ready
|
|
86
|
+
? "target met"
|
|
87
|
+
: `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`;
|
|
88
|
+
el.pathRoute.replaceChildren();
|
|
89
|
+
const steps = document.createElement("div");
|
|
90
|
+
steps.className = "route-steps";
|
|
91
|
+
for (const step of p.steps || []) {
|
|
92
|
+
const d = document.createElement("div");
|
|
93
|
+
d.className = "route-step " + (step.state || "");
|
|
94
|
+
d.textContent = `${step.name} · ${step.state || ""}`;
|
|
95
|
+
steps.append(d);
|
|
96
|
+
}
|
|
97
|
+
el.pathRoute.append(steps);
|
|
98
|
+
const callout = document.createElement("div");
|
|
99
|
+
callout.className = "route-callout";
|
|
100
|
+
const phase = document.createElement("h3");
|
|
101
|
+
phase.textContent = p.ready ? "READY" : String(p.phase || "progress").toUpperCase();
|
|
102
|
+
const why = document.createElement("p");
|
|
103
|
+
why.textContent = p.why || "";
|
|
104
|
+
const next = document.createElement("p");
|
|
105
|
+
next.textContent = "Next: " + (p.next || "");
|
|
106
|
+
const criteria = document.createElement("p");
|
|
107
|
+
criteria.className = "muted";
|
|
108
|
+
criteria.textContent = p.criteria || "";
|
|
109
|
+
callout.append(phase, why, next, criteria);
|
|
110
|
+
el.pathRoute.append(callout);
|
|
111
|
+
|
|
112
|
+
el.pathDetail.replaceChildren();
|
|
113
|
+
for (const d of p.domains || []) {
|
|
114
|
+
const section = document.createElement("section");
|
|
115
|
+
section.className = "path-domain";
|
|
116
|
+
const h = document.createElement("h3");
|
|
117
|
+
h.textContent = `${d.name} · ${Math.round((d.weight_min || 0) * 100)}–${Math.round((d.weight_max || 0) * 100)}% of exam`;
|
|
118
|
+
const summary = document.createElement("p");
|
|
119
|
+
summary.className = "muted";
|
|
120
|
+
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)}%`;
|
|
121
|
+
section.append(h, summary);
|
|
122
|
+
if (d.skills?.length) {
|
|
123
|
+
const skills = document.createElement("ul");
|
|
124
|
+
skills.className = "path-skills";
|
|
125
|
+
for (const s of d.skills) { const li = document.createElement("li"); li.textContent = s; skills.append(li); }
|
|
126
|
+
section.append(skills);
|
|
127
|
+
}
|
|
128
|
+
const table = document.createElement("table");
|
|
129
|
+
table.className = "node-table";
|
|
130
|
+
const head = document.createElement("tr");
|
|
131
|
+
for (const label of ["Node / subdomain", "Status", "Questions", "Accuracy", "Box"]) {
|
|
132
|
+
const th = document.createElement("th"); th.textContent = label; head.append(th);
|
|
133
|
+
}
|
|
134
|
+
table.append(head);
|
|
135
|
+
for (const n of d.nodes || []) {
|
|
136
|
+
const tr = document.createElement("tr");
|
|
137
|
+
const title = document.createElement("td");
|
|
138
|
+
title.className = "path-node-title";
|
|
139
|
+
title.textContent = n.title;
|
|
140
|
+
const small = document.createElement("small");
|
|
141
|
+
small.textContent = n.summary || n.id;
|
|
142
|
+
title.append(small);
|
|
143
|
+
const status = document.createElement("td");
|
|
144
|
+
const tag = document.createElement("span");
|
|
145
|
+
tag.className = "status-tag " + (n.status || "unseen");
|
|
146
|
+
tag.textContent = n.status || "unseen";
|
|
147
|
+
status.append(tag);
|
|
148
|
+
const qs = document.createElement("td");
|
|
149
|
+
qs.className = "num";
|
|
150
|
+
qs.textContent = `${n.seen}/${n.total}`;
|
|
151
|
+
const acc = document.createElement("td");
|
|
152
|
+
acc.className = "num";
|
|
153
|
+
acc.textContent = n.accuracy == null ? "—" : `${Math.round(n.accuracy * 100)}%`;
|
|
154
|
+
const box = document.createElement("td");
|
|
155
|
+
box.className = "num";
|
|
156
|
+
box.textContent = String(n.box ?? 0);
|
|
157
|
+
tr.append(title, status, qs, acc, box);
|
|
158
|
+
table.append(tr);
|
|
159
|
+
}
|
|
160
|
+
section.append(table);
|
|
161
|
+
el.pathDetail.append(section);
|
|
162
|
+
}
|
|
163
|
+
} catch (e) {
|
|
164
|
+
el.pathRoute.textContent = e.message;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export async function loadDashboard() {
|
|
169
|
+
el.dashboardBody.textContent = "Loading...";
|
|
170
|
+
try {
|
|
171
|
+
const data = await api("/api/status");
|
|
172
|
+
const d = data.dashboard || {};
|
|
173
|
+
const r = d.readiness || {};
|
|
174
|
+
el.dashboardMeta.textContent = `${d.title || ""} · ${d.verified || 0}/${d.total || 0} verified`;
|
|
175
|
+
el.dashboardBody.replaceChildren();
|
|
176
|
+
const grid = document.createElement("div");
|
|
177
|
+
grid.className = "dash-grid";
|
|
178
|
+
grid.append(
|
|
179
|
+
stat("Readiness", `${((r.value || 0) * 100).toFixed(1)}%`),
|
|
180
|
+
stat("Coverage", `${((r.coverage || 0) * 100).toFixed(0)}%`),
|
|
181
|
+
stat("Due now", d.due || 0),
|
|
182
|
+
stat("Gaps / review", `${d.boxes?.[1] || 0} / ${d.boxes?.[2] || 0}`),
|
|
183
|
+
stat("Mastered", d.boxes?.[3] || 0),
|
|
184
|
+
stat("Sessions", d.sessions || 0)
|
|
185
|
+
);
|
|
186
|
+
el.dashboardBody.append(grid);
|
|
187
|
+
|
|
188
|
+
const domains = document.createElement("div");
|
|
189
|
+
for (const [id, v] of Object.entries(r.by_domain || {})) {
|
|
190
|
+
const row = document.createElement("div");
|
|
191
|
+
row.className = "domain-row";
|
|
192
|
+
const name = document.createElement("span");
|
|
193
|
+
name.textContent = id;
|
|
194
|
+
const bar = document.createElement("div");
|
|
195
|
+
bar.className = "bar";
|
|
196
|
+
const fill = document.createElement("span");
|
|
197
|
+
fill.style.width = `${Math.round((v.accuracy || 0) * 100)}%`;
|
|
198
|
+
bar.append(fill);
|
|
199
|
+
const num = document.createElement("span");
|
|
200
|
+
num.className = "muted";
|
|
201
|
+
num.textContent = `${v.seen}/${v.total}${v.accuracy == null ? "" : ` · ${Math.round(v.accuracy * 100)}%`}`;
|
|
202
|
+
row.append(name, bar, num);
|
|
203
|
+
domains.append(row);
|
|
204
|
+
}
|
|
205
|
+
el.dashboardBody.append(domains);
|
|
206
|
+
if (d.weak?.length) {
|
|
207
|
+
const weak = document.createElement("div");
|
|
208
|
+
weak.className = "weak-list";
|
|
209
|
+
weak.textContent = "Weakest domains: " + d.weak.map((x) => `${x.domain} ${Math.round((x.accuracy || 0) * 100)}%`).join(" · ");
|
|
210
|
+
el.dashboardBody.append(weak);
|
|
211
|
+
}
|
|
212
|
+
} catch (e) {
|
|
213
|
+
el.dashboardBody.textContent = e.message;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export async function loadTheory() {
|
|
218
|
+
try {
|
|
219
|
+
if (!store.theoryNodes) {
|
|
220
|
+
const data = await api("/api/theory");
|
|
221
|
+
store.theoryNodes = data.nodes || [];
|
|
222
|
+
el.theoryNodes.replaceChildren();
|
|
223
|
+
for (const n of store.theoryNodes) {
|
|
224
|
+
const b = document.createElement("button");
|
|
225
|
+
b.type = "button";
|
|
226
|
+
b.textContent = n.title || n.id;
|
|
227
|
+
b.dataset.node = n.id;
|
|
228
|
+
b.addEventListener("click", () => selectTheory(n.id));
|
|
229
|
+
el.theoryNodes.append(b);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
const preferred = store.state?.q?.node || store.state?.teach?.node || store.theoryNodes[0]?.id;
|
|
233
|
+
if (preferred) await selectTheory(preferred);
|
|
234
|
+
} catch (e) {
|
|
235
|
+
el.theoryBody.textContent = e.message;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export async function selectTheory(node) {
|
|
240
|
+
store.activeTheoryNode = node;
|
|
241
|
+
for (const b of el.theoryNodes.querySelectorAll("button")) b.classList.toggle("active", b.dataset.node === node);
|
|
242
|
+
el.theoryBody.textContent = "Loading...";
|
|
243
|
+
try {
|
|
244
|
+
const t = await api("/api/theory?node=" + encodeURIComponent(node));
|
|
245
|
+
el.theoryBody.replaceChildren();
|
|
246
|
+
const h = document.createElement("h2");
|
|
247
|
+
h.textContent = t.title || node;
|
|
248
|
+
const meta = document.createElement("p");
|
|
249
|
+
meta.className = "muted";
|
|
250
|
+
meta.textContent = [t.domain, t.summary].filter(Boolean).join(" · ");
|
|
251
|
+
el.theoryBody.append(h, meta);
|
|
252
|
+
const body = document.createElement("div");
|
|
253
|
+
renderMarkdownInto(body, t.section || "No theory section yet.");
|
|
254
|
+
el.theoryBody.append(body);
|
|
255
|
+
const doc = safeHttpUrl(t.docUrl || "");
|
|
256
|
+
if (doc) {
|
|
257
|
+
const p = document.createElement("p");
|
|
258
|
+
const a = document.createElement("a");
|
|
259
|
+
a.href = doc;
|
|
260
|
+
a.target = "_blank";
|
|
261
|
+
a.rel = "noreferrer";
|
|
262
|
+
a.textContent = doc;
|
|
263
|
+
p.append(a);
|
|
264
|
+
el.theoryBody.append(p);
|
|
265
|
+
}
|
|
266
|
+
} catch (e) {
|
|
267
|
+
el.theoryBody.textContent = e.message;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
export async function loadLogs() {
|
|
272
|
+
el.logsBody.textContent = "Loading...";
|
|
273
|
+
try {
|
|
274
|
+
const data = await api("/api/log");
|
|
275
|
+
el.logsBody.textContent = data.text || "No study log yet.";
|
|
276
|
+
} catch (e) {
|
|
277
|
+
el.logsBody.textContent = e.message;
|
|
278
|
+
}
|
|
279
|
+
}
|
package/web/server/http.mjs
CHANGED
|
@@ -383,14 +383,13 @@ export function createStudyServer({ root, loop, token = "", chat = null, runtime
|
|
|
383
383
|
if (method === "GET" && url.pathname === "/preview.js") {
|
|
384
384
|
return sendFile(res, path.join(PUBLIC, "preview.js"), "text/javascript; charset=utf-8");
|
|
385
385
|
}
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
if (method === "GET" && url.pathname
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
return sendFile(res, path.join(PUBLIC, "spa-render.js"), "text/javascript; charset=utf-8");
|
|
386
|
+
// Serve any top-level module in web/public: /app.js, /spa-logic.js,
|
|
387
|
+
// /spa-render.js, /spa-store.js, ... The pattern forbids slashes and dots
|
|
388
|
+
// except the trailing .js, so it cannot escape PUBLIC.
|
|
389
|
+
if (method === "GET" && /^\/[a-z0-9-]+\.js$/.test(url.pathname)) {
|
|
390
|
+
const name = url.pathname.slice(1);
|
|
391
|
+
const file = path.join(PUBLIC, name);
|
|
392
|
+
if (fs.existsSync(file)) return sendFile(res, file, "text/javascript; charset=utf-8");
|
|
394
393
|
}
|
|
395
394
|
if (method === "GET" && url.pathname === "/styles.css") {
|
|
396
395
|
return sendFile(res, path.join(PUBLIC, "styles.css"), "text/css; charset=utf-8");
|