cka-sim/web/index.html
2026-09-06 19:06:10 -06:00

469 lines
30 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Exam simulator</title>
<style>
:root {
--bg: #eceef1; --pane: #ffffff; --ink: #1d2126; --muted: #5f6b78; --line: #d5dae1;
--accent: #1f5fbf; --accent-ink: #ffffff; --warn: #b7791f; --danger: #b42318; --ok: #1e7f4f;
--flag: #d9a400; --term: #1a1b1e;
--sans: "IBM Plex Sans", "Segoe UI", system-ui, -apple-system, sans-serif;
--mono: "IBM Plex Mono", "JetBrains Mono", ui-monospace, Menlo, Consolas, monospace;
}
* { box-sizing: border-box; }
html, body { height: 100%; margin: 0; }
body { font: 14px/1.5 var(--sans); color: var(--ink); background: var(--bg); }
button { font: inherit; cursor: pointer; }
a { color: var(--accent); }
code { font-family: var(--mono); font-size: .92em; }
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
/* ---- top bar: exam name left, clock right, controls far right ---- */
header { height: 48px; display: flex; align-items: center; gap: 16px; padding: 0 14px;
background: #252a31; color: #e8ebef; }
header .name { font-weight: 600; }
header .mode { color: #a9b2bd; font-size: 13px; }
header .spacer { flex: 1; }
#clock { font-family: var(--mono); font-size: 20px; letter-spacing: .04em; font-variant-numeric: tabular-nums; }
#clock.warn { color: #f5c451; } #clock.danger { color: #ff7b72; } #clock.paused { color: #a9b2bd; }
.btn { border: 1px solid var(--line); background: var(--pane); color: var(--ink); border-radius: 4px; padding: 6px 12px; }
.btn.primary { background: var(--accent); border-color: var(--accent); color: var(--accent-ink); }
.btn.danger { background: var(--danger); border-color: var(--danger); color: #fff; }
.btn.ghost { background: transparent; color: #e8ebef; border-color: #4a525c; }
.btn:disabled { opacity: .5; cursor: default; }
/* ---- main split ---- */
main { height: calc(100% - 48px); display: grid; grid-template-columns: 56px 440px 6px 1fr; }
#qnav { background: #f6f7f9; border-right: 1px solid var(--line); overflow-y: auto; padding: 8px 0; }
#qnav button { display: block; width: 40px; height: 34px; margin: 4px auto; border: 1px solid var(--line); border-radius: 4px;
background: var(--pane); position: relative; font-variant-numeric: tabular-nums; }
#qnav button.current { border-color: var(--accent); box-shadow: inset 0 0 0 1px var(--accent); font-weight: 600; }
#qnav button.flagged::after { content: ""; position: absolute; top: -4px; right: -4px; width: 10px; height: 10px;
background: var(--flag); border-radius: 50%; border: 2px solid #f6f7f9; }
#qnav button.pass { background: #e3f3ea; border-color: var(--ok); }
#qnav button.partial { background: #fbf1dc; border-color: var(--warn); }
#qnav button.fail { background: #f9e3e1; border-color: var(--danger); }
#task { background: var(--pane); display: flex; flex-direction: column; min-height: 0; }
#task .head { padding: 14px 18px 10px; border-bottom: 1px solid var(--line); }
#task .head .sub { color: var(--muted); font-size: 13px; }
#task h2 { margin: 2px 0 0; font-size: 17px; font-weight: 600; }
#task .body { flex: 1; overflow-y: auto; padding: 14px 18px; }
.prose p { margin: 0 0 10px; white-space: pre-wrap; }
.prose code { background: #eef2f7; padding: 1px 5px; border-radius: 3px; cursor: copy; border: 1px solid #dfe5ed; }
.prose code:hover { background: #dce6f5; }
.prose code.copied { background: #cfe9da; border-color: var(--ok); }
#task .foot { border-top: 1px solid var(--line); padding: 10px 18px; display: flex; gap: 8px; align-items: center; }
#task .foot .spacer { flex: 1; }
.btn.flag.on { background: #fff4c7; border-color: var(--flag); }
#checks { margin-top: 12px; border: 1px solid var(--line); border-radius: 4px; padding: 10px 12px; font-size: 13px; }
#checks div { padding: 2px 0; }
#solution { margin-top: 12px; border: 1px solid var(--line); border-radius: 4px; padding: 8px 12px; font-size: 13px; }
#solution summary { cursor: pointer; font-weight: 600; }
#solution pre { margin: 8px 0 0; white-space: pre-wrap; font: 12.5px/1.5 var(--mono); background: #f6f7f9; padding: 10px; border-radius: 4px; }
#checks .ok::before { content: "✓ "; color: var(--ok); } #checks .no::before { content: "✗ "; color: var(--danger); }
#notes { width: 100%; height: 120px; border: 0; border-top: 1px solid var(--line); resize: none; padding: 10px 18px;
font: 13px/1.45 var(--mono); background: #fbfbf6; color: var(--ink); }
#notes::placeholder { color: #9aa3ad; }
#handle { background: var(--line); cursor: col-resize; }
#termwrap { background: var(--term); position: relative; min-width: 0; }
#term { width: 100%; height: 100%; border: 0; display: block; }
#noterm { color: #cbd2da; padding: 32px; font-family: var(--mono); font-size: 13px; line-height: 1.7; max-width: 70ch; }
#noterm code { color: #f5c451; }
/* ---- overlays: start, confirm, results ---- */
.overlay { position: fixed; inset: 0; background: rgba(20,24,30,.55); display: flex; align-items: center; justify-content: center; }
.card { position: relative; background: var(--pane); border-radius: 6px; padding: 26px 28px; width: min(640px, 92vw); max-height: 88vh; overflow-y: auto; }
.card h1 { margin: 0 0 4px; font-size: 20px; }
.card .sub { color: var(--muted); margin-bottom: 18px; }
.card .row { display: flex; gap: 10px; flex-wrap: wrap; align-items: center; margin-top: 16px; }
.opts { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.opt { border: 1px solid var(--line); border-radius: 6px; padding: 14px; text-align: left; background: var(--pane); }
.opt:hover { border-color: var(--accent); }
.opt b { display: block; margin-bottom: 4px; }
.opt span { color: var(--muted); font-size: 13px; }
.picker { columns: 2; margin: 10px 0 0; font-size: 13px; }
.picker label { display: block; padding: 2px 0; }
pre.log { background: #f6f7f9; border: 1px solid var(--line); padding: 10px; font: 12px/1.5 var(--mono); max-height: 240px; overflow: auto; }
.score { font-size: 34px; font-family: var(--mono); }
.score small { font-size: 14px; color: var(--muted); font-family: var(--sans); }
.verdict { display: inline-block; padding: 2px 10px; border-radius: 4px; color: #fff; margin-left: 10px; font-size: 14px; vertical-align: middle; }
.verdict.pass { background: var(--ok); } .verdict.fail { background: var(--danger); }
.bar { display: grid; grid-template-columns: 70px 1fr 60px; gap: 10px; align-items: center; margin: 6px 0; font-size: 13px; }
.bar .track { height: 10px; background: #e6e9ee; border-radius: 5px; overflow: hidden; }
.bar .fill { height: 100%; background: var(--accent); }
.qres { margin-top: 16px; }
.qres details { border-top: 1px solid var(--line); padding: 6px 0; }
.qres summary { cursor: pointer; }
.qres summary .pts { float: right; font-family: var(--mono); }
.qres .t-pass summary .pts { color: var(--ok); } .qres .t-fail summary .pts { color: var(--danger); } .qres .t-partial summary .pts { color: var(--warn); }
.hidden { display: none !important; }
.pill { font-size:12px; padding:2px 8px; border-radius:10px; border:1px solid var(--line); color:var(--muted); }
.pill.sel { background:var(--accent); border-color:var(--accent); color:#fff; }
.pill.pick { cursor:pointer; }
.pill.pick:hover { border-color:var(--accent); color:var(--accent); }
.pill.done.pick:hover { color:var(--ok); }
.pill.done { background:#e3f3ea; border-color:var(--ok); color:var(--ok); }
.hardtag { font-size:11px; background:var(--danger); color:#fff; border-radius:3px; padding:1px 5px; margin-left:6px; vertical-align:middle; }
@media (prefers-reduced-motion: no-preference) { .prose code { transition: background .15s; } }
@media (max-width: 900px) { main { grid-template-columns: 56px 1fr; } #handle, #termwrap { display: none; } }
</style>
</head>
<body>
<header>
<span class="name" id="trackName">Exam simulator</span>
<span class="mode" id="modeLabel"></span>
<span class="spacer"></span>
<span id="clock">--:--:--</span>
<button class="btn ghost" id="btnPause">Pause</button>
<button class="btn ghost" id="btnHistory">History</button>
<button class="btn danger" id="btnEnd">Submit exam</button>
</header>
<main>
<nav id="qnav" aria-label="Questions"></nav>
<section id="task">
<div class="head">
<div class="sub" id="qMeta"></div>
<h2 id="qTitle"></h2>
</div>
<div class="body">
<div class="prose" id="qBody"></div>
<div id="checks" class="hidden"></div>
<details id="solution" class="hidden"><summary>Reference solution</summary><pre id="solutionBody"></pre></details>
</div>
<div class="foot">
<button class="btn" id="btnPrev">Previous</button>
<button class="btn" id="btnNext">Next</button>
<span class="spacer"></span>
<button class="btn" id="btnCheck">Check this task</button>
<button class="btn flag" id="btnFlag">Flag for review</button>
</div>
<textarea id="notes" placeholder="Notepad — kept with the attempt, not graded."></textarea>
</section>
<div id="handle" role="separator" aria-orientation="vertical"></div>
<section id="termwrap"></section>
</main>
<!-- start / lobby -->
<div class="overlay" id="lobby">
<div class="card">
<h1 id="lobbyTitle">Exam simulator</h1>
<div class="sub" id="lobbySub"></div>
<div id="ver" style="position:absolute;right:28px;top:30px;color:var(--muted);font-size:12px"></div>
<div id="trackPick" style="display:flex;gap:6px;margin:0 0 14px"></div>
<div style="color:var(--muted);font-size:12px;margin:-8px 0 4px">Drill a domain (✓ = drilled at least once):</div>
<div id="coverage" style="display:flex;gap:6px;margin:0 0 14px;flex-wrap:wrap"></div>
<label style="display:block;margin-bottom:14px"><input type="checkbox" id="hardMode"> <b>Hard mode</b>
<span style="color:var(--muted)"> — killer.sh-style: more tasks in the same time, no hints, traps, exact-output tasks, compound failures, collateral checks, exam contexts.</span></label>
<div class="opts" id="lobbyOpts">
<button class="opt" id="optStudy"><b>Study mode</b><span>5 tasks, no clock, reference solutions visible from the start. Nothing scored or recorded.</span></button>
<button class="opt" id="optNext"><b>Drill next domain</b><span id="nextDesc"></span></button>
<button class="opt" id="optAll"><b>Drill the whole bank</b><span id="allDesc"></span></button>
<button class="opt" id="optDrill"><b>Drill: weak / pick</b><span>Tasks you're weak on, or choose your own. Feedback per task while you work.</span></button>
<button class="opt" id="optSimA"><b>Simulator Set A</b><span id="simADesc"></span></button>
<button class="opt" id="optSimB"><b>Simulator Set B</b><span id="simBDesc"></span></button>
<button class="opt" id="optExam" style="grid-column:1/3"><b>Full exam</b><span id="examDesc"></span></button>
</div>
<div id="gate" class="hidden" style="color:var(--warn);margin-top:10px;font-size:13px"></div>
<div id="drillPick" class="hidden">
<div class="sub" style="margin-top:14px">Pick tasks to drill. Pre-selected: weak or never attempted.</div>
<div class="picker" id="picker"></div>
<div class="row"><button class="btn primary" id="btnStartDrill">Start drill</button><button class="btn" id="btnBackDrill">Back</button></div>
</div>
<div id="seeding" class="hidden">
<div class="sub">Seeding scenarios on the cluster…</div>
<pre class="log" id="seedLog"></pre>
</div>
<div id="lobbyErr" class="hidden" style="color:var(--danger);margin-top:12px;white-space:pre-wrap"></div>
</div>
</div>
<!-- confirm submit -->
<div class="overlay hidden" id="confirm">
<div class="card">
<h1>Submit and end the session?</h1>
<div class="sub">Every task is graded against the cluster as it is right now. You can't return after submitting.</div>
<div id="confirmFlags"></div>
<div class="row"><button class="btn danger" id="btnConfirmEnd">Submit</button><button class="btn" id="btnCancelEnd">Keep working</button></div>
</div>
</div>
<!-- results -->
<div class="overlay hidden" id="results">
<div class="card">
<h1>Results</h1>
<div class="sub" id="resSub"></div>
<div><span class="score" id="resScore"></span><span class="verdict" id="resVerdict"></span></div>
<div id="resDomains" style="margin-top:14px"></div>
<div class="qres" id="resQuestions"></div>
<div class="row">
<button class="btn primary" id="btnDrillMissed">Drill what I missed</button>
<button class="btn" id="btnNewExam">New full exam</button>
<button class="btn" id="btnCloseRes">Review tasks</button>
</div>
</div>
</div>
<!-- history -->
<div class="overlay hidden" id="histOv">
<div class="card">
<h1>Attempt history</h1>
<div class="sub" id="histSub"></div>
<div id="histBody"></div>
<div class="row"><button class="btn" id="btnCloseHist">Close</button></div>
</div>
</div>
<script>
(() => {
const $ = s => document.querySelector(s);
let S = null, cur = null, skew = 0, clockT = null, autoSubmitted = false;
const api = (p, body) => fetch(p, body ? {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(body)} : {})
.then(async r => { const j = await r.json(); if (!r.ok) throw new Error(j.error || r.statusText); return j; });
const fmt = s => { s = Math.max(0, Math.floor(s)); return [s/3600, s%3600/60, s%60].map(n => String(Math.floor(n)).padStart(2,'0')).join(':'); };
const esc = s => s.replace(/[&<>]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;'}[c]));
// minimal markdown: paragraphs, **bold**, `code` (click to copy). Lists stay as literal lines.
function render(md) {
return md.split(/\n\s*\n/).map(p => '<p>' + esc(p).replace(/\*\*(.+?)\*\*/g, '<b>$1</b>')
.replace(/`([^`]+)`/g, (m, c) => `<code title="click to copy">${c}</code>`) + '</p>').join('');
}
function qResultClass(q) {
const r = S.session && S.session.results[q.id];
if (!r) return '';
return r.pass ? 'pass' : (r.earned ? 'partial' : 'fail');
}
function paint() {
const sess = S.session, live = sess && !sess.ended_at;
$('#trackName').textContent = S.track.name;
$('#modeLabel').textContent = sess ? (sess.untimed ? 'Study' : (sess.mode === 'drill' ? 'Drill' : 'Exam')) + (sess.hard ? ' · HARD' : '') : '';
$('#btnEnd').classList.toggle('hidden', !live);
if (live && sess.untimed) $('#btnEnd').textContent = 'End study session';
$('#btnPause').classList.toggle('hidden', !live || sess.untimed);
if (live) $('#btnPause').textContent = sess.paused ? 'Resume' : 'Pause';
$('#btnCheck').classList.toggle('hidden', !(live && sess.mode === 'drill'));
$('#lobby').classList.toggle('hidden', !!live || (sess && sess.ended_at && !$('#results').classList.contains('hidden')));
// question nav
const nav = $('#qnav'); nav.innerHTML = '';
S.questions.forEach(q => {
const b = document.createElement('button'); b.textContent = q.id;
b.title = `${S.questions.indexOf(q) + 1} of ${S.questions.length} — Q${q.id}: ${q.title}`;
b.className = [q.id === cur ? 'current' : '', sess && sess.flags.includes(q.id) ? 'flagged' : '',
(sess && (sess.mode === 'drill' || sess.ended_at)) ? qResultClass(q) : ''].join(' ');
b.onclick = () => { cur = q.id; paint(); };
nav.appendChild(b);
});
if (!S.questions.find(q => q.id === cur)) cur = S.questions[0] && S.questions[0].id;
const q = S.questions.find(q => q.id === cur);
if (q) {
const pos = S.questions.indexOf(q) + 1;
$('#qMeta').textContent = `Question ${pos} of ${S.questions.length} · Q${q.id} · ${S.track.domains[q.domain]} · ${q.weight} points`;
$('#qTitle').innerHTML = esc(q.title) + (q.difficulty === 'hard' ? '<span class="hardtag">hard</span>' : '');
$('#qBody').innerHTML = render(q.task);
$('#qBody').querySelectorAll('code').forEach(c => c.onclick = () => {
navigator.clipboard.writeText(c.textContent).then(() => { c.classList.add('copied'); setTimeout(() => c.classList.remove('copied'), 700); });
});
$('#btnFlag').classList.toggle('on', !!(sess && sess.flags.includes(q.id)));
$('#btnFlag').textContent = sess && sess.flags.includes(q.id) ? 'Flagged' : 'Flag for review';
const r = sess && sess.results[q.id], show = r && (sess.mode === 'drill' || sess.ended_at);
$('#checks').classList.toggle('hidden', !show);
$('#solution').classList.toggle('hidden', !q.solution);
if (q.solution) $('#solutionBody').textContent = q.solution;
if (show) $('#checks').innerHTML = `<b>${r.earned}/${r.total} points</b>` +
r.checks.map(c => `<div class="${c.pass ? 'ok' : 'no'}">${esc(c.name)} <span style="color:var(--muted)">(${c.pts})</span></div>`).join('');
const i = S.questions.indexOf(q);
$('#btnPrev').disabled = i === 0; $('#btnNext').disabled = i === S.questions.length - 1;
}
if (sess && document.activeElement !== $('#notes')) $('#notes').value = sess.notes || '';
// terminal
const tw = $('#termwrap');
if (!tw.dataset.ready) {
tw.dataset.ready = 1;
tw.innerHTML = S.ttyd ? `<iframe id="term" src="http://127.0.0.1:${S.ttyd_port}/" title="terminal"></iframe>` :
`<div id="noterm">The terminal pane needs <code>ttyd</code>, which isn't installed.<br><br>
Install it (<code>dnf install ttyd</code>, <code>pacman -S ttyd</code>, <code>brew install ttyd</code>, or a release from github.com/tsl0922/ttyd)
and restart <code>./exam web</code>.<br><br>Until then, work in any terminal with kubectl pointed at the cluster.
Task files go under <code>${S.workspace}/</code>, relative to the cka-sim directory.</div>`;
}
tickClock();
}
function tickClock() {
clearInterval(clockT);
const sess = S.session, el = $('#clock');
if (!sess || sess.ended_at) { el.textContent = sess ? 'ended' : '--:--:--'; el.className = ''; return; }
if (sess.untimed) { el.textContent = 'study'; el.className = 'paused'; return; }
const step = () => {
const now = Date.now()/1000 - skew;
const pausedTotal = sess.paused_total + (sess.paused ? (now - sess.server_time) : 0);
const left = sess.ends_at + pausedTotal - now;
el.textContent = (sess.paused ? '⏸ ' : '') + fmt(left);
el.className = sess.paused ? 'paused' : (left < 5*60 ? 'danger' : (left < 15*60 ? 'warn' : ''));
if (sess.paused) return;
if (left <= 0 && !autoSubmitted) { autoSubmitted = true; clearInterval(clockT); submit(); }
};
step(); clockT = setInterval(step, 1000);
}
async function load() {
S = await api('/api/session');
if (S.session) skew = Date.now()/1000 - S.session.server_time;
const sess = S.session;
const hard = $('#hardMode').checked, qn = hard ? (S.track.quota_hard || {}) : (S.track.quota || {});
const total = Object.values(qn).reduce((a, b) => a + b, 0) || S.bank.length;
$('#examDesc').textContent = `${total} tasks sampled across the 5 domains, ${S.track.minutes} minutes, pass mark ${S.track.pass_pct}%. No feedback until you submit.`;
const drilled = S.drilled || {}, undone = Object.keys(S.track.domains).filter(d => !drilled[d]);
$('#coverage').innerHTML = Object.keys(S.track.domains).map(d =>
`<span class="pill pick ${drilled[d] ? 'done' : ''}" data-domain="${d}" title="Drill ${S.track.domains[d]} — 5 tasks">${d} ${drilled[d] ? '✓' : '·'}</span>`).join('');
$('#coverage').querySelectorAll('[data-domain]').forEach(el => el.onclick = () => start('drill', null, false, false, el.dataset.domain));
const nxt = Object.keys(S.track.domains).sort((a, b) => (drilled[a] || 0) - (drilled[b] || 0))[0];
const eligible = S.bank.filter(q => hard || q.difficulty !== 'hard'), seenN = eligible.filter(q => (S.seen || []).includes(q.id)).length;
$('#allDesc').textContent = `${seenN}/${eligible.length} tasks attempted. 5 unseen tasks per session, round-robin across domains${hard ? ', hard first' : ''}; then whatever you failed.`;
$('#nextDesc').textContent = `${S.track.domains[nxt]} — 5 tasks, ${hard ? 5 : 6} min each. Drills rotate through all domains before the exam.`;
for (const s of ['a', 'b']) {
const info = (S.sets || {})[s];
const el = $('#sim' + s.toUpperCase() + 'Desc');
if (el) el.textContent = info ? `${info.count} fixed tasks, ${S.track.minutes} min, exam rules. Hardest set in the bank — closest to a real sitting.` : '';
}
$('#gate').classList.toggle('hidden', !undone.length);
$('#gate').innerHTML = undone.length ? `Drill every domain once before the full exam. Not yet drilled: ${undone.join(', ')}. <a href="#" id="forceExam">Start the exam anyway</a>` : '';
$('#optExam').disabled = !!undone.length; $('#optExam').style.opacity = undone.length ? .5 : 1;
const fe = $('#forceExam'); if (fe) fe.onclick = e => { e.preventDefault(); start('exam'); };
$('#lobbyTitle').textContent = S.track.name;
$('#trackPick').innerHTML = (S.tracks || []).map(t =>
`<span class="pill pick ${t === S.track_id ? 'sel' : ''}" data-track="${t}">${t.toUpperCase()}</span>`).join('');
$('#trackPick').querySelectorAll('[data-track]').forEach(el => el.onclick = async () => {
if (el.dataset.track === S.track_id) return;
S = await api('/api/track', {track: el.dataset.track}); cur = null; load();
}); $('#ver').textContent = 'exam ' + (S.version || '?');
$('#lobbySub').textContent = sess && sess.ended_at ? `Last session ended with ${sess.score.pct}%. Start another, or review it under History.` : 'Choose a session. Seeding the cluster takes about a minute.';
if (sess && sess.ended_at && !$('#results').dataset.shown) showResults();
paint();
}
async function startSim(name) {
$('#lobbyOpts').classList.add('hidden'); $('#lobbyErr').classList.add('hidden');
$('#seeding').classList.remove('hidden'); $('#seedLog').textContent = 'starting…';
try {
await api('/api/start', {mode: 'exam', sim: name, hard: true});
for (;;) {
await new Promise(r => setTimeout(r, 1200));
const s = await api('/api/session');
$('#seedLog').textContent = ((s.setup_log || []).join('\n') || 'starting…').replace(/\x1b\[[0-9;]*m/g, '');
$('#seedLog').scrollTop = $('#seedLog').scrollHeight;
if (!s.seeding) { if (s.seed_error) throw new Error(s.seed_error); S = s; break; }
}
skew = Date.now()/1000 - S.session.server_time; autoSubmitted = false;
$('#results').dataset.shown = ''; $('#results').classList.add('hidden'); cur = S.questions[0].id; paint();
} catch (e) {
$('#lobbyErr').textContent = e.message; $('#lobbyErr').classList.remove('hidden'); $('#lobbyOpts').classList.remove('hidden');
} finally { $('#seeding').classList.add('hidden'); }
}
async function start(mode, ids, next, campaign, domain, study) {
$('#lobbyOpts').classList.add('hidden'); $('#drillPick').classList.add('hidden'); $('#lobbyErr').classList.add('hidden');
$('#seeding').classList.remove('hidden'); $('#seedLog').textContent = 'starting…';
try {
await api('/api/start', {mode, ids, next: !!next, campaign: !!campaign, domain: domain || null, study: !!study, hard: $('#hardMode').checked});
// seeding runs in the background; stream its log until it finishes
for (;;) {
await new Promise(r => setTimeout(r, 1200));
const s = await api('/api/session');
$('#seedLog').textContent = ((s.setup_log || []).join('\n') || 'starting…').replace(/\x1b\[[0-9;]*m/g, '');
$('#seedLog').scrollTop = $('#seedLog').scrollHeight;
if (!s.seeding) {
if (s.seed_error) throw new Error(s.seed_error);
S = s; break;
}
}
skew = Date.now()/1000 - S.session.server_time; autoSubmitted = false;
$('#results').dataset.shown = ''; $('#results').classList.add('hidden'); cur = S.questions[0].id; paint();
} catch (e) {
$('#lobbyErr').textContent = e.message === 'Failed to fetch' ? 'Lost contact with ./exam web — is it still running? Restart it and reload this page.' : e.message;
$('#lobbyErr').classList.remove('hidden'); $('#lobbyOpts').classList.remove('hidden');
} finally { $('#seeding').classList.add('hidden'); }
}
async function submit() {
$('#confirm').classList.add('hidden');
$('#btnEnd').disabled = true; $('#btnEnd').textContent = 'Grading…';
try { S = await api('/api/end', {}); } finally { $('#btnEnd').disabled = false; $('#btnEnd').textContent = 'Submit exam'; }
showResults(); paint();
}
function showResults() {
const s = S.session, sc = s.score;
$('#results').dataset.shown = 1;
$('#resSub').textContent = `${s.mode === 'drill' ? 'Drill' : 'Full exam'} · ${fmt(s.ended_at - s.started_at - s.paused_total)} used${s.over_time ? ' (over time)' : ''}${s.pauses ? ` · paused ${s.pauses}× (${fmt(s.paused_total)})` : ''}`;
$('#resScore').innerHTML = `${sc.pct}% <small>${sc.earned} / ${sc.total} points</small>`;
const v = $('#resVerdict'); v.textContent = sc.pct >= sc.pass_pct ? 'Pass' : 'Fail'; v.className = 'verdict ' + (sc.pct >= sc.pass_pct ? 'pass' : 'fail');
$('#resDomains').innerHTML = Object.entries(s.domains).map(([d, x]) =>
`<div class="bar"><span>${d}</span><div class="track"><div class="fill" style="width:${100*x.earned/x.total}%"></div></div><span>${x.earned}/${x.total}</span></div>`).join('');
$('#resQuestions').innerHTML = S.questions.map(q => { const r = s.results[q.id]; if (!r) return '';
const cls = r.pass ? 't-pass' : (r.earned ? 't-partial' : 't-fail');
return `<details class="${cls}"><summary>Q${q.id} ${esc(q.title)}<span class="pts">${r.earned}/${r.total}</span></summary>` +
r.checks.map(c => `<div class="${c.pass ? 'ok' : 'no'}" style="padding-left:14px">${c.pass ? '✓' : '✗'} ${esc(c.name)} (${c.pts})</div>`).join('') +
(q.solution ? `<details style="margin:6px 0 4px 14px"><summary style="font-size:13px">Reference solution</summary><pre style="white-space:pre-wrap;font:12px/1.5 var(--mono);background:#f6f7f9;padding:8px;border-radius:4px">${esc(q.solution)}</pre></details>` : '') + '</details>'; }).join('');
const missed = S.questions.filter(q => s.results[q.id] && !s.results[q.id].pass).map(q => q.id);
$('#btnDrillMissed').disabled = !missed.length; $('#btnDrillMissed').onclick = () => { $('#results').classList.add('hidden'); start('drill', missed); };
$('#results').classList.remove('hidden'); $('#lobby').classList.add('hidden');
}
async function showHistory() {
const h = await api('/api/history');
$('#histSub').textContent = h.history.length ? `${h.history.length} attempt(s) on this track` : 'No attempts yet.';
$('#histBody').innerHTML = h.history.slice().reverse().map(e =>
`<div class="bar"><span>${e.mode}${e.hard ? '*' : ''}</span><div><b>${e.score_pct}%</b> · ${e.at.replace('T',' ')} · ${fmt(e.time_used)} · ${e.ids.length === S.questions.length ? 'all tasks' : 'Q' + e.ids.join(', Q')}</div><span></span></div>`).join('') +
(h.weak.length ? `<div style="margin-top:12px;color:var(--muted)">Weak or unattempted: Q${h.weak.join(', Q')}</div>` : '');
$('#histOv').classList.remove('hidden');
}
// ---- wiring ----
$('#optExam').onclick = () => start('exam');
$('#optNext').onclick = () => start('drill', null, true);
$('#optStudy').onclick = () => start('drill', null, true, false, null, true);
$('#optAll').onclick = () => start('drill', null, false, true);
$('#optSimA').onclick = () => startSim('a');
$('#optSimB').onclick = () => startSim('b');
$('#hardMode').onchange = () => load();
$('#optDrill').onclick = async () => {
const h = await api('/api/history');
const hard = $('#hardMode').checked;
$('#picker').innerHTML = S.bank.filter(q => hard || q.difficulty !== 'hard').map(q => `<label><input type="checkbox" value="${q.id}" ${h.weak.includes(q.id) ? 'checked' : ''}> Q${q.id} ${q.domain} ${esc(q.title)}${q.difficulty === 'hard' ? '<span class="hardtag">hard</span>' : ''}</label>`).join('');
$('#lobbyOpts').classList.add('hidden'); $('#drillPick').classList.remove('hidden');
};
$('#btnBackDrill').onclick = () => { $('#drillPick').classList.add('hidden'); $('#lobbyOpts').classList.remove('hidden'); };
$('#btnStartDrill').onclick = () => { const ids = [...$('#picker').querySelectorAll('input:checked')].map(i => +i.value); if (ids.length) start('drill', ids); };
$('#btnPrev').onclick = () => { const i = S.questions.findIndex(q => q.id === cur); cur = S.questions[i-1].id; paint(); };
$('#btnNext').onclick = () => { const i = S.questions.findIndex(q => q.id === cur); cur = S.questions[i+1].id; paint(); };
$('#btnFlag').onclick = async () => { S = await api('/api/flag', {id: cur}); paint(); };
$('#btnCheck').onclick = async () => { $('#btnCheck').disabled = true; try { S = await api('/api/grade', {id: cur}); paint(); } catch (e) { alert(e.message); } finally { $('#btnCheck').disabled = false; } };
let nt; $('#notes').oninput = () => { clearTimeout(nt); nt = setTimeout(() => api('/api/notes', {notes: $('#notes').value}), 600); };
$('#btnEnd').onclick = () => {
const f = S.session.flags, un = S.questions.filter(q => !S.session.results[q.id]).length;
$('#confirmFlags').innerHTML = f.length ? `<div>Flagged for review: Q${f.join(', Q')}</div>` : '';
$('#confirm').classList.remove('hidden');
};
$('#btnCancelEnd').onclick = () => $('#confirm').classList.add('hidden');
$('#btnConfirmEnd').onclick = submit;
$('#btnCloseRes').onclick = () => { $('#results').classList.add('hidden'); $('#lobby').classList.add('hidden'); };
$('#btnNewExam').onclick = () => { $('#results').classList.add('hidden'); $('#lobby').classList.remove('hidden'); $('#lobbyOpts').classList.remove('hidden'); };
$('#btnHistory').onclick = showHistory;
$('#btnPause').onclick = async () => { S = await api('/api/pause', {paused: !S.session.paused}); skew = Date.now()/1000 - S.session.server_time; paint(); };
$('#btnCloseHist').onclick = () => $('#histOv').classList.add('hidden');
// draggable split
const handle = $('#handle'), main = document.querySelector('main');
handle.onmousedown = e => { e.preventDefault(); const mv = ev => { main.style.gridTemplateColumns = `56px ${Math.max(320, ev.clientX - 56)}px 6px 1fr`; };
const up = () => { removeEventListener('mousemove', mv); removeEventListener('mouseup', up); }; addEventListener('mousemove', mv); addEventListener('mouseup', up); };
load();
})();
</script>
</body>
</html>