feat: session analysis after a quiz, and a merged TODO for the rest

The results page showed a score and a wall of explanations. What a learner needs
afterwards is where the time went and what to go back to, so
/analysis/session/:attemptId gives them: a rail of recent sessions, the four
figures they act on — correct, completed, time per question, total time — a
donut, the weakest topics, and a paginated table of every question with its
status, difficulty, time and how peers did on it.

Time per question was not recorded at all, so it could not be reported. It is
now (`attempt_answers.seconds_spent`), banked when you leave a question and
including the one still open at submission — without that the last question of
every session would show nothing. Answers from before this read "—" rather than
claiming zero, and a question nobody else has answered has no peer rate rather
than 0%, which would read as everyone having failed it.

Also in this pass, from the review:
  * quiz categories are gone from the library — a second taxonomy beside the
    real one, putting a heading above every test;
  * the board review sets are numbered rather than dated, in both the quizzes
    and the study plans built from the same material, so a learner does not meet
    2019 in one place and VII in another;
  * the footer's standing note is one clause, and the gap above it no longer
    looks like the page ended early.

Everything else asked for today is written down in docs/TODO.md rather than
half-built: resume instead of restart, an unsuspended exam that keeps running,
deleting a session's data, reset-all-data with a warning, recommendations split
by article/discipline/system, and the adaptive session. Two questions I owe
answers to are in there too.

208 backend, 249 frontend green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
This commit is contained in:
Daniel 2026-09-11 03:07:05 +02:00
parent d34233d87b
commit 6bb5767871
14 changed files with 638 additions and 32 deletions

View file

@ -0,0 +1,26 @@
"""How long each answer took.
Revision ID: e9f0a1b2c3d4
Revises: d8e9f0a1b2c3
"""
import sqlalchemy as sa
from alembic import op
from sqlalchemy import inspect
revision = "e9f0a1b2c3d4"
down_revision = "d8e9f0a1b2c3"
branch_labels = None
depends_on = None
def upgrade():
# Nothing recorded this, so "time per question" could not be reported at all.
# Null means an answer from before it was measured — distinct from zero,
# which would claim it was instant.
columns = {c["name"] for c in inspect(op.get_bind()).get_columns("attempt_answers")}
if "seconds_spent" not in columns:
op.add_column("attempt_answers", sa.Column("seconds_spent", sa.Integer, nullable=True))
def downgrade():
op.drop_column("attempt_answers", "seconds_spent")

View file

@ -32,6 +32,9 @@ class AttemptAnswer(Base):
attempt_id = Column(Integer, ForeignKey("quiz_attempts.id", ondelete="CASCADE"), nullable=False)
question_id = Column(Integer, ForeignKey("questions.id", ondelete="CASCADE"), nullable=False)
user_answer = Column(String, nullable=False)
# Seconds on this question. Null means an answer from before this was
# measured, which is not the same claim as zero.
seconds_spent = Column(Integer, nullable=True)
is_correct = Column(Boolean, default=False)
attempt = relationship("QuizAttempt", back_populates="answers")

View file

