diff --git a/app/room.py b/app/room.py
index dd54c2b..139810c 100644
--- a/app/room.py
+++ b/app/room.py
@@ -586,7 +586,11 @@ class RoomManager:
(sid, student_id, qidx, stored_answer, submitted_at, elapsed_ms, score),
)
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_projectors(sid)
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]:
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:
student = identity["student_id"]
msg.update(await self.student_summary(sid, student))
@@ -941,6 +954,14 @@ class RoomManager:
}
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))
# ---- Projector (public big-screen view) -------------------------------
diff --git a/static/projector.js b/static/projector.js
index 7d80a8e..404fdd5 100644
--- a/static/projector.js
+++ b/static/projector.js
@@ -576,19 +576,23 @@ function renderScoreArea(dist) {
`;
}).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 cx = (xEdge(i) + xEdge(i + 1)) / 2;
- return `${escapeText(b.label)}`;
+ return `${escapeText(b.label)}`;
}).join("");
- // Per-bucket count labels above each top, only if non-zero
- const dataLabels = buckets.map((b, i) => {
+ // 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;
- const cy = yFor(b.count) - 8;
- return `${b.count}
- `;
+ return ``;
}).join("");
// Median tag — find the bucket containing the cumulative midpoint
@@ -622,15 +626,15 @@ function renderScoreArea(dist) {
${yGrid}
- Score band (out of ${(dist.max_total || 0).toFixed(1)})
+ Score band (out of ${(dist.max_total || 0).toFixed(1)})
Students
- ${dataLabels}
+ ${xLabels}
+ ${dataPoints}
${medianMarks}
- 10 score bands · ${n} buckets
n = ${total} · mean ${mean.toFixed(2)} · max ${(dist.max_total || 0).toFixed(1)}
diff --git a/static/quiz.js b/static/quiz.js
index c8ea306..39d18dc 100644
--- a/static/quiz.js
+++ b/static/quiz.js
@@ -415,11 +415,16 @@ function submitAnswer(optionKey, optionText) {
function renderSubmitted(message) {
store.submitted = message;
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(`
Question ${message.question_idx + 1}
-
+${fmtScore(message.score)}
-
submitted in ${seconds}s
+
${seconds}s
+
answer recorded
Waiting for the reveal…
@@ -492,7 +497,7 @@ function renderFinished(message) {
Your total${fmtScore(message.your_total)}
Rank${message.your_rank ?? "—"}
-
Correct${message.questions_correct ?? 0} / ${message.questions_answered ?? 0}
+
Correct${message.questions_correct ?? 0} / ${message.total_questions ?? message.questions_answered ?? 0}
Final top 5
${renderBoard(message.final_top5)}
diff --git a/static/style.css b/static/style.css
index bcb785a..83636b8 100644
--- a/static/style.css
+++ b/static/style.css
@@ -1155,6 +1155,16 @@ h2.question-text.small {
letter-spacing: -0.04em;
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 {
width: 22px;
diff --git a/tests/conftest.py b/tests/conftest.py
index 1298d8e..1c8764b 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -16,10 +16,14 @@ CANONICAL_SID = "main"
@pytest.fixture
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 {
"title": "Sample Quiz",
"score_fn": "linear_decay",
- "time_limit_default": 2,
+ "time_limit_default": 8,
"session_id": CANONICAL_SID,
"questions": [
{
@@ -27,7 +31,7 @@ def sample_pool():
"text": "First question?",
"options": {"A": "Alpha", "B": "Beta", "C": "Gamma", "D": "Delta"},
"correct": "B",
- "time_limit": 2,
+ "time_limit": 8,
"explanation": "B is correct.",
},
{
@@ -35,28 +39,28 @@ def sample_pool():
"text": "Second question?",
"options": {"A": "One", "B": "Two", "C": "Three", "D": "Four"},
"correct": "C",
- "time_limit": 2,
+ "time_limit": 8,
},
{
"id": "q3",
"text": "Third question?",
"options": {"A": "Red", "B": "Blue", "C": "Green", "D": "Gold"},
"correct": "A",
- "time_limit": 2,
+ "time_limit": 8,
},
{
"id": "q4",
"text": "Fourth question?",
"options": {"A": "North", "B": "South", "C": "East", "D": "West"},
"correct": "D",
- "time_limit": 2,
+ "time_limit": 8,
},
{
"id": "q5",
"text": "Fifth question?",
"options": {"A": "Fetch", "B": "Decode", "C": "Execute", "D": "Write"},
"correct": "A",
- "time_limit": 1,
+ "time_limit": 8,
},
],
}
diff --git a/tests/test_pool.py b/tests/test_pool.py
index e9d6420..e76e6fe 100644
--- a/tests/test_pool.py
+++ b/tests/test_pool.py
@@ -7,7 +7,7 @@ def test_pool_validation_accepts_well_formed_pool(sample_pool):
pool = parse_pool_json(sample_pool)
assert pool["title"] == "Sample Quiz"
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"
public = public_question_payload(pool, 0)
assert "correct" not in public