feat: anti-cheat + presence panel + projector view

Refinement pass on top of the validated v1.2 base.

Hardening / quick fixes
- Caddyfile.tpl: CSP / HSTS / X-Frame / Referrer-Policy / Permissions-Policy,
  1 MiB request body cap, X-Forwarded-For pass-through; stock-Caddy compatible.
- auth.py: STUDENT_MAX_AGE 1y -> 30d.
- bootstrap.sh: stage 5 prepends `git config --add safe.directory` so the
  re-bootstrap path no longer 'detected dubious ownership' faults.
- admin.js: drop the misleading `closedPayload?.state || session.state`
  shim; state derives from session only.

Anti-cheat
- New `student_events` audit table; new POST /api/session/{sid}/event for
  blur / visibility_hidden / focus / visibility_visible. quiz.js debounces
  events at 1.5s and uses sendBeacon for visibility_hidden so the event
  survives a navigation. Counts surface in admin presence + CSV export.
- First-claim-wins on join: add_participant raises DuplicateStudentId on
  PK violation; route returns 409 + records a duplicate_join audit event
  with attempted name + IP + UA. Admin dashboard surfaces a per-row red
  badge for hits on real participants and a top-of-page alert for orphan
  attempts.
- DELETE /admin/api/students/{id} as the recovery hatch: clears the
  participant + submissions, kicks active WS sockets so a stale cookie
  cannot continue submitting. quiz.js surfaces the FastAPI detail message
  in the join form so users see the 'already in use' guidance.

Presence panel
- New presence_update WS message; in-process presence map keyed on
  student_id tracks ws_count + last_seen_ms. Admin dashboard renders
  per-student rows: connected/idle/dropped dot, blur+hidden+duplicate
  badges, 'answered current Q' tick, and a clear-student button.

Projector view (public, read-only)
- /projector/?sid=..., GET /api/session/{sid}/projector, WS
  /ws/projector/{sid}. Single self-contained projector_state snapshot
  pushed on every state change. Public leaderboard strips student_id;
  QR rendered server-side as data: URL (CSP-compliant).
- Includes per-Q answer histogram, 8-bucket response-time distribution,
  10-bucket score distribution.
- static/projector.{html,css,js}: editorial-broadside design — masthead,
  registration crosses, conic-gradient countdown ring, SVG stepped-area
  score distribution with median tick, leaderboard row-stagger. Inherits
  light/dark tokens from style.css; honours prefers-reduced-motion. No
  scroll at 1366x768 / 1920x1080 / 3440x1440.

Tests
- tests/test_anti_cheat.py: blur logging + CSV count, unknown-kind 422,
  unauthenticated event 401, duplicate-join 409 + audit, admin
  clear-student happy + 404, server-side submit lockout regression.
- tests/test_projector.py: snapshot shape, leaderboard student_id
  redaction, WS push on state change, 404 for unknown sid, page redirect
  when no sid.
- Existing tests updated for the new presence_update snapshot frame +
  CSV header columns + first-claim-wins refusal of re-key.

57/57 pytest green; smoke-tested locally end-to-end.
This commit is contained in:
ameer
2026-05-04 16:08:59 +08:00
parent f38722ed66
commit 9ea0a8b039
20 changed files with 3029 additions and 72 deletions

View File

