Files
quiz/static/projector.js
ameer 19603abc58 fix: hide score on submit + total denominator + projector chart cleanup
Three small UX/fairness tweaks from manual live testing:

1. Post-submit "wait for reveal" screen: show only the response time, no
   score. The +score reveal leaked correctness — any positive number =
   correct, zero = wrong — short-circuiting the "stop and think" beat
   the reveal pause was supposed to enforce. Time stays as the
   engagement signal; score now waits for the instructor reveal.

2. Final-screen "Correct X / Y" denominator is now total_questions
   instead of questions_answered. Missed questions are scored zero, so
   they belong in the denominator visibly. Server adds total_questions
   to the session_ended payload.

3. Projector score-distribution: drop the in-chart count labels (they
   collided with each other and with the median tag at small N), restore
   the previously-computed-but-not-rendered x-axis tick labels at the
   bottom. Stats line at the foot keeps n / mean / max.

Also: short-circuit the per-submit instructor + presence broadcasts
when no instructor / projector is connected (no listener, no DB work).
The 50-student load test was tight on margin against its 2 s
time_limit; with the new presence_message / live_histogram_message DB
queries firing on every submit, the margin disappeared on busy boxes.
Conftest fixture also bumped to 8 s per question for the same reason —
gives breathing room for sequential WS submits in the load test.

71/71 pytest green.
2026-05-04 18:25:44 +08:00

