feat: study-plan blocks as modules, sessions that know their block

From the three recordings and the AMBOSS screenshots.

Study plans
- Blocks of about 40, split evenly: 202 questions is six blocks of
  33-34, not five of 50 and one of 2. Reseeded (no progress or reading
  existed yet); the seeder now splits the same way.
- A block has its own page, laid out as a course module: the plan's
  blocks down the left, this block's reading then its session in the
  middle, back / previous / next along the bottom. Study or exam mode
  is chosen there, before the session exists; afterwards the mode is
  shown, not offered. The plan page is the table of contents and links
  into blocks rather than starting anything.
- Progress on a block comes from the same /quizzes/sessions row the
  Sessions page shows, so the two cannot disagree.

Sessions <-> plans
- A session started from a block carries its place in the plan: the
  session list and the analysis both return `plan` (plan, block,
  position, previous and next block). The analysis shows a strip with
  the way back to the block and on to the next one.
- Submitting a session marks its block complete. Nothing ever set
  completed_at before — every block read as unfinished forever.

Recommendations
- Framed by the learner's chosen study objective: answers and bank
  material linked to a different exam are left out, and the page is
  titled for the exam. Unlinked material stays in, as elsewhere.

Backend 216/216, frontend 257/258 (the one failure is
ArticleSplitView, which is timing-flaky under the full run and is
unrelated to this change; being checked separately).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
This commit is contained in:
Daniel 2026-09-11 04:31:21 +02:00
parent 5a37c3d3f0
commit ac92793600
17 changed files with 780 additions and 38 deletions

View file

@ -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),
}

View file

@ -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

View file

@ -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,

View file

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

View file

@ -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)

View file

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

View file

