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() {