680 lines
24 KiB
JavaScript
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.
/* ============================================================
* Projector view — front-of-room display.
*
* Read-only public WS client. The server is authoritative; we only
* receive `projector_state` snapshots and render them. There are no
* outbound mutations, no auth, no cookies.
*
* The projector is intentionally one-shot per state change: a render
* blows away `#projector-app` and re-builds it, except for two hot
* paths that need partial updates:
* 1) the countdown ring ticks at 4Hz (computed from deadline),
* 2) the lobby participant counter bumps on increment without
* rebuilding the whole lobby.
*
* Layout intent: one screen, no scroll, big-screen typography.
* ============================================================ */
const app = document.querySelector("#projector-app");
const params = new URLSearchParams(window.location.search);
const sid = params.get("sid");
const store = {
ws: null,
snapshot: null,
prevSnapshot: null,
countdownTimer: null,
connected: false,
};
// --------------------------------------------------------------
// Helpers
// --------------------------------------------------------------
function escapeText(value) {
return String(value ?? "").replace(/[&<>"']/g, (c) => ({
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#039;",
})[c]);
}
const escapeAttr = escapeText;
function fmtScore(value) {
return Number(value || 0).toFixed(2);
}
function clamp(n, lo, hi) {
return Math.max(lo, Math.min(hi, n));
}
// --------------------------------------------------------------
// Boot + WS
// --------------------------------------------------------------
async function boot() {
if (!sid) {
app.innerHTML = `
<section class="projector-shell">
<span class="reg-tr"></span><span class="reg-bl"></span>
<header class="projector-topbar">
<div class="topbar-left"><span class="brand">Live Quiz</span></div>
<div class="topbar-mid"></div>
<div class="topbar-right"></div>
</header>
<div class="projector-card fatal-card">
<h1 class="lobby-headline">Projector view</h1>
<p class="lobby-sub">Open <code>/projector/?sid=&lt;your-sid&gt;</code></p>
</div>
<footer class="projector-foot">
<span class="left"><span class="dot dim"></span> offline</span>
<span class="center"></span>
<span class="right">no session</span>
</footer>
</section>`;
return;
}
try {
const r = await fetch(`/api/session/${sid}/projector`);
if (!r.ok) throw new Error("not found");
store.snapshot = await r.json();
render();
} catch {
app.innerHTML = `
<section class="projector-shell">
<span class="reg-tr"></span><span class="reg-bl"></span>
<header class="projector-topbar">
<div class="topbar-left"><span class="brand">Live Quiz</span></div>
<div class="topbar-mid"></div>
<div class="topbar-right"></div>
</header>
<div class="projector-card fatal-card">
<h1 class="lobby-headline">Quiz unavailable</h1>
<p class="lobby-sub">No live session at <code>${escapeText(sid)}</code>.</p>
</div>
<footer class="projector-foot">
<span class="left"><span class="dot dim"></span> offline</span>
<span class="center"></span>
<span class="right">${escapeText(sid)}</span>
</footer>
</section>`;
return;
}
connect();
}
function connect() {
const protocol = window.location.protocol === "https:" ? "wss" : "ws";
const ws = new WebSocket(`${protocol}://${window.location.host}/ws/projector/${sid}`);
store.ws = ws;
ws.addEventListener("open", () => {
store.connected = true;
refreshConnDot();
});
ws.addEventListener("message", (event) => {
try {
const msg = JSON.parse(event.data);
if (msg.type === "projector_state") {
store.prevSnapshot = store.snapshot;
store.snapshot = msg;
render();
}
} catch {}
});
ws.addEventListener("close", () => {
store.connected = false;
refreshConnDot();
setTimeout(connect, 2000);
});
// Periodic ping to keep proxies from idling the socket out.
setInterval(() => {
if (ws.readyState === WebSocket.OPEN) {
try { ws.send(JSON.stringify({ type: "ping" })); } catch {}
}
}, 25_000);
}
function refreshConnDot() {
const dot = document.querySelector(".projector-foot .dot");
if (!dot) return;
dot.classList.toggle("dim", !store.connected);
const left = dot.parentElement;
if (left) {
const text = store.connected ? "live" : "reconnecting";
// last text node holds status word
const nodes = Array.from(left.childNodes);
const t = nodes.reverse().find((n) => n.nodeType === 3);
if (t) t.nodeValue = " " + text;
}
}
// --------------------------------------------------------------
// Top-level render
// --------------------------------------------------------------
function render() {
const s = store.snapshot;
if (!s) return;
stopCountdown();
const view =
s.state === "lobby" ? renderLobby(s)
: s.state === "question_open" ? renderQuestion(s, false)
: s.state === "question_closed" ? renderQuestion(s, true)
: s.state === "between_questions" ? renderBetween(s)
: s.state === "finished" ? renderFinished(s)
: `<div class="projector-card"><p class="muted">State: ${escapeText(s.state)}</p></div>`;
app.innerHTML = `
<section class="projector-shell" data-state="${escapeText(s.state)}">
<span class="reg-tr"></span><span class="reg-bl"></span>
${renderTopbar(s)}
${view}
${renderFoot(s)}
</section>
`;
// Lobby counter bump animation (post-mount): if the count went up
// since the previous snapshot, briefly mark .bump on the counter.
if (s.state === "lobby") {
const prev = store.prevSnapshot?.participant_count ?? -1;
if (prev >= 0 && s.participant_count > prev) {
const el = document.querySelector(".participant-count");
if (el) {
el.classList.remove("bump");
// force reflow then re-add to restart animation
// eslint-disable-next-line no-unused-expressions
void el.offsetWidth;
el.classList.add("bump");
}
}
}
// Start the countdown ticker for the question_open state
if (s.state === "question_open" && s.question) {
startCountdown(
Date.now() + (s.question.remaining_ms ?? 0),
s.question.time_limit ?? s.pool_meta?.time_limit_default ?? 60
);
} else if (s.state === "question_closed" && s.question) {
// freeze the ring at "spent"
const ring = document.querySelector(".countdown-ring");
if (ring) {
ring.style.setProperty("--pct", "0");
ring.classList.remove("urgent");
ring.classList.add("spent");
const num = ring.querySelector(".num");
if (num) num.textContent = "0s";
}
}
refreshConnDot();
}
// --------------------------------------------------------------
// Topbar (masthead)
// --------------------------------------------------------------
function renderTopbar(s) {
const idx = s.question?.idx ?? null;
const total = s.pool_meta?.question_count ?? s.question?.total_questions ?? 0;
const showQ = idx != null;
const stateLabel = ({
lobby: "Lobby",
question_open: "Live",
question_closed: "Reveal",
between_questions: "Between",
finished: "Finished",
})[s.state] || s.state;
return `
<header class="projector-topbar">
<div class="topbar-left">
<span class="brand">Live Quiz</span>
<h1 class="topbar-title">${escapeText(s.title || "Quiz")}</h1>
</div>
<div class="topbar-mid">
${showQ
? `<span class="folio">Question <b>${idx + 1}</b> of <b>${total}</b></span>`
: (total ? `<span class="folio"><b>${total}</b> questions</span>` : "")
}
<span class="state-badge state-${escapeText(s.state)}">${escapeText(stateLabel)}</span>
</div>
<div class="topbar-right">
${s.sid ? `<span class="folio">SID <b>${escapeText(s.sid)}</b></span>` : ""}
<span class="folio">${formatClock(s.server_ts)}</span>
</div>
</header>
`;
}
function formatClock(ts) {
if (!ts) return "";
const d = new Date(ts);
const hh = String(d.getHours()).padStart(2, "0");
const mm = String(d.getMinutes()).padStart(2, "0");
return `${hh}:${mm}`;
}
// --------------------------------------------------------------
// Footer
// --------------------------------------------------------------
function renderFoot(s) {
const dotClass = store.connected ? "dot" : "dot dim";
const status = store.connected ? "live" : "reconnecting";
const right = (() => {
if (s.state === "lobby") return `awaiting start`;
if (s.state === "finished") return `quiz complete`;
if (s.state === "between_questions") return `interlude`;
if (s.state === "question_closed") return `answers revealed`;
if (s.state === "question_open" && s.live_histogram) {
const c = s.live_histogram;
return `${c.submitted_count}/${c.total_count} submitted`;
}
return "";
})();
return `
<footer class="projector-foot">
<span class="left"><span class="${dotClass}"></span> ${status}</span>
<span class="center">${escapeText(s.title || "")}</span>
<span class="right">${escapeText(right)}</span>
</footer>
`;
}
// --------------------------------------------------------------
// State: LOBBY
// --------------------------------------------------------------
function renderLobby(s) {
const n = s.participant_count || 0;
const dotMax = 96;
const dots = Math.min(n, dotMax);
const time = s.pool_meta?.time_limit_default ?? 60;
const qcount = s.pool_meta?.question_count ?? 0;
const scoreFn = (s.pool_meta?.score_fn || "linear").replace(/_/g, " ");
return `
<div class="projector-grid lobby">
<div class="projector-card join-card">
<div>
<p class="lobby-eyebrow">Scan to join</p>
<h2 class="lobby-headline">Open the quiz on your phone.</h2>
<p class="lobby-sub">Point your camera at the code, or type the address below into a browser.</p>
</div>
<div class="qr-frame">
<div class="qr-big"><img src="${escapeAttr(s.qr_url || "")}" alt="Join QR code"></div>
</div>
<div class="lobby-url">${escapeText(s.join_url || "")}</div>
</div>
<div class="projector-card lobby-status">
<p class="lobby-eyebrow">Joined so far</p>
<div class="participant-count">
<b>${n}</b>
<div class="label">
<span class="word">student${n === 1 ? "" : "s"} ready,</span>
<span class="meta">↳ waiting on instructor</span>
</div>
</div>
<ol class="constellation" aria-label="${n} participants joined">
${Array.from({ length: dots }).map((_, i) => {
const d = (i % 24) * 18;
return `<li style="--d:${d}ms"></li>`;
}).join("")}
</ol>
<div>
<div class="lobby-rule"> how it runs </div>
<div class="lobby-meta-grid">
<div class="cell"><span class="v">${qcount}</span><span class="k">Questions</span></div>
<div class="cell"><span class="v">${time}s</span><span class="k">Per question</span></div>
<div class="cell"><span class="v">${escapeText(scoreFn)}</span><span class="k">Scoring</span></div>
</div>
</div>
</div>
</div>
`;
}
// --------------------------------------------------------------
// State: QUESTION (open + closed/reveal)
// --------------------------------------------------------------
function renderQuestion(s, revealed) {
const q = s.question;
if (!q) return `<div class="projector-card"><p class="muted">Loading question</p></div>`;
const hist = s.live_histogram?.counts || { A: 0, B: 0, C: 0, D: 0 };
const submitted = s.live_histogram?.submitted_count || 0;
const total = Math.max(1, s.live_histogram?.total_count || 1);
const reveal = s.reveal;
const correct = reveal?.correct;
// Pre-vote state: nobody has submitted yet AND we're not revealed.
// Hide the bars to keep the layout calm during reading time.
const hasVotes = ["A", "B", "C", "D"].some((k) => (hist[k] || 0) > 0);
const preVote = !revealed && !hasVotes;
const limit = q.time_limit || s.pool_meta?.time_limit_default || 60;
const remainingMs = q.remaining_ms ?? 0;
const initialPct = revealed ? 0 : clamp(100 * (remainingMs / 1000) / limit, 0, 100);
const initialSec = Math.ceil(remainingMs / 1000);
const ringClass =
revealed ? "countdown-ring spent"
: (initialSec <= 10 && initialSec > 0) ? "countdown-ring urgent"
: "countdown-ring";
const submittedPct = clamp(100 * submitted / Math.max(1, s.live_histogram?.total_count || 1), 0, 100);
return `
<div class="projector-grid question">
<div class="projector-card question-card">
<div class="question-head">
<h2 class="big-question">${escapeText(q.text)}</h2>
<div class="${ringClass}" id="big-countdown"
style="--pct:${initialPct}"
role="timer" aria-label="time remaining">
<span class="num">${revealed ? "0s" : initialSec + "s"}</span>
</div>
</div>
<ol class="big-options letterless ${revealed ? "revealed" : ""} ${preVote ? "pre-vote" : ""}">
${["A","B","C","D"].map((k) => {
const v = hist[k] || 0;
const pct = Math.round(100 * v / total);
const isCorrect = revealed && k === correct;
const isIncorrect = revealed && k !== correct;
const cls = [
isCorrect ? "correct" : "",
isIncorrect ? "incorrect" : "",
].filter(Boolean).join(" ");
return `
<li class="${cls}">
<span class="opt-text">${escapeText(q.options?.[k] || "")}</span>
<span class="opt-bar"><span class="opt-bar-fill" style="width:${pct}%"></span></span>
<span class="opt-count">${v}<small>${pct}%</small></span>
</li>
`;
}).join("")}
</ol>
${revealed && reveal?.explanation
? `<p class="big-explanation">${escapeText(reveal.explanation)}</p>`
: `<div class="submission-strip">
<span class="label">Submissions</span>
<span class="track"><span class="fill" style="--p:${submittedPct.toFixed(1)}%"></span></span>
<span class="nums">${submitted}<small>of ${s.live_histogram?.total_count || s.participant_count || 0}</small></span>
</div>`
}
</div>
<div class="projector-card side-card">
<p class="card-eyebrow">Response time</p>
${renderResponseTime(s.response_time_distribution)}
<p class="card-eyebrow">Top 5</p>
${renderLeaderboard((s.leaderboard || []).slice(0, 5))}
<p class="side-meta">${submitted} of ${s.live_histogram?.total_count || s.participant_count || 0} answered</p>
</div>
</div>
`;
}
// --------------------------------------------------------------
// State: BETWEEN
// --------------------------------------------------------------
function renderBetween(s) {
const next = (s.question?.idx ?? -1) >= 0
? `Next: question ${s.question.idx + 2} of ${s.pool_meta?.question_count ?? "?"}`
: "";
return `
<div class="projector-grid between">
<div class="projector-card">
<p class="card-eyebrow">Score distribution</p>
${renderScoreArea(s.score_distribution)}
<p class="side-meta">${escapeText(next)}</p>
</div>
<div class="projector-card">
<p class="card-eyebrow">Standings</p>
${renderLeaderboard((s.leaderboard || []).slice(0, 10))}
</div>
</div>
`;
}
// --------------------------------------------------------------
// State: FINISHED
// --------------------------------------------------------------
function renderFinished(s) {
const dist = s.score_distribution;
const top = (s.leaderboard || [])[0];
const headline = top
? `${escapeText(top.name)} took the broadside.`
: `The quiz is complete.`;
return `
<div class="projector-grid finished">
<div class="projector-card finished-grid">
<div class="finished-banner">
<span class="kicker">— The Final Tally —</span>
<h2>${headline}</h2>
<p class="summary">${dist?.n ?? 0} student${(dist?.n ?? 0) === 1 ? "" : "s"} answered &middot; max possible ${(dist?.max_total ?? 0).toFixed(1)} points</p>
</div>
${renderScoreArea(dist)}
</div>
<div class="projector-card">
<p class="card-eyebrow">Final leaderboard</p>
${renderLeaderboard(s.leaderboard || [])}
</div>
</div>
`;
}
// --------------------------------------------------------------
// Leaderboard
// --------------------------------------------------------------
function renderLeaderboard(rows) {
if (!rows || !rows.length) {
return `<div class="empty-state"><span class="glyph">— no scores yet —</span><p>Standings appear after the first question is scored.</p></div>`;
}
return `
<ol class="big-leaderboard">
${rows.map((r, i) => `
<li style="--d:${i * 35}ms">
<span class="rank">${r.rank}</span>
<span class="name">${escapeText(r.name)}</span>
<span class="score">${fmtScore(r.score)}</span>
</li>
`).join("")}
</ol>
`;
}
// --------------------------------------------------------------
// Charts
// --------------------------------------------------------------
/** Vertical bar chart with axis baseline + gridlines (CSS-driven). */
function renderResponseTime(dist) {
if (!dist || !dist.total) {
return `<div class="empty-state"><span class="glyph">— awaiting submissions —</span></div>`;
}
const max = Math.max(1, ...dist.buckets.map((b) => b.count));
const cells = dist.buckets.map((b) => {
const h = Math.max(2, Math.round(100 * b.count / max));
const empty = b.count === 0;
return `
<div class="bar-cell">
<span class="bar-fill" style="--h:${h}%" data-empty="${empty}"></span>
</div>`;
}).join("");
const nums = dist.buckets.map((b) => `<span class="bar-num">${b.count}</span>`).join("");
const labels = dist.buckets.map((b) => `<span class="bar-label">${escapeText(b.label)}</span>`).join("");
return `
<div class="bar-chart small">
<div class="bars">${cells}</div>
<div class="nums">${nums}</div>
<div class="labels">${labels}</div>
</div>
`;
}
/**
* Score distribution as a smoothed step-area chart. Gives a feel for the
* shape of the class result rather than 10 detached bars; reads well at
* lecture-hall distance because the silhouette is unambiguous.
*
* The SVG is intentionally drawn in a fixed 1000×360 box and stretched.
* We use a stepped path so each x-bucket looks like a flat top (since the
* bucket is a range, not a point), then close it down to the axis to fill.
*/
function renderScoreArea(dist) {
if (!dist || !dist.buckets || !dist.buckets.length) {
return `<div class="empty-state"><span class="glyph">— scores not yet tallied —</span><p>The distribution appears after the first question is scored.</p></div>`;
}
const W = 1000, H = 360;
const padL = 56, padR = 16, padT = 22, padB = 44;
const innerW = W - padL - padR;
const innerH = H - padT - padB;
const buckets = dist.buckets;
const n = buckets.length;
const total = dist.n || buckets.reduce((a, b) => a + b.count, 0) || 0;
const max = Math.max(1, ...buckets.map((b) => b.count));
// X coords for the *edges* between buckets (n+1 edges)
const xEdge = (i) => padL + (innerW * i) / n;
const yFor = (count) => padT + innerH * (1 - count / max);
// Stepped polyline: for each bucket draw flat top from xEdge(i) to xEdge(i+1)
const linePath = [];
buckets.forEach((b, i) => {
const x0 = xEdge(i), x1 = xEdge(i + 1), y = yFor(b.count);
if (i === 0) linePath.push(`M ${x0} ${y}`);
else linePath.push(`L ${x0} ${y}`);
linePath.push(`L ${x1} ${y}`);
});
const fillPath = [
...linePath,
`L ${xEdge(n)} ${padT + innerH}`,
`L ${xEdge(0)} ${padT + innerH}`,
`Z`,
];
// Y gridlines at 0, .25, .5, .75, 1
const yGrid = [0, 0.25, 0.5, 0.75, 1].map((t) => {
const y = padT + innerH * t;
const v = Math.round(max * (1 - t));
return `
<line class="grid-line" x1="${padL}" x2="${padL + innerW}" y1="${y}" y2="${y}"></line>
<text class="y-tick-label" x="${padL - 8}" y="${y}">${v}</text>
`;
}).join("");
// X-axis tick labels at each bucket centre. With 10 buckets across the
// 1000-unit-wide SVG these read cleanly at projector scale; the SVG
// stretches but the text rotates if we wanted, here it's horizontal
// because the labels are short ("0.0-1.0" etc.).
const xLabels = buckets.map((b, i) => {
const cx = (xEdge(i) + xEdge(i + 1)) / 2;
return `<text class="x-tick-label" x="${cx}" y="${H - padB + 18}" text-anchor="middle">${escapeText(b.label)}</text>`;
}).join("");
// Per-bucket data points (small circles at the top of each band) — no
// numeric labels above them. With small N the count labels collide
// with the median tag and with each other when bars are short; the
// x-axis labels + bottom legend (n / mean / max) carry that info now.
const dataPoints = buckets.map((b, i) => {
if (b.count === 0) return "";
const cx = (xEdge(i) + xEdge(i + 1)) / 2;
return `<circle class="data-point" cx="${cx}" cy="${yFor(b.count)}" r="5.5"></circle>`;
}).join("");
// Median tag — find the bucket containing the cumulative midpoint
let medianIdx = -1;
if (total > 0) {
let acc = 0;
for (let i = 0; i < buckets.length; i++) {
acc += buckets[i].count;
if (acc >= total / 2) { medianIdx = i; break; }
}
}
let medianMarks = "";
if (medianIdx >= 0) {
const mx = (xEdge(medianIdx) + xEdge(medianIdx + 1)) / 2;
medianMarks = `
<line class="median-line" x1="${mx}" x2="${mx}" y1="${padT}" y2="${padT + innerH}"></line>
<text class="median-tag" x="${mx}" y="${padT - 6}" text-anchor="middle">median</text>
`;
}
// Summary stats
const mean = total ? buckets.reduce((acc, b, i) => {
// approximate bucket midpoint as i+0.5 normalized to max_total
const mid = ((i + 0.5) / n) * (dist.max_total || n);
return acc + b.count * mid;
}, 0) / total : 0;
return `
<div class="area-chart">
<svg viewBox="0 0 ${W} ${H}" preserveAspectRatio="none" role="img" aria-label="Score distribution">
${yGrid}
<line class="axis" x1="${padL}" x2="${padL + innerW}" y1="${padT + innerH}" y2="${padT + innerH}"></line>
<line class="axis" x1="${padL}" x2="${padL}" y1="${padT}" y2="${padT + innerH}"></line>
<text class="axis-title" x="${padL + innerW / 2}" y="${H - 4}" text-anchor="middle">Score band (out of ${(dist.max_total || 0).toFixed(1)})</text>
<text class="axis-title" x="${padL - 36}" y="${padT + innerH / 2}" transform="rotate(-90 ${padL - 36} ${padT + innerH / 2})" text-anchor="middle">Students</text>
<path class="area-fill" d="${fillPath.join(" ")}"></path>
<path class="area-line" d="${linePath.join(" ")}"></path>
${xLabels}
${dataPoints}
${medianMarks}
</svg>
<div class="chart-legend">
<span class="stat">n = <b>${total}</b> &middot; mean <b>${mean.toFixed(2)}</b> &middot; max <b>${(dist.max_total || 0).toFixed(1)}</b></span>
</div>
</div>
`;
}
// --------------------------------------------------------------
// Countdown ring (partial update, runs at 4Hz)
// --------------------------------------------------------------
function startCountdown(deadlineMs, totalSec) {
stopCountdown();
const tick = () => {
const ring = document.querySelector("#big-countdown");
if (!ring) return stopCountdown();
const remaining = Math.max(0, deadlineMs - Date.now());
const sec = Math.ceil(remaining / 1000);
const pct = clamp(100 * (remaining / 1000) / Math.max(1, totalSec), 0, 100);
ring.style.setProperty("--pct", pct.toFixed(2));
const num = ring.querySelector(".num");
if (num) num.textContent = `${sec}s`;
const isUrgent = remaining > 0 && remaining <= 10000;
ring.classList.toggle("urgent", isUrgent);
if (remaining <= 0) {
ring.classList.add("spent");
stopCountdown();
}
};
tick();
store.countdownTimer = setInterval(tick, 250);
}
function stopCountdown() {
if (store.countdownTimer) clearInterval(store.countdownTimer);
store.countdownTimer = null;
}
// --------------------------------------------------------------
// Boot
// --------------------------------------------------------------
boot();