@ -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() {
<Route path="/media" element={<MediaPage />} />
<Route path="/study-plans" element={<StudyPlansPage />} />
<Route path="/study-plans/:id" element={<StudyPlanPage />} />
<Route path="/study-plans/:id/blocks/:blockId" element={<StudyPlanBlockPage />} />
<Route path="/articles" element={<ArticlesPage />} />
<Route path="/articles/:id" element={<ArticlePage />} />
{/* Cross-references in article prose address a topic by slug, which

View file

@ -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; }

View file

@ -103,8 +103,14 @@ export default function AnalysisPage() {
<div className="an-page">
<div className="an-header">
<h1>Your performance analysis</h1>
<p>Built from your own answers where you stand, and what to study next.</p>
<h1>
Your performance analysis
{data?.exam_name && <> for <Link to="/settings" className="an-exam">{data.exam_name}</Link></>}
</h1>
<p>
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.'}
</p>
</div>
<div className="an-tabs" role="tablist">

View file

@ -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; }

View file

@ -197,6 +197,20 @@ export default function AnalysisSessionPage() {
)}
</div>
</div>
{data.plan && (
<div className="an-plan">
<span>
Block {data.plan.block_position} of {data.plan.block_count} in{' '}
<Link to={`/study-plans/${data.plan.plan_id}`}>{data.plan.plan_name}</Link>
</span>
<span className="an-plan-links">
<Link to={`/study-plans/${data.plan.plan_id}/blocks/${data.plan.block_id}`}>Back to this block</Link>
{data.plan.next_block_id && (
<Link to={`/study-plans/${data.plan.plan_id}/blocks/${data.plan.next_block_id}`}>Next block </Link>
)}
</span>
</div>
)}
{confirmDelete && (
<p className="an-danger-note" role="alert">
This removes the attempt and its answers. Your overall statistics are

View file

@ -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; }
}

View file

@ -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 <div className="loading"><div className="spinner" /></div>
if (!plan) return <div className="plans-empty">{error || 'Plan not found.'}</div>
if (!block) return <div className="plans-empty">This block is not part of the plan.</div>
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 (
<div className="spb-layout">
<aside className="spb-rail" aria-label="Blocks in this plan">
<Link className="spb-rail-plan" to={`/study-plans/${plan.id}`}>{plan.name}</Link>
<ol className="spb-rail-list">
{blocks.map(b => {
const row = b.quiz_id ? sessions.find(r => r.quiz_id === b.quiz_id) : null
return (
<li key={b.id}>
<Link className={`spb-rail-item${b.id === block.id ? ' is-active' : ''}${b.completed ? ' is-done' : ''}`}
to={`/study-plans/${plan.id}/blocks/${b.id}`} aria-current={b.id === block.id ? 'page' : undefined}>
<strong>{b.title}</strong>
<span>
{b.question_count} question{b.question_count === 1 ? '' : 's'}
{b.articles.length > 0 && ` · ${b.articles.length} article${b.articles.length === 1 ? '' : 's'}`}
{b.completed ? ' · done' : row?.state === 'in_progress' ? ` · ${row.answered}/${row.total}` : ''}
</span>
</Link>
</li>
)
})}
</ol>
</aside>
<main className="spb-main">
<Link className="spb-crumb" to={`/study-plans/${plan.id}`}> {plan.name}</Link>
<h1 className="spb-title">{block.title}</h1>
{error && <p className="plans-error" role="alert">{error}</p>}
{block.articles.length > 0 && (
<section className="spb-section" aria-labelledby="spb-articles">
<h2 id="spb-articles">Articles <small>{readCount}/{block.articles.length} read</small></h2>
<ul className="spb-articles">
{block.articles.map(link => (
<li key={link.link_id} className={link.read ? 'is-read' : ''}>
<Link to={`/articles/${link.article_id}`}>{link.title}</Link>
{link.status !== 'published' && <span className="plan-tag is-draft">draft</span>}
<label className="spb-read">
<input type="checkbox" checked={link.read}
aria-label={`Mark ${link.title} as read`} onChange={() => toggleRead(link)} />
<span>Mark as read</span>
</label>
</li>
))}
</ul>
</section>
)}
<section className="spb-section" aria-labelledby="spb-sessions">
<div className="spb-section-head">
<h2 id="spb-sessions">Sessions</h2>
{/* 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' ? (
<div className="spb-modes" role="radiogroup" aria-label="Session mode">
{[['learning', 'Study mode'], ['timed', 'Exam mode']].map(([value, label]) => (
<button key={value} type="button" role="radio" aria-checked={mode === value}
className={mode === value ? 'is-on' : ''} onClick={() => setMode(value)}>{label}</button>
))}
</div>
) : session && (
<span className="spb-mode-tag">{session.mode === 'learning' ? 'Study mode' : 'Exam mode'}</span>
)}
</div>
{block.question_count === 0 ? (
<p className="plans-empty">No questions in this block yet.</p>
) : (
<div className="spb-session">
<div className="spb-session-title">{sessionTitle}</div>
<div className="spb-session-row">
<SessionProgress answered={session?.answered || 0}
total={session?.total || block.question_count}
correct={state === 'completed' ? session?.last_score ?? null : null} />
<div className="spb-session-actions">
{state === 'fresh' && (
<button className="btn btn-primary" disabled={busy} onClick={start}>Start</button>
)}
{state === 'in_progress' && (
<>
{session?.last_attempt_id && (
<Link className="btn btn-secondary" to={`/sessions/${session.last_attempt_id}`}>Go to analysis</Link>
)}
<Link className="btn btn-primary" to={`/study/${block.quiz_id}`}>Resume</Link>
</>
)}
{state === 'completed' && (
<>
<Link className="btn btn-secondary" to={`/study/${block.quiz_id}?restart=1`}>Sit again</Link>
{session?.last_attempt_id
? <Link className="btn btn-primary" to={`/sessions/${session.last_attempt_id}`}>Go to analysis</Link>
: <Link className="btn btn-primary" to={`/study/${block.quiz_id}`}>Open</Link>}
</>
)}
{state === 'not_started' && (
<Link className="btn btn-primary" to={`/study/${block.quiz_id}`}>Start</Link>
)}
</div>
</div>
</div>
)}
</section>
{user?.is_moderator && (
<p className="spb-edit-hint">
Reading and block titles are edited on the <Link to={`/study-plans/${plan.id}`}>plan page</Link>.
</p>
)}
</main>
<nav className="spb-footer" aria-label="Plan navigation">
<Link to={`/study-plans/${plan.id}`}>Back to study plan</Link>
{prev ? <Link to={`/study-plans/${plan.id}/blocks/${prev.id}`}> Previous block</Link> : <span />}
{next ? <Link to={`/study-plans/${plan.id}/blocks/${next.id}`}>Next block </Link> : <span />}
</nav>
</div>
)
}

View file

@ -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(
<MemoryRouter initialEntries={[`/study-plans/1/blocks/${blockId}`]}>
<Routes>
<Route path="/study-plans/:id/blocks/:blockId" element={<StudyPlanBlockPage />} />
<Route path="/study/:id" element={<h1>Test running</h1>} />
</Routes>
</MemoryRouter>)
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()
})
})

View file

@ -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() {
</div>
) : (
<>
<h2>{block.title}</h2>
<h2><Link to={`/study-plans/${plan.id}/blocks/${block.id}`}>{block.title}</Link></h2>
<span className="block-meta">
{block.question_count} question{block.question_count === 1 ? '' : 's'}
{block.completed && ' · done'}
@ -297,22 +287,21 @@ export default function StudyPlanPage() {
</div>
)}
{/* 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. */}
<div className="block-sessions">
<h3>Sessions</h3>
{block.quiz_id ? (
<Link className="btn btn-primary btn-sm" to={`/study/${block.quiz_id}`}>
{block.completed ? 'Review this block' : 'Continue this block'}
<div className="block-open">
<span className="block-meta">
{block.question_count === 0 ? 'No questions in this block yet.'
: block.completed ? 'Completed'
: block.quiz_id ? 'In progress' : 'Not started'}
</span>
<Link className="btn btn-primary btn-sm" to={`/study-plans/${plan.id}/blocks/${block.id}`}>
Open {block.title}
</Link>
) : block.question_count === 0 ? (
<p className="block-empty">No questions in this block yet.</p>
) : (
<div className="block-start">
<button className="btn btn-primary btn-sm" disabled={busy}
onClick={() => start(block, 'learning')}>Study mode</button>
<button className="btn btn-secondary btn-sm" disabled={busy}
onClick={() => start(block, 'timed')}>Exam mode</button>
</div>
)}
</div>
</div>
</li>
))}

