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:
parent
5a37c3d3f0
commit
ac92793600
17 changed files with 780 additions and 38 deletions
|
|
@ -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),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
73
backend/app/services/study_plan_context.py
Normal file
73
backend/app/services/study_plan_context.py
Normal 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()
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
164
backend/tests/test_study_plan_sessions.py
Normal file
164
backend/tests/test_study_plan_sessions.py
Normal 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()
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
97
frontend/src/pages/StudyPlanBlockPage.css
Normal file
97
frontend/src/pages/StudyPlanBlockPage.css
Normal 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; }
|
||||
}
|
||||
212
frontend/src/pages/StudyPlanBlockPage.jsx
Normal file
212
frontend/src/pages/StudyPlanBlockPage.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
123
frontend/src/pages/StudyPlanBlockPage.test.jsx
Normal file
123
frontend/src/pages/StudyPlanBlockPage.test.jsx
Normal 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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
Loading…
Reference in a new issue