@ -7,7 +7,7 @@ from fastapi import APIRouter, Depends, HTTPException, Request
logger = logging.getLogger(__name__)
from pydantic import BaseModel
from sqlalchemy.orm import Session
from sqlalchemy import func
from sqlalchemy import case, func
from app.database import get_db
from app.models.quiz import Quiz
@ -133,9 +133,11 @@ def submit_attempt(
grades = grade_quiz_answers(get_quiz_questions(db, attempt.quiz_id),
[(ans.question_id, ans.user_answer) for ans in submission.answers], attempt.selected_question_ids)
score = sum(correct for _, _, correct in grades)
timings = submission.timings or {}
for question, user_answer, is_correct in grades:
db.add(AttemptAnswer(attempt_id=attempt_id, question_id=question.id,
user_answer=user_answer, is_correct=is_correct))
user_answer=user_answer, is_correct=is_correct,
seconds_spent=timings.get(question.id)))
attempt.total_questions = len(grades)
# Review and grading use the same selected set, including skipped outcomes.
@ -643,3 +645,99 @@ def get_attempt(
course_id=quiz.course_id if quiz else None,
allow_review=review_allowed,
)
@router.get("/{attempt_id}/analysis")
def attempt_analysis(
attempt_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Everything the session analysis shows, in one call.
The results page had a score and a list of explanations; what a learner
needs afterwards is where the time went and which topics to go back to.
Peer statistics come from every other completed answer to the same question,
which is the only comparison available and an honest one.
"""
attempt = db.query(QuizAttempt).filter(
QuizAttempt.id == attempt_id, QuizAttempt.user_id == current_user.id).first()
if not attempt:
raise HTTPException(404, "Attempt not found")
quiz = db.get(Quiz, attempt.quiz_id)
rows = db.query(AttemptAnswer).filter(AttemptAnswer.attempt_id == attempt_id).all()
question_ids = [row.question_id for row in rows]
questions = {q.id: q for q in db.query(Question).filter(Question.id.in_(question_ids)).all()} \
if question_ids else {}
categories = {c.id: c.name for c in db.query(QuestionCategory).all()}
# How everyone else did on these same questions, excluding this attempt so a
# learner is not compared against themselves.
peer: dict[int, tuple[int, int]] = {}
if question_ids:
for qid, total, correct in db.query(
AttemptAnswer.question_id,
func.count(AttemptAnswer.id),
func.sum(case((AttemptAnswer.is_correct.is_(True), 1), else_=0)),
).filter(
AttemptAnswer.question_id.in_(question_ids),
AttemptAnswer.attempt_id != attempt_id,
).group_by(AttemptAnswer.question_id).all():
peer[qid] = (int(total or 0), int(correct or 0))
detail = []
timed = []
for index, row in enumerate(rows, start=1):
question = questions.get(row.question_id)
total, correct = peer.get(row.question_id, (0, 0))
if row.seconds_spent:
timed.append(row.seconds_spent)
detail.append({
"position": index,
"question_id": row.question_id,
"excerpt": (getattr(question, "question_text", "") or "")[:120],
"status": "correct" if row.is_correct else "skipped" if not row.user_answer else "incorrect",
"difficulty": getattr(question, "difficulty", None),
"category": categories.get(getattr(question, "question_category_id", None)),
"seconds_spent": row.seconds_spent,
# None rather than 0% when nobody else has answered: an unanswered
# question has no peer rate, and 0 would read as "everyone failed".
"peer_percent": round(100 * correct / total) if total else None,
"peer_sample": total,
})
answered = sum(1 for row in rows if row.user_answer)
score = sum(1 for row in rows if row.is_correct)
elapsed = None
if attempt.completed_at and attempt.started_at:
elapsed = int((attempt.completed_at - attempt.started_at).total_seconds())
# Where to go back to, worst first, counting only what was actually attempted.
by_category: dict[str, list[bool]] = {}
for row in rows:
question = questions.get(row.question_id)
name = categories.get(getattr(question, "question_category_id", None))
if name and row.user_answer:
by_category.setdefault(name, []).append(bool(row.is_correct))
recommendations = sorted(
({"name": name, "correct": sum(marks), "total": len(marks),
"percent": round(100 * sum(marks) / len(marks))} for name, marks in by_category.items()),
key=lambda row: (row["percent"], -row["total"]),
)
return {
"attempt_id": attempt.id,
"quiz_id": attempt.quiz_id,
"title": getattr(quiz, "title", None),
"mode": getattr(quiz, "mode", None),
"completed_at": attempt.completed_at,
"total": len(rows),
"answered": answered,
"score": score,
"percent": round(100 * score / len(rows)) if rows else 0,
"seconds_total": elapsed,
"seconds_per_question": round(sum(timed) / len(timed)) if timed else None,
"questions": detail,
"recommendations": recommendations[:8],
}

View file

@ -10,6 +10,9 @@ class AnswerSubmission(BaseModel):
class AttemptSubmit(BaseModel):
answers: list[AnswerSubmission]
# {question_id: seconds}. Absent for a client that does not measure, which is
# why the column is nullable rather than defaulted to zero.
timings: dict[int, int] | None = None
class AnswerDetail(BaseModel):

View file

@ -0,0 +1,77 @@
"""Number the board review sets instead of dating them.
A year in the title says when the questions were published, which is not
something a learner chooses a set by, and dates material that is otherwise
timeless. Numbering them in order keeps the sequence without the implication.
Both the quizzes and the study plans built from the same material are renamed,
so a learner does not meet "Board Review 2019" in one place and "Board Review
VIII" in another.
docker compose exec backend python -m scripts.number_board_reviews
docker compose exec backend python -m scripts.number_board_reviews --apply
"""
import re
import sys
from sqlalchemy import text as sa_text
from app.database import SessionLocal
NUMERALS = [
(1000, "M"), (900, "CM"), (500, "D"), (400, "CD"), (100, "C"), (90, "XC"),
(50, "L"), (40, "XL"), (10, "X"), (9, "IX"), (5, "V"), (4, "IV"), (1, "I"),
]
def roman(number: int) -> str:
out = []
for value, symbol in NUMERALS:
while number >= value:
out.append(symbol)
number -= value
return "".join(out)
def main():
apply_changes = "--apply" in sys.argv
db = SessionLocal()
try:
# Oldest set becomes I, so the numbering follows the order they were sat.
years = sorted({int(m.group(1)) for (title,) in db.execute(sa_text(
"SELECT title FROM quizzes WHERE title ~ 'Board Review [0-9]{4}'")).fetchall()
if (m := re.search(r"(\d{4})", title))})
if not years:
print(" Nothing to renumber.")
return 0
numbering = {year: roman(index) for index, year in enumerate(years, start=1)}
print(f" sets found: {len(years)}\n")
for year, numeral in numbering.items():
print(f" Board Review {year} -> Board Review {numeral}")
if not apply_changes:
print("\n Re-run with --apply.")
return 0
quizzes = renamed_plans = 0
for year, numeral in numbering.items():
quizzes += db.execute(sa_text("""
UPDATE quizzes SET title = replace(title, :old, :new)
WHERE title LIKE :like
"""), {"old": f"Board Review {year}", "new": f"Board Review {numeral}",
"like": f"%Board Review {year}%"}).rowcount or 0
renamed_plans += db.execute(sa_text("""
UPDATE study_plans SET name = :new, slug = :slug WHERE name = :old
"""), {"old": f"Board Review {year}", "new": f"Board Review {numeral}",
"slug": f"board-review-{numeral.lower()}"}).rowcount or 0
db.commit()
print(f"\n quizzes renamed : {quizzes}")
print(f" study plans renamed : {renamed_plans}")
finally:
db.close()
return 0
if __name__ == "__main__":
sys.exit(main())

View file

@ -3,7 +3,43 @@
Everything requested and not yet delivered. Ordered roughly by dependency, not
priority — say which to take and I'll reorder.
Updated 2026-09-10.
Updated 2026-09-11.
---
## Asked for on 2026-09-11, not yet done
Captured so nothing is lost while the article writing runs.
### Sessions and analysis
- [x] **Session analysis after a quiz**`/analysis/session/:attemptId`: rail of
latest sessions, the four figures (correct, completed, time per question,
total time), a donut, study recommendations, and a paginated performance
table with time and peer statistics. Time per question is now recorded
(`attempt_answers.seconds_spent`); answers from before that read "—"
rather than claiming zero.
- [ ] **Return to a session with Resume, not an immediate start** — opening a
part-finished session currently restarts it. It should offer Resume and
Repeat, as the reference does.
- [ ] **An unsuspended exam keeps running** — closing an exam-mode session
should let the clock continue and show the score when it expires, rather
than quietly pausing.
- [ ] **Deleting a session removes its data** — so it no longer counts towards
any statistic. Check the existing delete does this fully.
- [ ] **Reset all data**, with a warning that says plainly what goes.
### Reading and study
- [ ] **Study recommendations by Articles / Disciplines / Systems** — currently
one list by category. Should be three tabs, each linking onward.
- [ ] **Adaptive session** — questions ordered by what would help most, with an
explanation of how it decides.
### Questions I owe an answer to
- [x] **What extracted the PDFs?** PyMuPDF (`fitz`) in `pdf_service.py`, with an
MD5 skip list for repeated branding images. It pulled every embedded image
from all 18 source PDFs, which is why one 767-page document produced 908.
- [ ] **How do the study recommendations work?** — walk through the code.
- [ ] **How would an adaptive session work?** — design before building.
---

View file

@ -34,6 +34,7 @@ const FlashcardsPage = lazy(() => import('./pages/FlashcardsPage'))
const ArticlesPage = lazy(() => import('./pages/ArticlesPage'))
const SearchPage = lazy(() => import('./pages/SearchPage'))
const SessionsPage = lazy(() => import('./pages/SessionsPage'))
const AnalysisSessionPage = lazy(() => import('./pages/AnalysisSessionPage'))
const AiModePage = lazy(() => import('./pages/AiModePage'))
const MediaPage = lazy(() => import('./pages/MediaPage'))
const EditorialPage = lazy(() => import('./pages/EditorialPage'))
@ -104,6 +105,7 @@ function AppRoutes() {
<Route path="/settings" element={<SettingsPage />} />
<Route path="/question-bank" element={<QuestionBankPage />} />
<Route path="/analysis" element={<AnalysisPage />} />
<Route path="/analysis/session/:attemptId" element={<AnalysisSessionPage />} />
<Route path="/questions/manage" element={<QuestionManagerPage />} />
<Route path="/flashcards" element={<FlashcardsPage />} />
<Route path="/search" element={<SearchPage />} />

View file

@ -1,7 +1,9 @@
/* A footer you can navigate from. */
.site-footer {
margin-top: 56px;
/* Enough to separate it from the page, not enough to look like the page
ended early. */
margin-top: 32px;
padding: 30px 0 calc(28px + env(safe-area-inset-bottom));
border-top: 1px solid var(--border);
background: var(--card-bg);

View file

@ -66,9 +66,7 @@ export default function SiteFooter() {
<div className="sf-base">
<span className="sf-mark">🏥 PedsHub</span>
<span>© {new Date().getFullYear()}</span>
<span className="sf-note">
Study material for exam revision. Not a substitute for clinical judgement.
</span>
<span className="sf-note">Not a substitute for clinical judgement.</span>
</div>
</div>
</footer>

View file

@ -0,0 +1,92 @@
/* Session analysis: a rail of sessions, the figures that matter, then the table. */
.an-page { display: grid; grid-template-columns: 260px 1fr; gap: 22px; align-items: start; max-width: 1240px; margin: 0 auto; }
.an-page.is-narrow { grid-template-columns: 1fr; }
.an-page.is-narrow .an-rail { display: none; }
.an-rail { position: sticky; top: 76px; max-height: calc(100vh - 100px); overflow-y: auto; background: var(--card-bg); border: 1px solid var(--border); border-radius: 12px; }
.an-rail-head { display: flex; align-items: center; justify-content: space-between; padding: 14px 14px 8px; }
.an-rail-head h2 { margin: 0; font-size: 0.95rem; }
.an-rail-head button { background: none; border: 1px solid var(--border); border-radius: 50%; width: 26px; height: 26px; cursor: pointer; color: var(--text-muted); }
.an-rail ul { list-style: none; margin: 0; padding: 0 0 8px; }
.an-rail-item { display: flex; flex-direction: column; gap: 3px; padding: 11px 14px; text-decoration: none; border-left: 3px solid transparent; }
.an-rail-item:hover { background: var(--bg); }
.an-rail-item.is-active { background: var(--option-sel-bg); border-left-color: var(--primary); }
.an-rail-mode { font-size: 0.78rem; font-weight: 700; color: var(--text); }
.an-rail-title { font-size: 0.82rem; color: var(--text-muted); overflow-wrap: anywhere; }
.an-rail-count { font-size: 0.7rem; font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase; color: var(--primary); }
.an-rail-bar { height: 3px; border-radius: 2px; background: var(--border); overflow: hidden; }
.an-rail-bar .is-right { display: block; height: 100%; background: var(--correct-fg); }
.an-rail-empty { padding: 12px 14px; font-size: 0.84rem; color: var(--text-muted); }
.an-rail-show { position: sticky; top: 76px; align-self: start; background: var(--card-bg); border: 1px solid var(--border); border-radius: 8px; padding: 8px 10px; font-size: 0.8rem; cursor: pointer; color: var(--text-muted); }
.an-main { min-width: 0; }
.an-empty { background: var(--card-bg); border: 1px solid var(--border); border-radius: 12px; padding: 32px; text-align: center; color: var(--text-muted); }
.an-head { display: flex; justify-content: space-between; align-items: baseline; gap: 12px; flex-wrap: wrap; margin-bottom: 16px; }
.an-head h1 { margin: 0; font-size: 1.3rem; font-weight: 700; }
.an-head h1 span { color: var(--primary); }
/* The four numbers a learner acts on. */
.an-figures { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 1px; background: var(--border); border: 1px solid var(--border); border-radius: 12px; overflow: hidden; margin-bottom: 18px; }
.an-figure { background: var(--card-bg); padding: 18px 14px; text-align: center; display: flex; flex-direction: column; gap: 6px; }
.an-figure strong { font-size: 1.5rem; font-variant-numeric: tabular-nums; }
.an-figure span { font-size: 0.72rem; font-weight: 600; letter-spacing: 0.04em; text-transform: uppercase; color: var(--text-muted); }
.an-split { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 16px; margin-bottom: 16px; }
.an-card { background: var(--card-bg); border: 1px solid var(--border); border-radius: 12px; padding: 16px 18px; }
.an-card h2 { margin: 0 0 12px; font-size: 1rem; }
.an-note { margin: 0; font-size: 0.85rem; color: var(--text-muted); }
.an-donut-wrap { display: flex; align-items: center; gap: 18px; flex-wrap: wrap; }
.an-donut { width: 150px; height: 150px; flex-shrink: 0; }
.an-donut-figure { font-size: 22px; font-weight: 700; fill: var(--text); }
.an-donut-label { font-size: 9px; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; fill: var(--text-subtle); }
.an-legend { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 7px; font-size: 0.85rem; }
.an-legend li { display: flex; align-items: center; gap: 8px; }
.an-legend i { width: 11px; height: 11px; border-radius: 50%; }
.an-legend .is-right { background: var(--correct-fg); }
.an-legend .is-wrong { background: var(--wrong-fg); }
.an-legend .is-none { background: var(--border); }
.an-recs { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 9px; }
.an-recs li { display: grid; grid-template-columns: 1fr 90px 46px; align-items: center; gap: 10px; font-size: 0.85rem; }
.an-rec-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.an-rec-bar { height: 6px; border-radius: 3px; background: var(--border); overflow: hidden; }
.an-rec-bar span { display: block; height: 100%; background: var(--wrong-fg); }
.an-rec-score { text-align: right; font-variant-numeric: tabular-nums; color: var(--text-muted); font-size: 0.8rem; }
.an-table-head { display: flex; justify-content: space-between; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 10px; }
.an-sorts { display: flex; gap: 4px; }
.an-sorts button { padding: 5px 10px; border: 1px solid var(--border); border-radius: 7px; background: var(--card-bg); font: inherit; font-size: 0.76rem; color: var(--text-muted); cursor: pointer; }
.an-sorts button.is-on { background: var(--option-sel-bg); border-color: var(--primary); color: var(--primary); font-weight: 650; }
/* Wide on purpose; it scrolls in its own box rather than pushing the page. */
.an-table-wrap { overflow-x: auto; }
.an-table { width: 100%; border-collapse: collapse; font-size: 0.85rem; }
.an-table th { text-align: left; padding: 8px 10px; font-size: 0.72rem; font-weight: 700; letter-spacing: 0.05em; text-transform: uppercase; color: var(--text-subtle); border-bottom: 1px solid var(--border); }
.an-table td { padding: 10px; border-bottom: 1px solid var(--border); vertical-align: top; }
.an-table a { color: var(--text); text-decoration: none; }
.an-table a:hover { color: var(--primary); }
.an-qnum { color: var(--text-subtle); margin-right: 6px; font-variant-numeric: tabular-nums; }
.an-qcat { display: block; margin-top: 3px; font-style: normal; font-size: 0.72rem; color: var(--text-subtle); }
.an-num { font-variant-numeric: tabular-nums; white-space: nowrap; }
.an-num em { font-style: normal; font-size: 0.72rem; color: var(--text-subtle); }
.an-status { font-size: 0.7rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em; padding: 2px 8px; border-radius: 10px; white-space: nowrap; }
.an-status.is-correct { background: var(--correct-bg); color: var(--correct-fg); }
.an-status.is-incorrect { background: var(--wrong-bg); color: var(--wrong-fg); }
.an-status.is-skipped { background: var(--bg); color: var(--text-muted); }
@media (max-width: 900px) {
.an-page { grid-template-columns: 1fr; }
.an-rail { position: static; max-height: none; }
}
.an-diff { font-size: 0.7rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em; padding: 2px 8px; border-radius: 10px; white-space: nowrap; background: var(--bg); color: var(--text-muted); border: 1px solid var(--border); }
.an-diff.is-easy { background: var(--correct-bg); color: var(--correct-fg); border-color: var(--correct-bd); }
.an-diff.is-hard { background: var(--wrong-bg); color: var(--wrong-fg); border-color: var(--wrong-bd); }
.an-diff.is-none { opacity: 0.65; }
.an-pager { display: flex; align-items: center; justify-content: center; gap: 14px; padding-top: 12px; font-size: 0.8rem; color: var(--text-muted); }
.an-pager button { width: 30px; height: 30px; border: 1px solid var(--border); border-radius: 8px; background: var(--card-bg); cursor: pointer; color: var(--text-muted); }
.an-pager button:disabled { opacity: 0.4; cursor: default; }
.an-pager button:not(:disabled):hover { border-color: var(--primary); color: var(--primary); }

View file

@ -0,0 +1,254 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { Link, useParams } from 'react-router-dom'
import api from '../api/client'
import './AnalysisSessionPage.css'
const clock = (seconds) => {
if (seconds == null) return '—'
const m = Math.floor(seconds / 60)
const s = Math.round(seconds % 60)
return m ? `${m}m ${String(s).padStart(2, '0')}s` : `${s}s`
}
const hours = (seconds) => {
if (seconds == null) return '—'
const h = Math.floor(seconds / 3600)
const m = Math.round((seconds % 3600) / 60)
return h ? `${h}h ${String(m).padStart(2, '0')}m` : `${m}m`
}
/** Correct / incorrect / unanswered as one ring. */
function Donut({ correct, incorrect, skipped }) {
const total = correct + incorrect + skipped
if (!total) return null
const circumference = 2 * Math.PI * 54
const slice = (n) => (n / total) * circumference
let offset = 0
const arcs = [
{ value: correct, colour: 'var(--correct-fg)' },
{ value: incorrect, colour: 'var(--wrong-fg)' },
{ value: skipped, colour: 'var(--border)' },
]
return (
<svg className="an-donut" viewBox="0 0 140 140" role="img"
aria-label={`${correct} correct, ${incorrect} incorrect, ${skipped} unanswered`}>
{arcs.map((arc, i) => {
const length = slice(arc.value)
const dash = `${length} ${circumference - length}`
const node = (
<circle key={i} cx="70" cy="70" r="54" fill="none" stroke={arc.colour}
strokeWidth="16" strokeDasharray={dash} strokeDashoffset={-offset}
transform="rotate(-90 70 70)" />
)
offset += length
return node
})}
<text x="70" y="68" textAnchor="middle" className="an-donut-figure">
{Math.round((correct / total) * 100)}%
</text>
<text x="70" y="86" textAnchor="middle" className="an-donut-label">correct</text>
</svg>
)
}
const SORTS = {
position: (a, b) => a.position - b.position,
slowest: (a, b) => (b.seconds_spent ?? -1) - (a.seconds_spent ?? -1),
hardest: (a, b) => (a.peer_percent ?? 101) - (b.peer_percent ?? 101),
}
/**
* What the session actually tells you, rather than a score and a wall of
* explanations.
*
* The four figures at the top are the ones a learner acts on: how much was
* right, how much was reached at all, how long each question took, and how long
* the sitting was. Time per question is the one that says whether you are
* reading carefully or stalling.
*/
export default function AnalysisSessionPage() {
const { attemptId } = useParams()
const [data, setData] = useState(null)
const [sessions, setSessions] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [sort, setSort] = useState('position')
// Ten at a time: a session of forty is a table nobody reads to the end of.
const [page, setPage] = useState(0)
const [railOpen, setRailOpen] = useState(true)
const load = useCallback(() => {
setLoading(true)
api.get(`/attempts/${attemptId}/analysis`)
.then(res => setData(res.data))
.catch(() => setError('Could not load this session'))
.finally(() => setLoading(false))
}, [attemptId])
useEffect(() => { load() }, [load])
useEffect(() => {
api.get('/quizzes/sessions').then(res => setSessions((res.data || []).slice(0, 12)))
.catch(() => setSessions([]))
}, [])
const rows = useMemo(
() => (data ? [...data.questions].sort(SORTS[sort]) : []), [data, sort])
const PER_PAGE = 10
const pages = Math.max(1, Math.ceil(rows.length / PER_PAGE))
const shown = rows.slice(page * PER_PAGE, page * PER_PAGE + PER_PAGE)
useEffect(() => { setPage(0) }, [sort])
if (loading) return <div className="loading"><div className="spinner" /></div>
if (!data) return <div className="an-empty">{error || 'Session not found.'}</div>
const correct = data.score
const incorrect = data.answered - data.score
const skipped = data.total - data.answered
return (
<div className={`an-page${railOpen ? '' : ' is-narrow'}`}>
<aside className="an-rail">
<div className="an-rail-head">
<h2>Latest sessions</h2>
<button type="button" onClick={() => setRailOpen(false)} aria-label="Hide sessions"></button>
</div>
<ul>
{sessions.map(session => (
<li key={session.quiz_id}>
<Link className={`an-rail-item${session.last_attempt_id === Number(attemptId) ? ' is-active' : ''}`}
to={session.last_attempt_id ? `/analysis/session/${session.last_attempt_id}` : `/quizzes/${session.quiz_id}`}>
<span className="an-rail-mode">
{session.mode === 'learning' ? 'Study mode' : 'Exam mode'}:
</span>
<span className="an-rail-title">{session.title}</span>
<span className="an-rail-count">
{session.answered}/{session.total} questions
</span>
<span className="an-rail-bar">
<span className="is-right" style={{ width: `${(session.last_percentage || 0)}%` }} />
</span>
</Link>
</li>
))}
{sessions.length === 0 && <li className="an-rail-empty">No sessions yet.</li>}
</ul>
</aside>
{!railOpen && (
<button type="button" className="an-rail-show" onClick={() => setRailOpen(true)}>
Sessions
</button>
)}
<main className="an-main">
<div className="an-head">
<h1>Your performance for <span>{data.title}</span></h1>
<Link className="btn btn-secondary btn-sm" to={`/results/${attemptId}`}>Review answers</Link>
</div>
<div className="an-figures">
{[
['✓', `${data.percent}%`, 'correct'],
['◷', `${data.answered}/${data.total}`, 'completed'],
['⏱', clock(data.seconds_per_question), 'time per question'],
['⏲', hours(data.seconds_total), 'total time spent'],
].map(([icon, value, label]) => (
<div key={label} className="an-figure">
<strong>{value}</strong>
<span><span aria-hidden="true">{icon}</span> {label}</span>
</div>
))}
</div>
<div className="an-split">
<section className="an-card">
<h2>{data.title}</h2>
<div className="an-donut-wrap">
<Donut correct={correct} incorrect={incorrect} skipped={skipped} />
<ul className="an-legend">
<li><i className="is-right" />{correct} correct</li>
<li><i className="is-wrong" />{incorrect} incorrect</li>
<li><i className="is-none" />{skipped} unanswered</li>
</ul>
</div>
</section>
<section className="an-card">
<h2>Study recommendations</h2>
{data.recommendations.length === 0 ? (
<p className="an-note">Answer some questions and the weakest topics will show here.</p>
) : (
<ul className="an-recs">
{data.recommendations.map(rec => (
<li key={rec.name}>
<span className="an-rec-name">{rec.name}</span>
<span className="an-rec-bar">
<span style={{ width: `${rec.percent}%` }} />
</span>
<span className="an-rec-score">{rec.correct}/{rec.total}</span>
</li>
))}
</ul>
)}
</section>
</div>
<section className="an-card an-table-card">
<div className="an-table-head">
<h2>Performance analytics</h2>
<div className="an-sorts">
{[['position', 'In order'], ['slowest', 'Slowest first'], ['hardest', 'Hardest first']].map(([key, label]) => (
<button key={key} type="button" className={sort === key ? 'is-on' : ''}
onClick={() => setSort(key)}>{label}</button>
))}
</div>
</div>
<div className="an-table-wrap">
<table className="an-table">
<thead>
<tr>
<th scope="col">Question</th>
<th scope="col">Status</th>
<th scope="col">Difficulty</th>
<th scope="col">Time</th>
<th scope="col">Peers correct</th>
</tr>
</thead>
<tbody>
{shown.map(row => (
<tr key={row.question_id}>
<td>
<span className="an-qnum">{row.position}.</span>
<Link to={`/results/${attemptId}?q=${row.position}`}>{row.excerpt}</Link>
{row.category && <em className="an-qcat">{row.category}</em>}
</td>
<td><span className={`an-status is-${row.status}`}>{row.status}</span></td>
<td>
{row.difficulty
? <span className={`an-diff is-${row.difficulty}`}>{row.difficulty}</span>
: <span className="an-diff is-none">not set</span>}
</td>
<td className="an-num">{row.seconds_spent == null ? '—' : clock(row.seconds_spent)}</td>
<td className="an-num">
{row.peer_percent == null ? '—' : `${row.peer_percent}%`}
{row.peer_sample > 0 && <em> of {row.peer_sample}</em>}
</td>
</tr>
))}
</tbody>
</table>
</div>
{pages > 1 && (
<div className="an-pager">
<button type="button" onClick={() => setPage(p => Math.max(0, p - 1))}
disabled={page === 0} aria-label="Previous page"></button>
<span>Results {page * PER_PAGE + 1}{Math.min(rows.length, (page + 1) * PER_PAGE)} of {rows.length}</span>
<button type="button" onClick={() => setPage(p => Math.min(pages - 1, p + 1))}
disabled={page >= pages - 1} aria-label="Next page"></button>
</div>
)}
</section>
</main>
</div>
)
}

View file

@ -399,6 +399,9 @@ export default function QuizPage() {
const [sessionSeconds, setSessionSeconds] = useState(0)
const [questionSeconds, setQuestionSeconds] = useState(0)
const [clockPaused, setClockPaused] = useState(false)
// Seconds spent on each question, banked when you leave it. Without this the
// analysis can report a total but never a per-question time.
const [questionTimes, setQuestionTimes] = useState({})
const [favorites, setFavorites] = useState([])
const [activeReadSegment, setActiveReadSegment] = useState(null)
const [manualHighlights, setManualHighlights] = useState({})
@ -771,7 +774,17 @@ const timerStarted = timeLeft !== null
return () => clearInterval(tick)
}, [clockPaused, attemptId])
// Time on *this* question restarts when you move to another one.
// Bank the time on the question you are leaving, then start the next at zero.
const leavingRef = useRef({ id: null, seconds: 0 })
leavingRef.current = { id: current?.id, seconds: questionSeconds }
useEffect(() => {
const { id, seconds } = leavingRef.current
return () => {
if (id && seconds > 0) {
setQuestionTimes(prev => ({ ...prev, [id]: (prev[id] || 0) + seconds }))
}
}
}, [current?.id])
useEffect(() => { setQuestionSeconds(0) }, [current?.id])
useEffect(() => {
@ -889,6 +902,12 @@ const timerStarted = timeLeft !== null
answers: Object.entries(answers).map(([qid, answer]) => ({
question_id: parseInt(qid), user_answer: answer,
})),
// The question still open has not been banked yet; without it the last
// question of every session would report no time at all.
timings: {
...questionTimes,
...(current?.id ? { [current.id]: (questionTimes[current.id] || 0) + questionSeconds } : {}),
},
}
const res = await api.post(`/attempts/${attemptId}/submit`, submission)
clearInterval(timerRef.current)

View file

@ -331,7 +331,9 @@ describe('quiz player', () => {
await userEvent.click(screen.getAllByRole('button', { name: 'Review & Complete' })[0])
await userEvent.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Complete test' }))
expect(await screen.findByText('Submitted results')).toBeInTheDocument()
expect(api.post).toHaveBeenCalledWith('/attempts/50/submit', { answers: [{ question_id: 1, user_answer: 'First answer' }] })
expect(api.post).toHaveBeenCalledWith('/attempts/50/submit', expect.objectContaining({
answers: [{ question_id: 1, user_answer: 'First answer' }],
}))
})
it('starts timed quizzes in exam mode without a mode prompt', async () => {
@ -356,7 +358,9 @@ describe('quiz player', () => {
expect(api.delete).not.toHaveBeenCalled()
await userEvent.click(screen.getByRole('button', { name: 'Retry submission' }))
expect(await screen.findByText('Submitted results')).toBeInTheDocument()
expect(api.post).toHaveBeenLastCalledWith('/attempts/50/submit', { answers: [{ question_id: 1, user_answer: 'Second answer' }] })
expect(api.post).toHaveBeenLastCalledWith('/attempts/50/submit', expect.objectContaining({
answers: [{ question_id: 1, user_answer: 'Second answer' }],
}))
})
it('makes a quiz shareable and copies the link without showing it', async () => {

View file

@ -360,16 +360,11 @@ export default function QuizzesPage() {
return groups
}, [previewRows])
const libraryGroups = useMemo(() => {
const byCat = new Map()
for (const row of rows) {
const key = row.category_id ?? 'none'
if (!byCat.has(key)) byCat.set(key, { name: row.category_name || 'Uncategorized', rows: [] })
byCat.get(key).rows.push(row)
}
return [...byCat.values()].sort((a, b) =>
a.name === 'Uncategorized' ? 1 : b.name === 'Uncategorized' ? -1 : a.name.localeCompare(b.name))
}, [rows])
// One list, newest first. Quiz categories were a second taxonomy beside the
// real one and are gone; grouping by them left a heading over every test.
const libraryRows = useMemo(
() => [...rows].sort((a, b) => (b.created_at || '').localeCompare(a.created_at || '')),
[rows])
const isSearching = searchQuery.trim().length >= 2
const allSearchQuestions = searchResults?.flatMap(r => r.matching_questions.map(q => ({ ...q, quiz_title: r.quiz_title }))) ?? []
@ -485,7 +480,7 @@ export default function QuizzesPage() {
Sessions<span className="qz-tab-count">{counts.all}</span>
</button>
<button className="qz-tab" role="tab" aria-selected={tab === 'library'} onClick={() => setTab('library')}>
Library<span className="qz-tab-count">{libraryGroups.length}</span>
Library<span className="qz-tab-count">{libraryRows.length}</span>
</button>
</div>
@ -534,19 +529,16 @@ export default function QuizzesPage() {
)}
{tab === 'library' && (
libraryGroups.length === 0 ? (
libraryRows.length === 0 ? (
<div className="qz-empty">No tests to show yet.</div>
) : libraryGroups.map(group => (
<div key={group.name}>
<h2 className="qz-group-head">{group.name} ({group.rows.length})</h2>
<div className="qz-grid">
{group.rows.map(row => (
<QuizCard key={row.quiz_id} quiz={row} isModerator={isModerator}
onOpenSessions={() => { setTab('sessions'); setStateFilter('all') }} />
))}
</div>
) : (
<div className="qz-grid">
{libraryRows.map(row => (
<QuizCard key={row.quiz_id} quiz={row} isModerator={isModerator}
onOpenSessions={() => { setTab('sessions'); setStateFilter('all') }} />
))}
</div>
))
)
)}
</>