View file

@ -83,16 +83,16 @@ describe('study plans', () => {
'/study-plans/reading/100/read', null, { params: { read: false } }))
})
it('offers both modes on a fresh block and continues one already started', async () => {
it('is a table of contents: every block opens its own page, nothing starts from here', async () => {
mountPlan()
const started = (await screen.findByText('Block 1')).closest('.block')
expect(within(started).getByRole('link', { name: 'Review this block' })).toHaveAttribute('href', '/study/77')
expect(within(started).getByRole('link', { name: 'Open Block 1' })).toHaveAttribute('href', '/study-plans/1/blocks/10')
expect(within(started).getByText('Completed')).toBeInTheDocument()
const fresh = screen.getByText('Block 2').closest('.block')
api.post.mockResolvedValue({ data: { id: 91 } })
await userEvent.click(within(fresh).getByRole('button', { name: 'Study mode' }))
await waitFor(() => expect(api.post).toHaveBeenCalledWith(
'/study-plans/blocks/11/start', null, { params: { mode: 'learning' } }))
expect(within(fresh).getByText('Not started')).toBeInTheDocument()
expect(within(fresh).queryByRole('button', { name: 'Study mode' })).not.toBeInTheDocument()
expect(api.post).not.toHaveBeenCalled()
})
it('says a block is empty rather than offering a test with nothing in it', async () => {

View file

@ -82,3 +82,7 @@
.block-admin { margin-left: 0; width: 100%; }
.block-start .btn { flex: 1; }
}
.block-head h2 a { color: var(--text); text-decoration: none; }
.block-head h2 a:hover { color: var(--primary); }
.block-open { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }