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.
This commit is contained in:
ameer
2026-05-04 18:25:44 +08:00
parent 168cffea8b
commit 19603abc58
6 changed files with 66 additions and 22 deletions

View File

@@ -586,7 +586,11 @@ class RoomManager:
(sid, student_id, qidx, stored_answer, submitted_at, elapsed_ms, score), (sid, student_id, qidx, stored_answer, submitted_at, elapsed_ms, score),
) )
await db.commit() await db.commit()
await self.broadcast_instructors(sid, await self.live_histogram_message(sid, qidx)) # Skip live histogram build when there's no instructor listening
# — same rationale as broadcast_presence. Submit storm should not
# be paying for DB work that nobody consumes.
if self.instructor_clients.get(sid):
await self.broadcast_instructors(sid, await self.live_histogram_message(sid, qidx))
await self.broadcast_presence(sid) await self.broadcast_presence(sid)
await self.broadcast_projectors(sid) await self.broadcast_projectors(sid)
return {"type": "submit_ack", "question_idx": qidx, "answer": stored_answer, "score": score, "elapsed_ms": elapsed_ms} return {"type": "submit_ack", "question_idx": qidx, "answer": stored_answer, "score": score, "elapsed_ms": elapsed_ms}
@@ -719,7 +723,16 @@ class RoomManager:
async def ended_message(self, sid: str, identity: dict[str, Any] | None = None) -> dict[str, Any]: async def ended_message(self, sid: str, identity: dict[str, Any] | None = None) -> dict[str, Any]:
you_id = identity["student_id"] if identity else None you_id = identity["student_id"] if identity else None
msg = {"type": "session_ended", "final_top5": await self.leaderboard(sid, limit=5, you_student_id=you_id)} pool = await self.get_pool_for_session(sid)
msg = {
"type": "session_ended",
"final_top5": await self.leaderboard(sid, limit=5, you_student_id=you_id),
# Total questions in the pool — clients use this as the
# denominator on the "Correct X / Y" display so missed
# questions are visibly counted as wrong (X stays low),
# rather than hiding behind a smaller denominator.
"total_questions": question_count(pool),
}
if identity: if identity:
student = identity["student_id"] student = identity["student_id"]
msg.update(await self.student_summary(sid, student)) msg.update(await self.student_summary(sid, student))
@@ -941,6 +954,14 @@ class RoomManager:
} }
async def broadcast_presence(self, sid: str) -> None: async def broadcast_presence(self, sid: str) -> None:
# Skip the (DB-heavy) message build when no instructor is listening.
# The presence_message touches participants + question_events +
# student_events + submissions; on a 50-student submit storm
# those queries ran for every submit even if no admin was on
# the WS, eating budget that mattered to the time-limited
# question close.
if not self.instructor_clients.get(sid):
return
await self.broadcast_instructors(sid, await self.presence_message(sid)) await self.broadcast_instructors(sid, await self.presence_message(sid))
# ---- Projector (public big-screen view) ------------------------------- # ---- Projector (public big-screen view) -------------------------------

View File

