diff --git a/backend/app/routers/attempts.py b/backend/app/routers/attempts.py
index b1d7663..efa40d8 100644
--- a/backend/app/routers/attempts.py
+++ b/backend/app/routers/attempts.py
@@ -9,6 +9,7 @@ from pydantic import BaseModel
from sqlalchemy.orm import Session
from sqlalchemy import case, func
+from app.services.study_plan_context import mark_block_complete, plan_context_for_quizzes
from app.database import get_db
from app.models.quiz import Quiz
from app.models.question import Question
@@ -163,6 +164,9 @@ def submit_attempt(
attempt.score = score
attempt.completed_at = datetime.utcnow()
+ # A block behind this quiz is now done. Nothing else ever set this;
+ # plans showed every block as unfinished however many times it was sat.
+ mark_block_complete(db, current_user.id, attempt.quiz_id)
db.commit()
# Clear saved progress from Redis when submitted
@@ -358,6 +362,9 @@ def get_progress(
attempt.score = sum(correct for _, _, correct in grades)
attempt.total_questions = len(grades)
attempt.completed_at = datetime.utcnow()
+ # A block behind this quiz is now done. Nothing else ever set this;
+ # plans showed every block as unfinished however many times it was sat.
+ mark_block_complete(db, current_user.id, attempt.quiz_id)
attempt.expired = 1 # mark as timer-expired; exclude from history
db.commit()
r.delete(key)
@@ -744,4 +751,7 @@ def attempt_analysis(
"seconds_per_question": round(sum(timed) / len(timed)) if timed else None,
"questions": detail,
"recommendations": recommendations[:8],
+ # Present when a study-plan block produced this session: the way back
+ # to the plan and on to the next block.
+ "plan": plan_context_for_quizzes(db, current_user.id, [attempt.quiz_id]).get(attempt.quiz_id),
}
diff --git a/backend/app/routers/quizzes.py b/backend/app/routers/quizzes.py
index eae2cd1..71cb955 100644
--- a/backend/app/routers/quizzes.py
+++ b/backend/app/routers/quizzes.py
@@ -5,6 +5,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import cast, String, or_, and_, func
from sqlalchemy.orm import Session
+from app.services.study_plan_context import plan_context_for_quizzes
from app.utils.upload_access import validate_image_attachments
from app.utils.quiz_questions import validate_option_explanations
from app.database import get_db
@@ -329,6 +330,7 @@ def list_quiz_sessions(
logger.warning("Redis unavailable for session progress", exc_info=True)
categories = {c.id: c.name for c in db.query(QuizCategory).all()}
+ plans = plan_context_for_quizzes(db, current_user.id, quiz_ids)
rows = []
for quiz in quizzes:
live = active.get(quiz.id)
@@ -374,6 +376,7 @@ def list_quiz_sessions(
"last_completed_at": last.completed_at.isoformat() if last else None,
"best_percentage": max((pct(a) for a in done), default=None),
"last_activity": last_activity.isoformat() if last_activity else None,
+ "plan": plans.get(quiz.id),
})
rows.sort(key=lambda r: (r["last_activity"] or ""), reverse=True)
return rows
diff --git a/backend/app/routers/study_tools.py b/backend/app/routers/study_tools.py
index d6fe309..1661022 100644
--- a/backend/app/routers/study_tools.py
+++ b/backend/app/routers/study_tools.py
@@ -8,6 +8,8 @@ from pydantic import BaseModel, Field, HttpUrl, field_validator
from sqlalchemy import func, or_
from sqlalchemy.orm import Session
+from app.models.exam import Exam
+from app.services.quiz_builder import exam_scope_predicate
from app.database import get_db
from app.models.article import Article
from app.models.attempt import AttemptAnswer, QuizAttempt
@@ -250,6 +252,13 @@ def study_recommendations(
empirical-Bayes estimate over recorded answers — not a psychometric exam
score, and not a prediction of any real examination.
"""
+ # Everything below is scoped to the study objective the learner has chosen.
+ # Someone revising for a paediatrics board who sat a plan meant for a step
+ # exam should not have that plan steer their recommendations; the objective
+ # is the frame, not whatever they happened to answer.
+ exam_filter = exam_scope_predicate(db, user)
+ active_exam = db.get(Exam, user.active_exam_id) if getattr(user, "active_exam_id", None) else None
+
categories = db.query(QuestionCategory).all()
ancestry = _category_rollup(categories)
names = {cat.id: cat.name for cat in categories}
@@ -267,6 +276,7 @@ def study_recommendations(
QuizAttempt.completed_at.isnot(None),
or_(QuizAttempt.expired == 0, QuizAttempt.expired.is_(None)),
Quiz.course_id.is_(None),
+ *([exam_filter] if exam_filter is not None else []),
).all()
extra_links: dict[int, set[int]] = defaultdict(set)
@@ -297,7 +307,8 @@ def study_recommendations(
available: dict[int, int] = defaultdict(int)
bank_total = 0
for question_id, primary in db.query(Question.id, Question.question_category_id).filter(
- shareable_question_predicate()).all():
+ shareable_question_predicate(),
+ *([exam_filter] if exam_filter is not None else [])).all():
bank_total += 1
for cid in categories_for(question_id, primary):
available[cid] += 1
@@ -362,6 +373,8 @@ def study_recommendations(
return {
"group": group,
+ "exam_id": active_exam.id if active_exam else None,
+ "exam_name": active_exam.name if active_exam else None,
"unlocked": unlocked,
"answers_needed": max(0, READINESS_UNLOCK_ANSWERS - total_answers),
"total_answered": total_answers,
diff --git a/backend/app/services/study_plan_context.py b/backend/app/services/study_plan_context.py
new file mode 100644
index 0000000..58a4dc9
--- /dev/null
+++ b/backend/app/services/study_plan_context.py
@@ -0,0 +1,73 @@
+"""Which study-plan block a quiz belongs to, for the learner who started it.
+
+A session that came out of a study plan is still a session — it sits in the
+learner's list and has its own analysis — but it also has a place in a
+sequence. Both the session list and the analysis need to say so, and to offer
+the way back to the plan and on to the next block, so the lookup lives here
+rather than in either router.
+"""
+from sqlalchemy.orm import Session
+
+from app.models.study_plan import StudyPlan, StudyPlanBlock, StudyPlanBlockProgress
+
+
+def plan_context_for_quizzes(db: Session, user_id: int, quiz_ids: list[int]) -> dict[int, dict]:
+ """Map quiz id → plan context, for quizzes that a block produced for this learner.
+
+ Quizzes with no plan behind them are simply absent from the result.
+ """
+ if not quiz_ids:
+ return {}
+ rows = (
+ db.query(StudyPlanBlockProgress, StudyPlanBlock, StudyPlan)
+ .join(StudyPlanBlock, StudyPlanBlock.id == StudyPlanBlockProgress.block_id)
+ .join(StudyPlan, StudyPlan.id == StudyPlanBlock.plan_id)
+ .filter(StudyPlanBlockProgress.user_id == user_id,
+ StudyPlanBlockProgress.quiz_id.in_(quiz_ids))
+ .all()
+ )
+ if not rows:
+ return {}
+
+ # Neighbours come from the plan's full block order, fetched once per plan.
+ plan_ids = {plan.id for _, _, plan in rows}
+ order: dict[int, list[StudyPlanBlock]] = {}
+ for block in (db.query(StudyPlanBlock)
+ .filter(StudyPlanBlock.plan_id.in_(plan_ids))
+ .order_by(StudyPlanBlock.plan_id, StudyPlanBlock.position).all()):
+ order.setdefault(block.plan_id, []).append(block)
+
+ out: dict[int, dict] = {}
+ for progress, block, plan in rows:
+ siblings = order.get(plan.id, [])
+ index = next((i for i, b in enumerate(siblings) if b.id == block.id), -1)
+ prev_block = siblings[index - 1] if index > 0 else None
+ next_block = siblings[index + 1] if 0 <= index < len(siblings) - 1 else None
+ out[progress.quiz_id] = {
+ "plan_id": plan.id,
+ "plan_name": plan.name,
+ "block_id": block.id,
+ "block_title": block.title,
+ "block_position": block.position,
+ "block_count": len(siblings),
+ "prev_block_id": prev_block.id if prev_block else None,
+ "next_block_id": next_block.id if next_block else None,
+ "completed": progress.completed_at is not None,
+ }
+ return out
+
+
+def mark_block_complete(db: Session, user_id: int, quiz_id: int) -> None:
+ """Record that the block behind `quiz_id` has been sat through.
+
+ Called when an attempt is submitted. Idempotent: a block done twice is
+ still done once, and the first completion is the date that counts.
+ """
+ from datetime import datetime
+
+ row = (db.query(StudyPlanBlockProgress)
+ .filter(StudyPlanBlockProgress.user_id == user_id,
+ StudyPlanBlockProgress.quiz_id == quiz_id)
+ .first())
+ if row is not None and row.completed_at is None:
+ row.completed_at = datetime.utcnow()
diff --git a/backend/scripts/seed_study_plans.py b/backend/scripts/seed_study_plans.py
index 157d39b..3d9088a 100644
--- a/backend/scripts/seed_study_plans.py
+++ b/backend/scripts/seed_study_plans.py
@@ -1,6 +1,6 @@
"""Turn the year question sets into study plans of numbered blocks.
-One plan per year, split into blocks of BLOCK_SIZE, plus a mixed plan that
+One plan per year, split evenly into blocks of about BLOCK_SIZE, plus a mixed plan that
draws MIXED_SIZE questions at random across every year.
Block membership is snapshotted, not a live filter: a plan you are part-way
@@ -13,6 +13,7 @@ years that changed, and leaves the mixed plan's draw alone unless --reshuffle.
import random
import re
import sys
+from math import ceil
from sqlalchemy import text as sa_text
@@ -20,7 +21,7 @@ from app.database import SessionLocal
from app.models.exam import Exam
from app.models.study_plan import StudyPlan, StudyPlanBlock
-BLOCK_SIZE = 50
+BLOCK_SIZE = 40
MIXED_SIZE = 300
MIXED_SLUG = "mixed-review"
# The imported material is tagged with the source programme's name; the plans
@@ -63,6 +64,22 @@ def upsert_plan(db, slug, name, description, kind, sort_order, exam_id):
return plan
+def even_chunks(ids, target=BLOCK_SIZE):
+ """Split into ceil(n / target) blocks whose sizes differ by at most one.
+
+ Plain slicing leaves a remainder — 202 questions became five blocks of 50
+ and one of 2 — and a block of two questions is not a study block.
+ """
+ k = max(1, ceil(len(ids) / target))
+ base, extra = divmod(len(ids), k)
+ out, i = [], 0
+ for n in range(k):
+ size = base + (1 if n < extra else 0)
+ out.append(ids[i:i + size])
+ i += size
+ return out
+
+
def set_blocks(db, plan, chunks):
"""Replace the plan's blocks with `chunks`, numbered from 1."""
db.query(StudyPlanBlock).filter(StudyPlanBlock.plan_id == plan.id).delete(synchronize_session=False)
@@ -87,12 +104,12 @@ def main():
for order, (tag_id, name) in enumerate(tags, start=1):
ids = question_ids_for(db, tag_id)
everything.extend(ids)
- chunks = [ids[i:i + BLOCK_SIZE] for i in range(0, len(ids), BLOCK_SIZE)]
+ chunks = even_chunks(ids)
summary.append((name, len(ids), len(chunks)))
if apply_changes:
year = re.search(r"(\d{4})", name).group(1)
plan = upsert_plan(db, YEAR_SLUG.format(year=year), YEAR_NAME.format(year=year),
- f"{len(ids)} questions in {len(chunks)} blocks of up to {BLOCK_SIZE}.",
+ f"{len(ids)} questions in {len(chunks)} blocks of about {BLOCK_SIZE}.",
"set", order, exam_id)
set_blocks(db, plan, chunks)
diff --git a/backend/tests/test_study_plan_sessions.py b/backend/tests/test_study_plan_sessions.py
new file mode 100644
index 0000000..630381c
--- /dev/null
+++ b/backend/tests/test_study_plan_sessions.py
@@ -0,0 +1,164 @@
+"""A study-plan block, once started, is a session — and knows where it came from.
+
+Disposable SQLite; no network or AI. Covers the join between plans and
+sessions: the session list and the analysis carry the block's place in the
+plan, submitting marks the block done (nothing did before), and the
+recommendations are framed by the study objective rather than by whatever
+happened to be answered.
+"""
+import sys
+import unittest
+from datetime import datetime
+from types import ModuleType
+from unittest.mock import patch
+
+import test_quiz_builder as fixtures
+from app.models.attempt import AttemptAnswer, QuizAttempt
+from app.models.exam import Exam, QuestionExamLink
+from app.models.quiz import Quiz
+from app.models.study_plan import StudyPlan, StudyPlanBlock, StudyPlanBlockProgress
+from app.routers import attempts, study_plans, study_tools
+from app.services.study_plan_context import mark_block_complete, plan_context_for_quizzes
+
+with patch.dict(sys.modules, {"app.services.quiz_service": ModuleType("app.services.quiz_service")}):
+ from app.routers import quizzes
+
+
+class StudyPlanSessionTests(unittest.TestCase):
+ def setUp(self):
+ self.bank = fixtures.BuilderTests()
+ self.bank.setUp()
+ self.client = self.bank.client
+ self.db = self.bank.db
+ for router, prefix in [(study_plans.router, "/study-plans"), (quizzes.router, "/quizzes"),
+ (attempts.router, "/attempts"), (study_tools.router, "/study-tools")]:
+ self.client.app.include_router(router, prefix=prefix)
+
+ self.db.add(StudyPlan(id=1, slug="board-review-i", name="Board Review I", kind="set", is_published=1))
+ self.db.flush()
+ self.db.add_all([
+ StudyPlanBlock(id=10, plan_id=1, position=1, title="Block 1", question_ids=[1, 2]),
+ # Both blocks use questions the owner can see; a block whose questions
+ # are all private to someone else cannot be started, by design.
+ StudyPlanBlock(id=11, plan_id=1, position=2, title="Block 2", question_ids=[2]),
+ ])
+ self.db.commit()
+ self.bank.user = self.bank.owner
+
+ def tearDown(self):
+ self.bank.tearDown()
+
+ def start(self, block_id=10, mode="learning"):
+ response = self.client.post(f"/study-plans/blocks/{block_id}/start", params={"mode": mode})
+ self.assertEqual(response.status_code, 200, response.text)
+ return response.json()["id"]
+
+ # ── sessions know their block ─────────────────────────────────────────────
+
+ def test_a_started_block_appears_in_sessions_with_its_place_in_the_plan(self):
+ quiz_id = self.start()
+ rows = {row["quiz_id"]: row for row in self.client.get("/quizzes/sessions").json()}
+ self.assertIn(quiz_id, rows)
+ plan = rows[quiz_id]["plan"]
+ self.assertEqual((plan["plan_name"], plan["block_title"]), ("Board Review I", "Block 1"))
+ self.assertEqual((plan["block_position"], plan["block_count"]), (1, 2))
+ self.assertIsNone(plan["prev_block_id"])
+ self.assertEqual(plan["next_block_id"], 11)
+ self.assertFalse(plan["completed"])
+
+ def test_a_session_with_no_plan_behind_it_says_so_plainly(self):
+ self.assertEqual(plan_context_for_quizzes(self.db, self.bank.owner.id, [999]), {})
+
+ def test_the_last_block_has_no_next(self):
+ quiz_id = self.start(11)
+ context = plan_context_for_quizzes(self.db, self.bank.owner.id, [quiz_id])[quiz_id]
+ self.assertEqual(context["prev_block_id"], 10)
+ self.assertIsNone(context["next_block_id"])
+
+ def test_plan_context_is_per_learner(self):
+ quiz_id = self.start()
+ # The same quiz means nothing to someone else's progress.
+ self.assertEqual(plan_context_for_quizzes(self.db, self.bank.peer.id, [quiz_id]), {})
+
+ # ── completion ────────────────────────────────────────────────────────────
+
+ def test_submitting_marks_the_block_done_once_and_keeps_the_first_date(self):
+ quiz_id = self.start()
+ row = self.db.query(StudyPlanBlockProgress).filter_by(quiz_id=quiz_id).one()
+ self.assertIsNone(row.completed_at)
+
+ mark_block_complete(self.db, self.bank.owner.id, quiz_id)
+ self.db.commit()
+ first = self.db.get(StudyPlanBlockProgress, row.id).completed_at
+ self.assertIsNotNone(first)
+
+ mark_block_complete(self.db, self.bank.owner.id, quiz_id)
+ self.db.commit()
+ self.assertEqual(self.db.get(StudyPlanBlockProgress, row.id).completed_at, first)
+
+ blocks = {b["title"]: b for b in self.client.get("/study-plans/1").json()["blocks"]}
+ self.assertTrue(blocks["Block 1"]["completed"])
+ self.assertFalse(blocks["Block 2"]["completed"])
+
+ def test_marking_a_quiz_with_no_block_is_a_no_op(self):
+ mark_block_complete(self.db, self.bank.owner.id, 12345) # must not raise
+ self.db.commit()
+
+ def test_the_analysis_carries_the_plan_context(self):
+ quiz_id = self.start()
+ attempt = QuizAttempt(id=700, quiz_id=quiz_id, user_id=self.bank.owner.id, mode="learning",
+ total_questions=2, score=1, started_at=datetime(2026, 3, 1, 9),
+ completed_at=datetime(2026, 3, 1, 10))
+ self.db.add(attempt)
+ self.db.add_all([
+ AttemptAnswer(attempt_id=700, question_id=1, user_answer="A", is_correct=True),
+ AttemptAnswer(attempt_id=700, question_id=2, user_answer="B", is_correct=False),
+ ])
+ self.db.commit()
+ response = self.client.get("/attempts/700/analysis")
+ self.assertEqual(response.status_code, 200, response.text)
+ plan = response.json()["plan"]
+ self.assertEqual(plan["plan_id"], 1)
+ self.assertEqual(plan["block_id"], 10)
+ self.assertEqual(plan["next_block_id"], 11)
+
+ # ── recommendations follow the study objective ────────────────────────────
+
+ def test_recommendations_are_framed_by_the_active_exam(self):
+ self.db.add_all([Exam(id=1, slug="boards", name="Pediatrics Boards"),
+ Exam(id=2, slug="step", name="Step 1")])
+ self.db.flush()
+ # Question 1 belongs to the boards, 2 to the step exam, 5 to neither.
+ self.db.add_all([QuestionExamLink(question_id=1, exam_id=1),
+ QuestionExamLink(question_id=2, exam_id=2)])
+ quiz = Quiz(id=300, title="Mixed", user_id=self.bank.owner.id, mode="learning",
+ questions_count=2, is_published=1)
+ self.db.add(quiz)
+ self.db.flush()
+ self.db.add(QuizAttempt(id=701, quiz_id=300, user_id=self.bank.owner.id, mode="learning",
+ total_questions=2, score=1, started_at=datetime(2026, 3, 2, 9),
+ completed_at=datetime(2026, 3, 2, 10)))
+ self.db.add_all([
+ AttemptAnswer(attempt_id=701, question_id=1, user_answer="A", is_correct=True),
+ AttemptAnswer(attempt_id=701, question_id=2, user_answer="B", is_correct=False),
+ ])
+ self.db.commit()
+
+ # No objective chosen: everything counts.
+ everything = self.client.get("/study-tools/recommendations").json()
+ self.assertIsNone(everything["exam_name"])
+ self.assertEqual(everything["total_answered"], 2)
+
+ # Studying for the boards: the step-exam answer is left out of the
+ # picture, and the bank it is measured against shrinks to match.
+ self.bank.owner.active_exam_id = 1
+ self.db.commit()
+ boards = self.client.get("/study-tools/recommendations").json()
+ self.assertEqual(boards["exam_name"], "Pediatrics Boards")
+ self.assertEqual(boards["total_answered"], 1)
+ self.assertEqual(boards["overall_accuracy"], 100.0)
+ self.assertEqual(boards["bank_total"], everything["bank_total"] - 1)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index 26cef11..ec13ce1 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -39,6 +39,7 @@ const MediaPage = lazy(() => import('./pages/MediaPage'))
const EditorialPage = lazy(() => import('./pages/EditorialPage'))
const StudyPlansPage = lazy(() => import('./pages/StudyPlansPage'))
const StudyPlanPage = lazy(() => import('./pages/StudyPlanPage'))
+const StudyPlanBlockPage = lazy(() => import('./pages/StudyPlanBlockPage'))
const ArticlePage = lazy(() => import('./pages/ArticlesPage').then(m => ({ default: m.ArticlePage })))
const PublicQuizPage = lazy(() => import('./pages/PublicQuizPage'))
const FlashcardStudyPage = lazy(() => import('./pages/FlashcardStudyPage'))
@@ -141,6 +142,7 @@ function AppRoutes() {
} />
} />
} />
+ } />
} />
} />
{/* Cross-references in article prose address a topic by slug, which
diff --git a/frontend/src/pages/AnalysisPage.css b/frontend/src/pages/AnalysisPage.css
index 69cc4f9..f0df185 100644
--- a/frontend/src/pages/AnalysisPage.css
+++ b/frontend/src/pages/AnalysisPage.css
@@ -142,3 +142,6 @@
.an-nextstep-controls .btn { flex: 1; }
.an-detail-actions .btn { flex: 1; text-align: center; }
}
+
+.an-exam { color: var(--primary); text-decoration: none; border-bottom: 2px solid currentColor; }
+.an-exam:hover { opacity: .8; }
diff --git a/frontend/src/pages/AnalysisPage.jsx b/frontend/src/pages/AnalysisPage.jsx
index fd64be1..6ea10f1 100644
--- a/frontend/src/pages/AnalysisPage.jsx
+++ b/frontend/src/pages/AnalysisPage.jsx
@@ -103,8 +103,14 @@ export default function AnalysisPage() {
-
Your performance analysis
-
Built from your own answers — where you stand, and what to study next.
+
+ Your performance analysis
+ {data?.exam_name && <> for {data.exam_name}>}
+
+
+ Built from your own answers — where you stand, and what to study next.
+ {data?.exam_name && ' Scoped to the exam you are studying for; the rest of the bank is left out.'}
+
diff --git a/frontend/src/pages/AnalysisSessionPage.css b/frontend/src/pages/AnalysisSessionPage.css
index 78e067d..b6e74b5 100644
--- a/frontend/src/pages/AnalysisSessionPage.css
+++ b/frontend/src/pages/AnalysisSessionPage.css
@@ -111,3 +111,15 @@
.an-notyet-lead { margin: 0 0 6px; font-size: 1.02rem; font-weight: 650; }
.an-notyet p { margin: 0 0 10px; font-size: .88rem; color: var(--text-muted); }
.an-notyet-note { line-height: 1.6; }
+
+/* Where this session sits in its study plan. A session from a plan is still a
+ session, but it is also a step in a sequence, and the next step is the
+ thing most worth offering here. */
+.an-plan {
+ display: flex; justify-content: space-between; gap: 12px; flex-wrap: wrap;
+ margin: -6px 0 16px; padding: 10px 14px; font-size: .84rem;
+ background: var(--option-sel-bg); border-radius: 8px; color: var(--text-muted);
+}
+.an-plan a { color: var(--primary); text-decoration: none; font-weight: 600; }
+.an-plan a:hover { text-decoration: underline; }
+.an-plan-links { display: flex; gap: 16px; }
diff --git a/frontend/src/pages/AnalysisSessionPage.jsx b/frontend/src/pages/AnalysisSessionPage.jsx
index d92d33f..4df811f 100644
--- a/frontend/src/pages/AnalysisSessionPage.jsx
+++ b/frontend/src/pages/AnalysisSessionPage.jsx
@@ -197,6 +197,20 @@ export default function AnalysisSessionPage() {
)}
+ {data.plan && (
+
+
+ Block {data.plan.block_position} of {data.plan.block_count} in{' '}
+ {data.plan.plan_name}
+
+
+ Back to this block
+ {data.plan.next_block_id && (
+ Next block ›
+ )}
+
+
+ )}
{confirmDelete && (
This removes the attempt and its answers. Your overall statistics are
diff --git a/frontend/src/pages/StudyPlanBlockPage.css b/frontend/src/pages/StudyPlanBlockPage.css
new file mode 100644
index 0000000..5c9f93a
--- /dev/null
+++ b/frontend/src/pages/StudyPlanBlockPage.css
@@ -0,0 +1,97 @@
+/* One block of a study plan, laid out as a course module: blocks down the
+ left, this block in the middle, previous / next along the bottom. */
+
+.spb-layout {
+ display: grid; grid-template-columns: 260px minmax(0, 1fr); gap: 0 28px;
+ max-width: 1240px; margin: 0 auto; padding-bottom: 72px;
+}
+
+/* ── Blocks rail ─────────────────────────────────────────────────── */
+.spb-rail { position: sticky; top: 76px; align-self: start; max-height: calc(100vh - 100px); overflow-y: auto; }
+.spb-rail-plan {
+ display: block; padding: 12px 14px; font-weight: 700; font-size: .95rem;
+ color: var(--text); text-decoration: none; border-bottom: 1px solid var(--border);
+}
+.spb-rail-plan:hover { color: var(--primary); }
+.spb-rail-list { list-style: none; margin: 0; padding: 0; }
+.spb-rail-item {
+ display: flex; flex-direction: column; gap: 3px; padding: 12px 14px;
+ text-decoration: none; color: var(--text); border-bottom: 1px solid var(--border);
+ border-left: 3px solid transparent;
+}
+.spb-rail-item:hover { background: var(--bg); }
+.spb-rail-item strong { font-size: .9rem; font-weight: 600; }
+.spb-rail-item span { font-size: .76rem; color: var(--text-muted); }
+/* The block you are on, and the ones behind you. */
+.spb-rail-item.is-active { background: var(--option-sel-bg); border-left-color: var(--primary); }
+.spb-rail-item.is-active strong { color: var(--primary); }
+.spb-rail-item.is-done strong::after { content: ' ✓'; color: var(--correct-fg); font-weight: 700; }
+
+/* ── This block ──────────────────────────────────────────────────── */
+.spb-main { min-width: 0; padding-top: 8px; }
+.spb-crumb {
+ font-size: .72rem; font-weight: 700; letter-spacing: .06em; text-transform: uppercase;
+ color: var(--text-muted); text-decoration: none;
+}
+.spb-crumb:hover { color: var(--primary); }
+.spb-title { margin: 8px 0 18px; font-size: 1.6rem; font-weight: 700; }
+
+.spb-section { padding: 18px 0; border-top: 1px solid var(--border); }
+.spb-section h2 { margin: 0 0 12px; font-size: 1.1rem; font-weight: 600; color: var(--text-muted); }
+.spb-section h2 small { margin-left: 8px; font-size: .78rem; font-weight: 500; color: var(--text-subtle); }
+.spb-section-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
+.spb-section-head h2 { margin: 0 0 12px; }
+
+.spb-articles { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; }
+.spb-articles li {
+ display: flex; align-items: center; gap: 12px; min-height: 52px; padding: 10px 16px;
+ background: var(--card-bg); border: 1px solid var(--border); border-radius: 10px;
+}
+.spb-articles li > a { flex: 1; min-width: 0; font-size: .95rem; color: var(--text); text-decoration: none; overflow-wrap: anywhere; }
+.spb-articles li > a:hover { color: var(--primary); }
+.spb-articles li.is-read > a { color: var(--text-muted); }
+.spb-read { display: inline-flex; align-items: center; gap: 8px; font-size: .88rem; cursor: pointer; white-space: nowrap; }
+.spb-read input { width: 18px; height: 18px; }
+
+/* Study / Exam as a segmented control: one of two, always one. */
+.spb-modes { display: inline-flex; border: 1px solid var(--border); border-radius: 8px; overflow: hidden; margin-bottom: 12px; }
+.spb-modes button {
+ padding: 8px 16px; font: inherit; font-size: .88rem; background: var(--card-bg);
+ color: var(--text-muted); border: 0; cursor: pointer;
+}
+.spb-modes button + button { border-left: 1px solid var(--border); }
+.spb-modes button.is-on { background: var(--option-sel-bg); color: var(--primary); font-weight: 600; }
+.spb-mode-tag {
+ margin-bottom: 12px; padding: 4px 10px; font-size: .74rem; font-weight: 700; letter-spacing: .04em;
+ text-transform: uppercase; color: var(--text-muted); background: var(--bg); border-radius: 6px;
+}
+
+.spb-session { padding: 16px 20px; background: var(--card-bg); border: 1px solid var(--border); border-radius: 12px; }
+.spb-session-title { font-size: 1rem; font-weight: 600; margin-bottom: 12px; }
+.spb-session-row { display: flex; align-items: center; gap: 20px; flex-wrap: wrap; }
+.spb-session-row .sp-wrap { flex: 1; min-width: 200px; }
+.spb-session-actions { display: flex; gap: 8px; flex-wrap: wrap; }
+
+.spb-edit-hint { font-size: .8rem; color: var(--text-muted); }
+
+/* ── Previous / next ─────────────────────────────────────────────── */
+.spb-footer {
+ position: fixed; left: 0; right: 0; bottom: 0; z-index: 30;
+ display: grid; grid-template-columns: 1fr 1fr 1fr; align-items: center;
+ background: var(--card-bg); border-top: 1px solid var(--border);
+ font-size: .78rem; font-weight: 700; letter-spacing: .06em; text-transform: uppercase;
+}
+.spb-footer > * { padding: 16px 20px calc(16px + env(safe-area-inset-bottom)); color: var(--text-muted); text-decoration: none; }
+.spb-footer > a:hover { color: var(--primary); }
+.spb-footer > :nth-child(2) { text-align: center; border-left: 1px solid var(--border); border-right: 1px solid var(--border); }
+.spb-footer > :nth-child(3) { text-align: right; }
+
+@media (max-width: 900px) {
+ .spb-layout { grid-template-columns: 1fr; }
+ .spb-rail { position: static; max-height: none; margin-bottom: 8px; }
+ .spb-rail-list { display: flex; gap: 6px; overflow-x: auto; padding-bottom: 6px; }
+ .spb-rail-item { border: 1px solid var(--border); border-radius: 8px; white-space: nowrap; }
+ .spb-rail-item.is-active { border-color: var(--primary); }
+ .spb-footer { font-size: .7rem; }
+ .spb-footer > * { padding-inline: 10px; }
+}
diff --git a/frontend/src/pages/StudyPlanBlockPage.jsx b/frontend/src/pages/StudyPlanBlockPage.jsx
new file mode 100644
index 0000000..9b60864
--- /dev/null
+++ b/frontend/src/pages/StudyPlanBlockPage.jsx
@@ -0,0 +1,212 @@
+import { useCallback, useEffect, useMemo, useState } from 'react'
+import { Link, useNavigate, useParams } from 'react-router-dom'
+import api from '../api/client'
+import { useAuth } from '../context/AuthContext'
+import SessionProgress from '../components/SessionProgress'
+import './StudyPlanBlockPage.css'
+
+const apiError = (err, fallback) => {
+ const detail = err?.response?.data?.detail
+ return typeof detail === 'string' ? detail : fallback
+}
+
+/**
+ * One block of a study plan: the reading, then the session it prepares you for.
+ *
+ * Laid out the way a course module is — the plan's blocks down the left, this
+ * block in the middle, previous and next along the bottom — because a plan is
+ * a sequence and the page should make the sequence visible. Starting a block
+ * makes a session; from then on the session lives in Sessions like any other,
+ * and this page shows where it has got to and offers the way back into it.
+ */
+export default function StudyPlanBlockPage() {
+ const { id, blockId } = useParams()
+ const { user } = useAuth()
+ const navigate = useNavigate()
+ const [plan, setPlan] = useState(null)
+ const [sessions, setSessions] = useState([])
+ const [loading, setLoading] = useState(true)
+ const [error, setError] = useState('')
+ const [busy, setBusy] = useState(false)
+ // Study mode by default: the plan is for learning first. Exam mode is the
+ // choice you make deliberately, not the one you fall into.
+ const [mode, setMode] = useState('learning')
+
+ const load = useCallback(() => {
+ setLoading(true)
+ Promise.all([
+ api.get(`/study-plans/${id}`),
+ // Progress for the block's session comes from the same row the Sessions
+ // page shows, so the two never disagree about how far along you are.
+ api.get('/quizzes/sessions').catch(() => ({ data: [] })),
+ ])
+ .then(([planRes, sessionRes]) => {
+ setPlan(planRes.data)
+ setSessions(Array.isArray(sessionRes.data) ? sessionRes.data : [])
+ })
+ .catch(err => setError(apiError(err, 'Could not load this plan')))
+ .finally(() => setLoading(false))
+ }, [id])
+
+ useEffect(() => { load() }, [load])
+
+ const blocks = plan?.blocks || []
+ const index = blocks.findIndex(b => String(b.id) === String(blockId))
+ const block = index >= 0 ? blocks[index] : null
+ const prev = index > 0 ? blocks[index - 1] : null
+ const next = index >= 0 && index < blocks.length - 1 ? blocks[index + 1] : null
+
+ const session = useMemo(
+ () => (block?.quiz_id ? sessions.find(row => row.quiz_id === block.quiz_id) : null),
+ [sessions, block],
+ )
+
+ const start = async () => {
+ setBusy(true); setError('')
+ try {
+ const res = await api.post(`/study-plans/blocks/${block.id}/start`, null, { params: { mode } })
+ navigate(`/study/${res.data.id}`)
+ } catch (err) { setError(apiError(err, 'Could not start this block')) }
+ finally { setBusy(false) }
+ }
+
+ const toggleRead = async (link) => {
+ setPlan(prevPlan => ({
+ ...prevPlan,
+ blocks: prevPlan.blocks.map(b => ({
+ ...b, articles: b.articles.map(a => a.link_id === link.link_id ? { ...a, read: !a.read } : a),
+ })),
+ }))
+ try {
+ await api.post(`/study-plans/reading/${link.link_id}/read`, null, { params: { read: !link.read } })
+ } catch (err) { setError(apiError(err, 'Could not save that')); load() }
+ }
+
+ if (loading) return
+ if (!plan) return
{error || 'Plan not found.'}
+ if (!block) return
This block is not part of the plan.
+
+ const readCount = block.articles.filter(a => a.read).length
+ const sessionTitle = `${plan.name} — ${block.title}`
+ // What the session card offers depends on where the session has got to,
+ // never on a guess: no quiz yet → start; sat → analysis; part-way → resume.
+ const state = !block.quiz_id ? 'fresh' : session?.state || (block.completed ? 'completed' : 'not_started')
+
+ return (
+
+ {/* The mode is chosen before the session exists. Once it does, the
+ mode is a fact about it, and is shown rather than offered. */}
+ {state === 'fresh' ? (
+
+ Reading and block titles are edited on the plan page.
+
+ )}
+
+
+
+
+ )
+}
diff --git a/frontend/src/pages/StudyPlanBlockPage.test.jsx b/frontend/src/pages/StudyPlanBlockPage.test.jsx
new file mode 100644
index 0000000..3a81082
--- /dev/null
+++ b/frontend/src/pages/StudyPlanBlockPage.test.jsx
@@ -0,0 +1,123 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { render, screen, waitFor, within } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { MemoryRouter, Route, Routes } from 'react-router-dom'
+import StudyPlanBlockPage from './StudyPlanBlockPage'
+import api from '../api/client'
+
+vi.mock('../api/client', () => ({ default: { get: vi.fn(), post: vi.fn(), patch: vi.fn(), delete: vi.fn() } }))
+let currentUser = { id: 1, name: 'Learner', is_moderator: false }
+vi.mock('../context/AuthContext', () => ({ useAuth: () => ({ user: currentUser }) }))
+
+const plan = {
+ id: 1, slug: 'board-review-i', name: 'Board Review I', description: null, kind: 'set', is_published: true,
+ blocks: [
+ { id: 10, position: 1, title: 'Block 1', question_count: 38, quiz_id: 77, completed: false,
+ articles: [{ link_id: 100, article_id: 5, slug: 'asthma', title: 'Asthma', status: 'published', read: true }] },
+ { id: 11, position: 2, title: 'Block 2', question_count: 38, quiz_id: null, completed: false,
+ articles: [{ link_id: 101, article_id: 6, slug: 'croup', title: 'Croup', status: 'published', read: false }] },
+ { id: 12, position: 3, title: 'Block 3', question_count: 38, quiz_id: 78, completed: true, articles: [] },
+ ],
+}
+
+// The same rows the Sessions page shows; the block page reads its progress
+// from them rather than keeping a second opinion.
+const sessions = [
+ { quiz_id: 77, title: 'Board Review I — Block 1', mode: 'learning', state: 'in_progress',
+ answered: 12, total: 38, last_attempt_id: 501, last_score: null },
+ { quiz_id: 78, title: 'Board Review I — Block 3', mode: 'timed', state: 'completed',
+ answered: 38, total: 38, last_attempt_id: 502, last_score: 30 },
+]
+
+const mount = (blockId) => render(
+
+
+ } />
+ Test running} />
+
+ )
+
+describe('a study-plan block', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ currentUser = { id: 1, name: 'Learner', is_moderator: false }
+ api.get.mockImplementation(url => {
+ if (url === '/study-plans/1') return Promise.resolve({ data: plan })
+ if (url === '/quizzes/sessions') return Promise.resolve({ data: sessions })
+ return Promise.resolve({ data: [] })
+ })
+ })
+
+ it('lets you pick the mode before starting, and starts in the one you picked', async () => {
+ mount(11)
+ await screen.findByRole('heading', { name: 'Block 2' })
+ // Nothing was launched by arriving here.
+ expect(api.post).not.toHaveBeenCalled()
+
+ const study = screen.getByRole('radio', { name: 'Study mode' })
+ const exam = screen.getByRole('radio', { name: 'Exam mode' })
+ expect(study).toHaveAttribute('aria-checked', 'true')
+ await userEvent.click(exam)
+ expect(exam).toHaveAttribute('aria-checked', 'true')
+
+ api.post.mockResolvedValue({ data: { id: 91 } })
+ await userEvent.click(screen.getByRole('button', { name: 'Start' }))
+ await waitFor(() => expect(api.post).toHaveBeenCalledWith(
+ '/study-plans/blocks/11/start', null, { params: { mode: 'timed' } }))
+ expect(await screen.findByText('Test running')).toBeInTheDocument()
+ })
+
+ it('shows how far a started block has got, and offers resume and analysis rather than a restart', async () => {
+ mount(10)
+ await screen.findByRole('heading', { name: 'Block 1' })
+ expect(screen.getByText('12/38 questions')).toBeInTheDocument()
+ expect(screen.getByRole('link', { name: 'Resume' })).toHaveAttribute('href', '/study/77')
+ expect(screen.getByRole('link', { name: 'Go to analysis' })).toHaveAttribute('href', '/sessions/501')
+ // The mode is a fact about an existing session, not a choice.
+ expect(screen.queryByRole('radio', { name: 'Exam mode' })).not.toBeInTheDocument()
+ expect(screen.getByText('Study mode')).toBeInTheDocument()
+ })
+
+ it('sends a finished block to its analysis first', async () => {
+ mount(12)
+ await screen.findByRole('heading', { name: 'Block 3' })
+ expect(screen.getByRole('link', { name: 'Go to analysis' })).toHaveAttribute('href', '/sessions/502')
+ expect(screen.getByRole('link', { name: 'Sit again' })).toHaveAttribute('href', '/study/78?restart=1')
+ })
+
+ it('walks the plan: blocks on the left, previous and next along the bottom', async () => {
+ mount(11)
+ await screen.findByRole('heading', { name: 'Block 2' })
+ const rail = screen.getByRole('complementary', { name: 'Blocks in this plan' })
+ expect(within(rail).getByRole('link', { name: /Block 2/ })).toHaveAttribute('aria-current', 'page')
+ expect(within(rail).getByRole('link', { name: /Block 3.*done/ })).toBeInTheDocument()
+
+ const footer = screen.getByRole('navigation', { name: 'Plan navigation' })
+ expect(within(footer).getByRole('link', { name: 'Back to study plan' })).toHaveAttribute('href', '/study-plans/1')
+ expect(within(footer).getByRole('link', { name: '‹ Previous block' })).toHaveAttribute('href', '/study-plans/1/blocks/10')
+ expect(within(footer).getByRole('link', { name: 'Next block ›' })).toHaveAttribute('href', '/study-plans/1/blocks/12')
+ })
+
+ it('has no previous on the first block and no next on the last', async () => {
+ mount(10)
+ await screen.findByRole('heading', { name: 'Block 1' })
+ expect(screen.queryByRole('link', { name: '‹ Previous block' })).not.toBeInTheDocument()
+ expect(screen.getByRole('link', { name: 'Next block ›' })).toBeInTheDocument()
+ })
+
+ it('puts the reading above the session and lets it be marked read', async () => {
+ mount(11)
+ await screen.findByRole('heading', { name: 'Block 2' })
+ const headings = screen.getAllByRole('heading', { level: 2 }).map(h => h.textContent)
+ expect(headings[0]).toMatch(/^Articles/)
+ expect(headings[1]).toBe('Sessions')
+
+ api.post.mockResolvedValue({ data: {} })
+ const croup = screen.getByRole('checkbox', { name: 'Mark Croup as read' })
+ expect(croup).not.toBeChecked()
+ await userEvent.click(croup)
+ await waitFor(() => expect(api.post).toHaveBeenCalledWith(
+ '/study-plans/reading/101/read', null, { params: { read: true } }))
+ expect(croup).toBeChecked()
+ })
+})
diff --git a/frontend/src/pages/StudyPlanPage.jsx b/frontend/src/pages/StudyPlanPage.jsx
index 6311c47..83680ec 100644
--- a/frontend/src/pages/StudyPlanPage.jsx
+++ b/frontend/src/pages/StudyPlanPage.jsx
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useState } from 'react'
-import { Link, useNavigate, useParams } from 'react-router-dom'
+import { Link, useParams } from 'react-router-dom'
import api from '../api/client'
import { useAuth } from '../context/AuthContext'
import './StudyPlansPage.css'
@@ -25,7 +25,6 @@ const apiError = (err, fallback) => {
export default function StudyPlanPage() {
const { user } = useAuth()
const { id } = useParams()
- const navigate = useNavigate()
const [plan, setPlan] = useState(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
@@ -52,15 +51,6 @@ export default function StudyPlanPage() {
useEffect(() => { load() }, [load])
- const start = async (block, mode) => {
- setBusy(true); setError('')
- try {
- const res = await api.post(`/study-plans/blocks/${block.id}/start`, null, { params: { mode } })
- navigate(`/study/${res.data.id}`)
- } catch (err) { setError(apiError(err, 'Could not start this block')) }
- finally { setBusy(false) }
- }
-
const toggleRead = async (link) => {
// Optimistic: a tick that waits on the network feels broken, and the only
// cost of being wrong is a checkbox that flips back.
@@ -226,7 +216,7 @@ export default function StudyPlanPage() {
) : (
<>
-
{block.title}
+
{block.title}
{block.question_count} question{block.question_count === 1 ? '' : 's'}
{block.completed && ' · done'}
@@ -297,22 +287,21 @@ export default function StudyPlanPage() {
)}
+ {/* The session itself — mode, progress, start / resume / analysis —
+ lives on the block's own page. This is the plan's table of
+ contents, and a table of contents does not start things. */}
Sessions
- {block.quiz_id ? (
-
- {block.completed ? 'Review this block' : 'Continue this block'}
+