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:
parent
d34233d87b
commit
6bb5767871
14 changed files with 638 additions and 32 deletions
26
backend/alembic/versions/e9f0a1b2c3d4_answer_timing.py
Normal file
26
backend/alembic/versions/e9f0a1b2c3d4_answer_timing.py
Normal 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")
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
77
backend/scripts/number_board_reviews.py
Normal file
77
backend/scripts/number_board_reviews.py
Normal 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())
|
||||
38
docs/TODO.md
38
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.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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 />} />
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
92
frontend/src/pages/AnalysisSessionPage.css
Normal file
92
frontend/src/pages/AnalysisSessionPage.css
Normal 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); }
|
||||
254
frontend/src/pages/AnalysisSessionPage.jsx
Normal file
254
frontend/src/pages/AnalysisSessionPage.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
))
|
||||
)
|
||||
)}
|
||||
|
||||
</>
|
||||
|
|
|
|||
Loading…
Reference in a new issue