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') }} /> + ))}
- )) + ) )}