@@ -576,19 +576,23 @@ function renderScoreArea(dist) {
`; `;
}).join(""); }).join("");
// X-axis tick labels at each bucket centre // 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 xLabels = buckets.map((b, i) => {
const cx = (xEdge(i) + xEdge(i + 1)) / 2; const cx = (xEdge(i) + xEdge(i + 1)) / 2;
return `<text class="x-tick-label" x="${cx}" y="${H - padB + 16}">${escapeText(b.label)}</text>`; return `<text class="x-tick-label" x="${cx}" y="${H - padB + 18}" text-anchor="middle">${escapeText(b.label)}</text>`;
}).join(""); }).join("");
// Per-bucket count labels above each top, only if non-zero // Per-bucket data points (small circles at the top of each band) — no
const dataLabels = buckets.map((b, i) => { // 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 ""; if (b.count === 0) return "";
const cx = (xEdge(i) + xEdge(i + 1)) / 2; const cx = (xEdge(i) + xEdge(i + 1)) / 2;
const cy = yFor(b.count) - 8; return `<circle class="data-point" cx="${cx}" cy="${yFor(b.count)}" r="5.5"></circle>`;
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(""); }).join("");
// Median tag — find the bucket containing the cumulative midpoint // Median tag — find the bucket containing the cumulative midpoint
@@ -622,15 +626,15 @@ function renderScoreArea(dist) {
${yGrid} ${yGrid}
<line class="axis" x1="${padL}" x2="${padL + innerW}" y1="${padT + innerH}" y2="${padT + innerH}"></line> <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> <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 + 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> <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-fill" d="${fillPath.join(" ")}"></path>
<path class="area-line" d="${linePath.join(" ")}"></path> <path class="area-line" d="${linePath.join(" ")}"></path>
${dataLabels} ${xLabels}
${dataPoints}
${medianMarks} ${medianMarks}
</svg> </svg>
<div class="chart-legend"> <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> <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>
</div> </div>

View File

@@ -415,11 +415,16 @@ function submitAnswer(optionKey, optionText) {
function renderSubmitted(message) { function renderSubmitted(message) {
store.submitted = message; store.submitted = message;
const seconds = (message.elapsed_ms / 1000).toFixed(1); const seconds = (message.elapsed_ms / 1000).toFixed(1);
// Deliberately hide the score until the instructor reveals — leaks
// correctness otherwise (any positive score = correct, zero = wrong),
// which short-circuits the "stop and think" beat the reveal pause is
// there to enforce. Show response time as the engagement signal
// instead.
setView(` setView(`
<div class="card narrow center"> <div class="card narrow center">
<p class="eyebrow">Question ${message.question_idx + 1}</p> <p class="eyebrow">Question ${message.question_idx + 1}</p>
<h1 class="big-score">+${fmtScore(message.score)}</h1> <h1 class="big-score">${seconds}<small class="unit">s</small></h1>
<p class="muted">submitted in ${seconds}s</p> <p class="muted">answer recorded</p>
<p class="muted small">Waiting for the reveal…</p> <p class="muted small">Waiting for the reveal…</p>
<div class="spinner" aria-hidden="true"></div> <div class="spinner" aria-hidden="true"></div>
</div> </div>
@@ -492,7 +497,7 @@ function renderFinished(message) {
<div class="reveal-stats"> <div class="reveal-stats">
<div class="stat big"><span class="muted">Your total</span><b>${fmtScore(message.your_total)}</b></div> <div class="stat big"><span class="muted">Your total</span><b>${fmtScore(message.your_total)}</b></div>
<div class="stat"><span class="muted">Rank</span><b>${message.your_rank ?? "—"}</b></div> <div class="stat"><span class="muted">Rank</span><b>${message.your_rank ?? "—"}</b></div>
<div class="stat"><span class="muted">Correct</span><b>${message.questions_correct ?? 0} / ${message.questions_answered ?? 0}</b></div> <div class="stat"><span class="muted">Correct</span><b>${message.questions_correct ?? 0} / ${message.total_questions ?? message.questions_answered ?? 0}</b></div>
</div> </div>
<h3>Final top 5</h3> <h3>Final top 5</h3>
${renderBoard(message.final_top5)} ${renderBoard(message.final_top5)}

View File

@@ -1155,6 +1155,16 @@ h2.question-text.small {
letter-spacing: -0.04em; letter-spacing: -0.04em;
line-height: 1; line-height: 1;
} }
/* Unit suffix (e.g. "s" after a duration) — small, muted, baseline-sized
* so it reads as a tag, not part of the number. */
.big-score .unit {
font-size: 0.32em;
color: var(--muted);
letter-spacing: 0;
margin-left: 6px;
vertical-align: 0.55em;
font-weight: 500;
}
.spinner { .spinner {
width: 22px; width: 22px;

View File

@@ -16,10 +16,14 @@ CANONICAL_SID = "main"
@pytest.fixture @pytest.fixture
def sample_pool(): def sample_pool():
# 8 s per question gives the load-simulation room to drive 50 sequential
# WS submits without the autoclose timer racing them on busy CI / dev
# boxes. Tests that don't care about the timer simply close questions
# explicitly; the larger default doesn't slow them down.
return { return {
"title": "Sample Quiz", "title": "Sample Quiz",
"score_fn": "linear_decay", "score_fn": "linear_decay",
"time_limit_default": 2, "time_limit_default": 8,
"session_id": CANONICAL_SID, "session_id": CANONICAL_SID,
"questions": [ "questions": [
{ {
@@ -27,7 +31,7 @@ def sample_pool():
"text": "First question?", "text": "First question?",
"options": {"A": "Alpha", "B": "Beta", "C": "Gamma", "D": "Delta"}, "options": {"A": "Alpha", "B": "Beta", "C": "Gamma", "D": "Delta"},
"correct": "B", "correct": "B",
"time_limit": 2, "time_limit": 8,
"explanation": "B is correct.", "explanation": "B is correct.",
}, },
{ {
@@ -35,28 +39,28 @@ def sample_pool():
"text": "Second question?", "text": "Second question?",
"options": {"A": "One", "B": "Two", "C": "Three", "D": "Four"}, "options": {"A": "One", "B": "Two", "C": "Three", "D": "Four"},
"correct": "C", "correct": "C",
"time_limit": 2, "time_limit": 8,
}, },
{ {
"id": "q3", "id": "q3",
"text": "Third question?", "text": "Third question?",
"options": {"A": "Red", "B": "Blue", "C": "Green", "D": "Gold"}, "options": {"A": "Red", "B": "Blue", "C": "Green", "D": "Gold"},
"correct": "A", "correct": "A",
"time_limit": 2, "time_limit": 8,
}, },
{ {
"id": "q4", "id": "q4",
"text": "Fourth question?", "text": "Fourth question?",
"options": {"A": "North", "B": "South", "C": "East", "D": "West"}, "options": {"A": "North", "B": "South", "C": "East", "D": "West"},
"correct": "D", "correct": "D",
"time_limit": 2, "time_limit": 8,
}, },
{ {
"id": "q5", "id": "q5",
"text": "Fifth question?", "text": "Fifth question?",
"options": {"A": "Fetch", "B": "Decode", "C": "Execute", "D": "Write"}, "options": {"A": "Fetch", "B": "Decode", "C": "Execute", "D": "Write"},
"correct": "A", "correct": "A",
"time_limit": 1, "time_limit": 8,
}, },
], ],
} }

View File

@@ -7,7 +7,7 @@ def test_pool_validation_accepts_well_formed_pool(sample_pool):
pool = parse_pool_json(sample_pool) pool = parse_pool_json(sample_pool)
assert pool["title"] == "Sample Quiz" assert pool["title"] == "Sample Quiz"
assert pool["score_fn"] == "linear_decay" assert pool["score_fn"] == "linear_decay"
assert question_time_limit(pool, 0) == 2 assert question_time_limit(pool, 0) == 8
assert get_question(pool, 0)["correct"] == "B" assert get_question(pool, 0)["correct"] == "B"
public = public_question_payload(pool, 0) public = public_question_payload(pool, 0)
assert "correct" not in public assert "correct" not in public