From 6bb57678712239a7e9fd61edce075f207a5233ad Mon Sep 17 00:00:00 2001 From: Daniel Date: Fri, 11 Sep 2026 03:07:05 +0200 Subject: [PATCH] feat: session analysis after a quiz, and a merged TODO for the rest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN --- .../versions/e9f0a1b2c3d4_answer_timing.py | 26 ++ backend/app/models/attempt.py | 3 + backend/app/routers/attempts.py | 102 ++++++- backend/app/schemas/attempt.py | 3 + backend/scripts/number_board_reviews.py | 77 ++++++ docs/TODO.md | 38 ++- frontend/src/App.jsx | 2 + frontend/src/components/SiteFooter.css | 4 +- frontend/src/components/SiteFooter.jsx | 4 +- frontend/src/pages/AnalysisSessionPage.css | 92 +++++++ frontend/src/pages/AnalysisSessionPage.jsx | 254 ++++++++++++++++++ frontend/src/pages/QuizPage.jsx | 21 +- frontend/src/pages/QuizPage.test.jsx | 8 +- frontend/src/pages/QuizzesPage.jsx | 36 +-- 14 files changed, 638 insertions(+), 32 deletions(-) create mode 100644 backend/alembic/versions/e9f0a1b2c3d4_answer_timing.py create mode 100644 backend/scripts/number_board_reviews.py create mode 100644 frontend/src/pages/AnalysisSessionPage.css create mode 100644 frontend/src/pages/AnalysisSessionPage.jsx diff --git a/backend/alembic/versions/e9f0a1b2c3d4_answer_timing.py b/backend/alembic/versions/e9f0a1b2c3d4_answer_timing.py new file mode 100644 index 0000000..b81ccbe --- /dev/null +++ b/backend/alembic/versions/e9f0a1b2c3d4_answer_timing.py @@ -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") diff --git a/backend/app/models/attempt.py b/backend/app/models/attempt.py index 802de0d..324749f 100644 --- a/backend/app/models/attempt.py +++ b/backend/app/models/attempt.py @@ -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") diff --git a/backend/app/routers/attempts.py b/backend/app/routers/attempts.py index 92e1ddc..0c29fb5 100644 --- a/backend/app/routers/attempts.py +++ b/backend/app/routers/attempts.py @@ -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], + } diff --git a/backend/app/schemas/attempt.py b/backend/app/schemas/attempt.py index d996b89..96978be 100644 --- a/backend/app/schemas/attempt.py +++ b/backend/app/schemas/attempt.py @@ -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): diff --git a/backend/scripts/number_board_reviews.py b/backend/scripts/number_board_reviews.py new file mode 100644 index 0000000..2eaff9e --- /dev/null +++ b/backend/scripts/number_board_reviews.py @@ -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()) diff --git a/docs/TODO.md b/docs/TODO.md index 6c071f0..b69337c 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -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. --- diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 172354b..3cae676 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -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() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/components/SiteFooter.css b/frontend/src/components/SiteFooter.css index c9062cb..7817f1c 100644 --- a/frontend/src/components/SiteFooter.css +++ b/frontend/src/components/SiteFooter.css @@ -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); diff --git a/frontend/src/components/SiteFooter.jsx b/frontend/src/components/SiteFooter.jsx index e2861d6..936db7b 100644 --- a/frontend/src/components/SiteFooter.jsx +++ b/frontend/src/components/SiteFooter.jsx @@ -66,9 +66,7 @@ export default function SiteFooter() {
🏥 PedsHub © {new Date().getFullYear()} - - Study material for exam revision. Not a substitute for clinical judgement. - + Not a substitute for clinical judgement.
diff --git a/frontend/src/pages/AnalysisSessionPage.css b/frontend/src/pages/AnalysisSessionPage.css new file mode 100644 index 0000000..666d929 --- /dev/null +++ b/frontend/src/pages/AnalysisSessionPage.css @@ -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); } diff --git a/frontend/src/pages/AnalysisSessionPage.jsx b/frontend/src/pages/AnalysisSessionPage.jsx new file mode 100644 index 0000000..9c29bc1 --- /dev/null +++ b/frontend/src/pages/AnalysisSessionPage.jsx @@ -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 ( + + {arcs.map((arc, i) => { + const length = slice(arc.value) + const dash = `${length} ${circumference - length}` + const node = ( + + ) + offset += length + return node + })} + + {Math.round((correct / total) * 100)}% + + correct + + ) +} + +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
+ if (!data) return
{error || 'Session not found.'}
+ + const correct = data.score + const incorrect = data.answered - data.score + const skipped = data.total - data.answered + + return ( +
+ + + {!railOpen && ( + + )} + +
+
+

Your performance for {data.title}

+ Review answers +
+ +
+ {[ + ['✓', `${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]) => ( +
+ {value} + {label} +
+ ))} +
+ +
+
+

{data.title}

+
+ +
    +
  • {correct} correct
  • +
  • {incorrect} incorrect
  • +
  • {skipped} unanswered
  • +
+
+
+ +
+

Study recommendations

+ {data.recommendations.length === 0 ? ( +

Answer some questions and the weakest topics will show here.

+ ) : ( +
    + {data.recommendations.map(rec => ( +
  • + {rec.name} + + + + {rec.correct}/{rec.total} +
  • + ))} +
+ )} +
+
+ +
+
+

Performance analytics

+
+ {[['position', 'In order'], ['slowest', 'Slowest first'], ['hardest', 'Hardest first']].map(([key, label]) => ( + + ))} +
+
+
+ + + + + + + + + + + + {shown.map(row => ( + + + + + + + + ))} + +
QuestionStatusDifficultyTimePeers correct
+ {row.position}. + {row.excerpt}… + {row.category && {row.category}} + {row.status} + {row.difficulty + ? {row.difficulty} + : not set} + {row.seconds_spent == null ? '—' : clock(row.seconds_spent)} + {row.peer_percent == null ? '—' : `${row.peer_percent}%`} + {row.peer_sample > 0 && of {row.peer_sample}} +
+
+ {pages > 1 && ( +
+ + Results {page * PER_PAGE + 1}–{Math.min(rows.length, (page + 1) * PER_PAGE)} of {rows.length} + +
+ )} +
+
+
+ ) +} diff --git a/frontend/src/pages/QuizPage.jsx b/frontend/src/pages/QuizPage.jsx index 5ac715c..cb2cf74 100644 --- a/frontend/src/pages/QuizPage.jsx +++ b/frontend/src/pages/QuizPage.jsx @@ -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) diff --git a/frontend/src/pages/QuizPage.test.jsx b/frontend/src/pages/QuizPage.test.jsx index 74ff026..b7e2ce1 100644 --- a/frontend/src/pages/QuizPage.test.jsx +++ b/frontend/src/pages/QuizPage.test.jsx @@ -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 () => { diff --git a/frontend/src/pages/QuizzesPage.jsx b/frontend/src/pages/QuizzesPage.jsx index 179c2eb..fca26ac 100644 --- a/frontend/src/pages/QuizzesPage.jsx +++ b/frontend/src/pages/QuizzesPage.jsx @@ -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{counts.all}
@@ -534,19 +529,16 @@ export default function QuizzesPage() { )} {tab === 'library' && ( - libraryGroups.length === 0 ? ( + libraryRows.length === 0 ? (
No tests to show yet.
- ) : libraryGroups.map(group => ( -
-

{group.name} ({group.rows.length})

-
- {group.rows.map(row => ( - { setTab('sessions'); setStateFilter('all') }} /> - ))} -
+ ) : ( +
+ {libraryRows.map(row => ( + { setTab('sessions'); setStateFilter('all') }} /> + ))}
- )) + ) )}