@@ -15,6 +15,9 @@ const store = {
session: null, // /admin/api/state response
ws: null,
roster: [],
presence: [], // presence_update.rows — richer than roster
orphanDuplicates: [], // presence_update.orphan_duplicate_joins
currentQIdx: null, // tracked for "answered current?" rendering
currentQuestion: null,
histogram: null,
totalCount: 0,
@@ -130,7 +133,10 @@ function renderLogin(error = null) {
function renderDashboard() {
const session = store.session;
if (!session) return;
const state = store.endedPayload ? "finished" : (store.closedPayload?.state || session.state);
// state derives from session (server-authoritative); endedPayload short-
// circuits to "finished" for the post-final render where we may not
// have re-fetched session.state yet.
const state = store.endedPayload ? "finished" : session.state;
app.innerHTML = `
<header class="topbar">
<div class="topbar-title">
@@ -143,10 +149,11 @@ function renderDashboard() {
</div>
</header>
${store.notice ? `<div class="alert info">${escapeText(store.notice)}</div>` : ""}
${renderDuplicateJoinAlerts()}
<section class="dashboard">
<aside class="dashboard-side">
${renderJoinPanel()}
${renderRosterPanel()}
${renderPresencePanel()}
</aside>
<main class="dashboard-main">
${renderStatePanel(state)}
@@ -155,6 +162,7 @@ function renderDashboard() {
`;
document.querySelector("#logout-btn").addEventListener("click", logout);
bindStateActions();
bindPresenceActions();
if (state === "question_open") startCountdown();
}
@@ -183,20 +191,84 @@ function renderJoinPanel() {
`;
}
function renderRosterPanel() {
const r = store.roster || [];
// Newest-first so late joiners are visible at the top of the list. The
// first three are tagged so the CSS can warm their dot — gives the
// operator a quick "yes the room is live" cue without an explicit log.
const ordered = r.slice().reverse();
function renderPresencePanel() {
const presence = store.presence || [];
const rosterCount = (store.roster || []).length;
const connected = presence.filter((p) => p.connected).length;
const idleStaleMs = 30_000;
const now = Date.now();
// Newest-first so late joiners stay visible at the top.
const ordered = presence.slice().reverse();
if (!ordered.length) {
return `
<div class="card panel">
<h2>Joined <span class="count">${rosterCount}</span></h2>
<p class="muted">No students have joined yet. Share the QR or URL.</p>
</div>
`;
}
const currentQIdx = store.currentQIdx ?? store.session?.current_question_idx;
const isQuestionOpen = currentQIdx != null && store.session?.state === "question_open";
return `
<div class="card panel">
<h2>Joined <span class="count">${r.length}</span></h2>
${ordered.length
? `<ul class="roster">${ordered.map((p, i) =>
`<li class="${i < 3 ? "is-fresh" : ""}"><span class="dot"></span><span class="who"><b>${escapeText(p.name)}</b><small>${escapeText(p.student_id)}</small></span></li>`
).join("")}</ul>`
: `<p class="muted">No students have joined yet. Share the QR or URL.</p>`}
<div class="card panel presence-panel">
<h2>Presence <span class="count">${connected}/${presence.length}</span></h2>
<ul class="presence-list">
${ordered.map((row, i) => {
const lastSeen = row.last_seen_ms || 0;
const stale = !row.connected && lastSeen && (now - lastSeen) > idleStaleMs;
const dotState = row.connected ? "is-online" : (stale ? "is-stale" : "is-offline");
const blur = row.blur_count || 0;
const hidden = row.hidden_count || 0;
const dupCount = row.duplicate_join_attempts?.count || 0;
const answered = row.answered_current;
const fresh = i < 3 && row.connected ? "is-fresh" : "";
return `
<li class="presence-row ${dotState} ${fresh}" data-student-id="${escapeText(row.student_id)}">
<span class="dot" title="${row.connected ? "Connected" : "Disconnected"}"></span>
<span class="who">
<b>${escapeText(row.name)}</b>
<small>${escapeText(row.student_id)}</small>
</span>
<span class="presence-flags">
${isQuestionOpen
? `<span class="flag ${answered ? "flag-ok" : "flag-pending"}" title="${answered ? "Answered current question" : "Has not answered current question"}">${answered ? "✓" : "·"}</span>`
: ""}
${blur > 0 ? `<span class="flag flag-warn" title="Tab blur events">${blur}↗</span>` : ""}
${hidden > 0 ? `<span class="flag flag-warn" title="Tab hidden events">${hidden}◌</span>` : ""}
${dupCount > 0 ? `<span class="flag flag-danger" title="Duplicate-join attempts">!${dupCount}</span>` : ""}
</span>
<button class="btn ghost xtiny" data-clear-student="${escapeText(row.student_id)}" title="Remove this student so they can re-join (recovery for hijack / lost cookie)">×</button>
</li>
`;
}).join("")}
</ul>
<p class="muted xsmall">
<span class="legend-dot is-online"></span> connected
<span class="legend-dot is-stale"></span> idle
<span class="legend-dot is-offline"></span> dropped
</p>
</div>
`;
}
function renderDuplicateJoinAlerts() {
const orphans = store.orphanDuplicates || [];
if (!orphans.length) return "";
// An orphan attempt is a duplicate-join on a student_id that no real
// participant currently holds — surface separately because it suggests
// someone is probing student_ids that aren't even claimed yet.
return `
<div class="alert error duplicate-alerts">
<h2 class="alert-title">Suspicious join attempts</h2>
<ul class="dup-list">
${orphans.map((o) => `
<li>
<code>${escapeText(o.student_id)}</code>
<span class="muted small">${o.count}× attempted${o.latest_ts ? ` · last ${escapeText(o.latest_ts)}` : ""}</span>
</li>
`).join("")}
</ul>
<p class="muted small">No real participant holds these IDs yet. If a student claims one of them and asks for help, you can clear it from the presence list.</p>
</div>
`;
}
@@ -399,6 +471,24 @@ function bindStateActions() {
if (copy) copy.addEventListener("click", copyJoinUrl);
}
function bindPresenceActions() {
document.querySelectorAll("[data-clear-student]").forEach((btn) => {
btn.addEventListener("click", async () => {
const studentId = btn.dataset.clearStudent;
if (!studentId) return;
if (!confirm(`Clear ${studentId}? Their submissions and presence row will be removed; they can then re-join with the same ID.`)) return;
btn.disabled = true;
try {
await api(`/admin/api/students/${encodeURIComponent(studentId)}`, { method: "DELETE" });
} catch (err) {
alert(err.message || "Could not clear student.");
btn.disabled = false;
}
// Server pushes presence_update so the row will disappear naturally.
});
});
}
async function onAction(action, btn) {
if (action === "reset") {
if (!confirm("Reset clears all participants and submissions. Continue?")) return;
@@ -502,6 +592,12 @@ function handleWSMessage(message) {
store.roster = message.participants || [];
renderDashboard();
break;
case "presence_update":
store.presence = message.rows || [];
store.orphanDuplicates = message.orphan_duplicate_joins || [];
store.currentQIdx = message.current_question_idx ?? null;
renderDashboard();
break;
case "question_open":
store.session.state = "question_open";
store.session.current_question_idx = message.question_idx;

1204
static/projector.css Normal file

File diff suppressed because it is too large Load Diff

16
static/projector.html Normal file
View File

@@ -0,0 +1,16 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Quiz — Projector</title>
<link rel="stylesheet" href="/static/style.css">
<link rel="stylesheet" href="/static/projector.css">
</head>
<body class="projector-body">
<main id="projector-app" aria-live="polite">
<div class="bootstrap-loading">Loading projector</div>
</main>
<script type="module" src="/static/projector.js"></script>
</body>
</html>

676
static/projector.js Normal file
View File

@@ -0,0 +1,676 @@
/* ============================================================
* 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 ${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-key">${k}</span>
<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
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 + 16}">${escapeText(b.label)}</text>`;
}).join("");
// Per-bucket count labels above each top, only if non-zero
const dataLabels = buckets.map((b, i) => {
if (b.count === 0) return "";
const cx = (xEdge(i) + xEdge(i + 1)) / 2;
const cy = yFor(b.count) - 8;
return `<text class="data-label" x="${cx}" y="${cy - 12}">${b.count}</text>
<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 - 6}" 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>
${dataLabels}
${medianMarks}
</svg>
<div class="chart-legend">
<span>10 score bands &middot; ${n} buckets</span>
<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();

View File

@@ -34,6 +34,61 @@ const RECONNECT = {
let countdownTimer = null;
/* Tab-blur audit. We POST a server event whenever the student
* backgrounds the page (visibilitychange) or moves focus away from the
* window (blur). Both are debounced so a rapid alt-tab roundtrip
* doesn't spam events. The server records each event in `student_events`
* and surfaces a count to the instructor presence panel.
*
* We only ping during a question_open state — switching tabs between
* questions is fine and we don't want to noise the audit. */
const FOCUS = {
lastBlur: 0,
lastHidden: 0,
debounceMs: 1500,
};
function postEvent(kind) {
if (!sid || !store.currentQuestion || store.submitted) return;
// Use sendBeacon when leaving the page so the event survives the
// navigation; otherwise fetch with credentials so the cookie rides.
const body = JSON.stringify({ kind, question_idx: store.currentQuestion.question_idx });
const url = `/api/session/${sid}/event`;
if (kind === "visibility_hidden" && navigator.sendBeacon) {
const blob = new Blob([body], { type: "application/json" });
navigator.sendBeacon(url, blob);
return;
}
fetch(url, {
method: "POST",
credentials: "same-origin",
headers: { "Content-Type": "application/json" },
body,
keepalive: true,
}).catch(() => {});
}
function onBlur() {
const now = Date.now();
if (now - FOCUS.lastBlur < FOCUS.debounceMs) return;
FOCUS.lastBlur = now;
postEvent("blur");
}
function onVisibility() {
const now = Date.now();
if (document.visibilityState === "hidden") {
if (now - FOCUS.lastHidden < FOCUS.debounceMs) return;
FOCUS.lastHidden = now;
postEvent("visibility_hidden");
} else if (document.visibilityState === "visible") {
postEvent("visibility_visible");
}
}
window.addEventListener("blur", onBlur);
document.addEventListener("visibilitychange", onVisibility);
function fmtScore(value) {
// Scores are floats on a 0.05 grid in [0, 1]. Display as a fixed
// two-decimal string so users see e.g. "0.85" instead of
@@ -136,7 +191,15 @@ function renderJoin(error = null) {
connect();
} catch (err) {
submit.disabled = false;
renderJoin(err.message || "Could not join.");
let msg = err.message || "Could not join.";
// The /join endpoint returns the FastAPI default JSON error envelope
// ({"detail": "..."}) — surface the human-readable detail rather
// than the raw JSON blob in the alert.
try {
const parsed = JSON.parse(msg);
if (parsed && parsed.detail) msg = parsed.detail;
} catch {}
renderJoin(msg);
}
});
}

View File

@@ -1256,6 +1256,145 @@ h2.question-text.small {
.options.student-reveal li.yours.correct::before { color: var(--correct-border); }
.options.student-reveal li.yours.wrong-pick::before { color: var(--wrong-border); }
/* ---------- Live presence panel (admin) ---------- */
.presence-panel { padding: 18px 20px 16px; }
.presence-list {
list-style: none;
margin: 0 0 12px;
padding: 0;
display: grid;
gap: 0;
max-height: 420px;
overflow-y: auto;
}
.presence-row {
display: grid;
grid-template-columns: 14px 1fr auto auto;
gap: 10px;
align-items: center;
padding: 9px 0;
border-bottom: 1px dotted var(--border);
}
.presence-row:last-child { border-bottom: 0; }
.presence-row .dot {
width: 8px;
height: 8px;
border-radius: 999px;
background: var(--muted-2);
flex-shrink: 0;
transition: background 0.4s ease, box-shadow 0.4s ease;
}
.presence-row.is-online .dot {
background: var(--correct-border);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--correct-border) 18%, transparent);
}
.presence-row.is-online.is-fresh .dot {
animation: rosterDotPulse 2.2s ease-in-out infinite;
}
.presence-row.is-stale .dot { background: var(--warn); }
.presence-row.is-offline .dot { background: var(--muted-2); }
.presence-row .who { display: grid; line-height: 1.2; min-width: 0; }
.presence-row .who b {
font-weight: 500;
font-size: 0.92rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.presence-row .who small {
color: var(--muted);
font-size: 0.7rem;
font-family: var(--font-mono);
letter-spacing: 0;
}
.presence-flags {
display: inline-flex;
gap: 4px;
align-items: center;
flex-wrap: wrap;
}
.flag {
font-family: var(--font-mono);
font-size: 0.68rem;
font-weight: 600;
letter-spacing: 0;
padding: 2px 7px;
border-radius: 0;
border: 1px solid var(--border);
color: var(--muted);
background: var(--surface);
font-variant-numeric: tabular-nums;
min-width: 18px;
text-align: center;
}
.flag-ok { color: var(--correct-border); border-color: var(--correct-border); }
.flag-pending { color: var(--muted-2); }
.flag-warn { color: var(--warn); border-color: var(--warn); }
.flag-danger {
color: var(--danger-text);
background: var(--danger);
border-color: var(--danger);
}
.btn.xtiny {
padding: 2px 8px;
font-size: 0.78rem;
min-height: 0;
letter-spacing: 0;
text-transform: none;
border-radius: 2px;
}
.legend-dot {
display: inline-block;
width: 7px;
height: 7px;
border-radius: 999px;
margin-right: 4px;
margin-left: 8px;
vertical-align: middle;
background: var(--muted-2);
}
.legend-dot:first-child { margin-left: 0; }
.legend-dot.is-online { background: var(--correct-border); }
.legend-dot.is-stale { background: var(--warn); }
.legend-dot.is-offline { background: var(--muted-2); }
.xsmall { font-size: 0.72rem; letter-spacing: 0.04em; }
.duplicate-alerts {
margin: 0 32px 16px;
}
.duplicate-alerts .alert-title {
font-family: var(--font-sans);
font-size: 0.72rem;
letter-spacing: 0.18em;
text-transform: uppercase;
font-weight: 700;
color: var(--danger);
margin: 0 0 6px;
padding: 0;
border: 0;
}
.dup-list {
list-style: none;
padding: 0;
margin: 0 0 6px;
display: grid;
gap: 4px;
font-family: var(--font-mono);
font-size: 0.85rem;
}
.dup-list li { display: flex; gap: 12px; align-items: baseline; }
@media (max-width: 900px) {
.duplicate-alerts { margin: 0 18px 12px; }
}
/* ---------- Responsive: mobile student view ---------- */
@media (max-width: 480px) {