From ac9279360091be5663e43f98cb5f6c476f8ff6f6 Mon Sep 17 00:00:00 2001 From: Daniel Date: Fri, 11 Sep 2026 04:31:21 +0200 Subject: [PATCH] feat: study-plan blocks as modules, sessions that know their block MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN --- backend/app/routers/attempts.py | 10 + backend/app/routers/quizzes.py | 3 + backend/app/routers/study_tools.py | 15 +- backend/app/services/study_plan_context.py | 73 ++++++ backend/scripts/seed_study_plans.py | 25 ++- backend/tests/test_study_plan_sessions.py | 164 ++++++++++++++ frontend/src/App.jsx | 2 + frontend/src/pages/AnalysisPage.css | 3 + frontend/src/pages/AnalysisPage.jsx | 10 +- frontend/src/pages/AnalysisSessionPage.css | 12 + frontend/src/pages/AnalysisSessionPage.jsx | 14 ++ frontend/src/pages/StudyPlanBlockPage.css | 97 ++++++++ frontend/src/pages/StudyPlanBlockPage.jsx | 212 ++++++++++++++++++ .../src/pages/StudyPlanBlockPage.test.jsx | 123 ++++++++++ frontend/src/pages/StudyPlanPage.jsx | 39 ++-- frontend/src/pages/StudyPlanPage.test.jsx | 12 +- frontend/src/pages/StudyPlansPage.css | 4 + 17 files changed, 780 insertions(+), 38 deletions(-) create mode 100644 backend/app/services/study_plan_context.py create mode 100644 backend/tests/test_study_plan_sessions.py create mode 100644 frontend/src/pages/StudyPlanBlockPage.css create mode 100644 frontend/src/pages/StudyPlanBlockPage.jsx create mode 100644 frontend/src/pages/StudyPlanBlockPage.test.jsx 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 ( +
+ + +
+ ‹ {plan.name} +

{block.title}

+ {error &&

{error}

} + + {block.articles.length > 0 && ( +
+

Articles {readCount}/{block.articles.length} read

+
    + {block.articles.map(link => ( +
  • + {link.title} + {link.status !== 'published' && draft} + +
  • + ))} +
+
+ )} + +
+
+

Sessions

+ {/* 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' ? ( +
+ {[['learning', 'Study mode'], ['timed', 'Exam mode']].map(([value, label]) => ( + + ))} +
+ ) : session && ( + {session.mode === 'learning' ? 'Study mode' : 'Exam mode'} + )} +
+ + {block.question_count === 0 ? ( +

No questions in this block yet.

+ ) : ( +
+
{sessionTitle}
+
+ +
+ {state === 'fresh' && ( + + )} + {state === 'in_progress' && ( + <> + {session?.last_attempt_id && ( + Go to analysis + )} + Resume + + )} + {state === 'completed' && ( + <> + Sit again + {session?.last_attempt_id + ? Go to analysis + : Open} + + )} + {state === 'not_started' && ( + Start + )} +
+
+
+ )} +
+ + {user?.is_moderator && ( +

+ 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'} +
+ + {block.question_count === 0 ? 'No questions in this block yet.' + : block.completed ? 'Completed' + : block.quiz_id ? 'In progress' : 'Not started'} + + + Open {block.title} - ) : block.question_count === 0 ? ( -

No questions in this block yet.

- ) : ( -
- - -
- )} +
))} diff --git a/frontend/src/pages/StudyPlanPage.test.jsx b/frontend/src/pages/StudyPlanPage.test.jsx index f5296c1..5f0e719 100644 --- a/frontend/src/pages/StudyPlanPage.test.jsx +++ b/frontend/src/pages/StudyPlanPage.test.jsx @@ -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 () => { diff --git a/frontend/src/pages/StudyPlansPage.css b/frontend/src/pages/StudyPlansPage.css index 92b35da..d649723 100644 --- a/frontend/src/pages/StudyPlansPage.css +++ b/frontend/src/pages/StudyPlansPage.css @@ -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; }