From 8a07703ee259a855f7c08305d96af7cfb6183b54 Mon Sep 17 00:00:00 2001 From: Daniel Date: Thu, 10 Sep 2026 12:10:39 +0200 Subject: [PATCH] feat: study plans you can open, work through, and edit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Thirteen plans were seeded with an API to serve them and nothing that called it, so the whole feature existed only in the database. Two pages and the editing endpoints it was missing. /study-plans lists the plans with progress stated in blocks — "3 of 6 blocks" is something you can act on, where "50%" only tells you how you feel about it. /study-plans/:id is one plan: each block shows Articles, then Sessions, in that order, because that is the order the block is meant to be done in. Reading is now part of a block (migration f4a5b6c7d8e9). "Mark as read" is the learner's own claim and reversible — someone who ticks the wrong row should be able to fix it without an educator, and progress nobody can correct stops being trusted and then stops being used. It is a separate table from `article_views` on purpose: opening an article is not the same claim as having finished with it. A draft article attached to a block is listed for the educator who can open it and left out for everyone else, rather than offered as a dead link. Editing is inline on the learner's own page rather than a separate builder, so the thing being changed and the thing a learner sees are the same object. Moderators create (as a draft — an empty plan is not something to put in front of anyone), rename, publish, delete; add, rename, reorder and remove blocks; move questions between blocks of one plan; attach reading found by searching rather than by id. Two places where the obvious implementation leaves the data wrong, both tested: deleting a block out of the middle shuffles the survivors down, or the next insert collides with a position nothing occupies; and reordering parks every row outside the range before writing the real positions, because (plan_id, position) is unique and the first move would otherwise collide with a position still held. A partial order is refused rather than half-applied. 166 backend, 188 frontend green. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01XeFQJXJTfHKTfbfsdxv57Z --- .../f4a5b6c7d8e9_study_plan_articles.py | 42 +++ backend/app/models/study_plan.py | 32 ++ backend/app/routers/study_plans.py | 286 +++++++++++++++- backend/tests/test_study_plan_editing.py | 182 ++++++++++ docs/TODO.md | 16 +- frontend/src/App.jsx | 4 + frontend/src/components/Navbar.jsx | 1 + frontend/src/pages/StudyPlanPage.jsx | 323 ++++++++++++++++++ frontend/src/pages/StudyPlanPage.test.jsx | 174 ++++++++++ frontend/src/pages/StudyPlansPage.css | 84 +++++ frontend/src/pages/StudyPlansPage.jsx | 116 +++++++ 11 files changed, 1250 insertions(+), 10 deletions(-) create mode 100644 backend/alembic/versions/f4a5b6c7d8e9_study_plan_articles.py create mode 100644 backend/tests/test_study_plan_editing.py create mode 100644 frontend/src/pages/StudyPlanPage.jsx create mode 100644 frontend/src/pages/StudyPlanPage.test.jsx create mode 100644 frontend/src/pages/StudyPlansPage.css create mode 100644 frontend/src/pages/StudyPlansPage.jsx diff --git a/backend/alembic/versions/f4a5b6c7d8e9_study_plan_articles.py b/backend/alembic/versions/f4a5b6c7d8e9_study_plan_articles.py new file mode 100644 index 0000000..3795739 --- /dev/null +++ b/backend/alembic/versions/f4a5b6c7d8e9_study_plan_articles.py @@ -0,0 +1,42 @@ +"""Reading attached to a study plan block, and who has finished it. + +Revision ID: f4a5b6c7d8e9 +Revises: e3f4a5b6c7d8 +""" +import sqlalchemy as sa +from alembic import op + +revision = "f4a5b6c7d8e9" +down_revision = "e3f4a5b6c7d8" +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table( + "study_plan_block_articles", + sa.Column("id", sa.Integer, primary_key=True), + sa.Column("block_id", sa.Integer, + sa.ForeignKey("study_plan_blocks.id", ondelete="CASCADE"), nullable=False, index=True), + sa.Column("article_id", sa.Integer, + sa.ForeignKey("articles.id", ondelete="CASCADE"), nullable=False, index=True), + sa.Column("position", sa.Integer, server_default="0"), + sa.UniqueConstraint("block_id", "article_id", name="uq_block_article"), + ) + # Opening an article is not the same claim as having finished with it, so + # this is its own table rather than a flag on article_views. + op.create_table( + "study_plan_article_reads", + sa.Column("id", sa.Integer, primary_key=True), + sa.Column("block_article_id", sa.Integer, + sa.ForeignKey("study_plan_block_articles.id", ondelete="CASCADE"), nullable=False, index=True), + sa.Column("user_id", sa.Integer, + sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True), + sa.Column("read_at", sa.DateTime, server_default=sa.func.now()), + sa.UniqueConstraint("block_article_id", "user_id", name="uq_block_article_read"), + ) + + +def downgrade(): + op.drop_table("study_plan_article_reads") + op.drop_table("study_plan_block_articles") diff --git a/backend/app/models/study_plan.py b/backend/app/models/study_plan.py index d3eb5a3..0102892 100644 --- a/backend/app/models/study_plan.py +++ b/backend/app/models/study_plan.py @@ -44,6 +44,38 @@ class StudyPlanBlock(Base): plan = relationship("StudyPlan", back_populates="blocks") +class StudyPlanBlockArticle(Base): + """Reading attached to a block, in the order it should be read. + + A block was questions only, which put the reading that prepares you for them + somewhere else entirely. This is the "read this, then sit this" pairing. + """ + + __tablename__ = "study_plan_block_articles" + __table_args__ = (UniqueConstraint("block_id", "article_id", name="uq_block_article"),) + + id = Column(Integer, primary_key=True, index=True) + block_id = Column(Integer, ForeignKey("study_plan_blocks.id", ondelete="CASCADE"), nullable=False, index=True) + article_id = Column(Integer, ForeignKey("articles.id", ondelete="CASCADE"), nullable=False, index=True) + position = Column(Integer, default=0) + + +class StudyPlanArticleRead(Base): + """One learner marking one of a block's articles as read. + + Deliberately separate from `article_views`: opening an article is not the + same claim as having finished with it, and the plan's progress is the second. + """ + + __tablename__ = "study_plan_article_reads" + __table_args__ = (UniqueConstraint("block_article_id", "user_id", name="uq_block_article_read"),) + + id = Column(Integer, primary_key=True, index=True) + block_article_id = Column(Integer, ForeignKey("study_plan_block_articles.id", ondelete="CASCADE"), nullable=False, index=True) + user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True) + read_at = Column(DateTime, default=datetime.utcnow) + + class StudyPlanBlockProgress(Base): """Which block a learner has started, and the quiz it produced.""" diff --git a/backend/app/routers/study_plans.py b/backend/app/routers/study_plans.py index f88b5bf..4246503 100644 --- a/backend/app/routers/study_plans.py +++ b/backend/app/routers/study_plans.py @@ -1,26 +1,58 @@ -"""Study plans — ordered blocks of questions a learner works through.""" +"""Study plans — ordered blocks of reading and questions a learner works through.""" import logging +import re from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel, Field from sqlalchemy.orm import Session from app.database import get_db +from app.models.article import Article from app.models.exam import Exam from app.models.question import Question -from app.models.study_plan import StudyPlan, StudyPlanBlock, StudyPlanBlockProgress +from app.models.study_plan import ( + StudyPlan, StudyPlanArticleRead, StudyPlanBlock, StudyPlanBlockArticle, + StudyPlanBlockProgress, +) from app.models.user import User from app.services.quiz_builder import GenerateTestRequest, bank_query, create_saved_test -from app.utils.auth import get_current_user +from app.utils.auth import get_current_user, require_moderator router = APIRouter() log = logging.getLogger(__name__) +def _reading_for(db: Session, user: User, block_ids: list[int]) -> dict[int, list[dict]]: + """Each block's reading, in order, with whether this learner has finished it.""" + if not block_ids: + return {} + rows = db.query(StudyPlanBlockArticle, Article).join( + Article, Article.id == StudyPlanBlockArticle.article_id).filter( + StudyPlanBlockArticle.block_id.in_(block_ids)).order_by( + StudyPlanBlockArticle.position, StudyPlanBlockArticle.id).all() + read = {row[0] for row in db.query(StudyPlanArticleRead.block_article_id).filter( + StudyPlanArticleRead.user_id == user.id).all()} + out: dict[int, list[dict]] = {} + for link, article in rows: + # A draft is still listed for the educator who can open it, and left out + # for everyone else rather than offered as a dead link. + if article.status != "published" and not user.is_moderator and article.user_id != user.id: + continue + out.setdefault(link.block_id, []).append({ + "link_id": link.id, "article_id": article.id, "slug": article.slug, + "title": article.title, "summary": article.summary, + "status": article.status, "read": link.id in read, + }) + return out + + @router.get("/") def list_study_plans(db: Session = Depends(get_db), current_user: User = Depends(get_current_user)): """Published plans, with how far this learner has got through each.""" - plans = db.query(StudyPlan).filter(StudyPlan.is_published == 1).order_by( - StudyPlan.sort_order, StudyPlan.name).all() + query = db.query(StudyPlan) + if not current_user.is_moderator: + query = query.filter(StudyPlan.is_published == 1) + plans = query.order_by(StudyPlan.sort_order, StudyPlan.name).all() if not plans: return [] exams = {e.id: e.name for e in db.query(Exam).all()} @@ -36,6 +68,7 @@ def list_study_plans(db: Session = Depends(get_db), current_user: User = Depends "id": plan.id, "slug": plan.slug, "name": plan.name, "description": plan.description, "kind": plan.kind, "exam_name": exams.get(plan.exam_id), + "is_published": bool(plan.is_published), "block_count": len(blocks), "question_count": sum(len(b.question_ids or []) for b in blocks), "blocks_completed": sum(1 for b in blocks if b.id in done), @@ -47,20 +80,24 @@ def list_study_plans(db: Session = Depends(get_db), current_user: User = Depends def get_study_plan(plan_id: int, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)): plan = db.get(StudyPlan, plan_id) - if not plan or not plan.is_published: + if not plan or (not plan.is_published and not current_user.is_moderator): raise HTTPException(404, "Study plan not found") progress = { row.block_id: row for row in db.query(StudyPlanBlockProgress).filter( StudyPlanBlockProgress.user_id == current_user.id).all() } + reading = _reading_for(db, current_user, [block.id for block in plan.blocks]) return { "id": plan.id, "slug": plan.slug, "name": plan.name, "description": plan.description, "kind": plan.kind, + "is_published": bool(plan.is_published), "blocks": [{ "id": block.id, "position": block.position, "title": block.title, "question_count": len(block.question_ids or []), "quiz_id": progress.get(block.id).quiz_id if block.id in progress else None, "completed": bool(progress.get(block.id) and progress[block.id].completed_at), + # Reading first, then the questions it prepares you for. + "articles": reading.get(block.id, []), } for block in plan.blocks], } @@ -107,3 +144,240 @@ def start_block(block_id: int, mode: str = "learning", db: Session = Depends(get row.quiz_id = created["id"] db.commit() return {**created, "reused": False} + + +@router.post("/reading/{link_id}/read") +def mark_reading(link_id: int, read: bool = True, db: Session = Depends(get_db), + current_user: User = Depends(get_current_user)): + """Mark one of a block's articles as read, or take that back. + + Reversible on purpose: a learner who ticks the wrong row should be able to + correct it without an educator, and progress nobody can correct stops being + trusted and then stops being used. + """ + link = db.get(StudyPlanBlockArticle, link_id) + if not link: + raise HTTPException(404, "That reading is not part of a block") + row = db.query(StudyPlanArticleRead).filter_by( + block_article_id=link_id, user_id=current_user.id).first() + if read and not row: + db.add(StudyPlanArticleRead(block_article_id=link_id, user_id=current_user.id)) + elif not read and row: + db.delete(row) + db.commit() + return {"link_id": link_id, "read": read} + + +# ── Editing, for moderators ─────────────────────────────────────────────────── + +SLUG_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$") + + +class PlanWrite(BaseModel): + name: str = Field(min_length=1, max_length=200) + slug: str = Field(min_length=1, max_length=120) + description: str | None = None + kind: str = "set" + exam_id: int | None = None + sort_order: int = 100 + is_published: bool = True + + +class PlanUpdate(BaseModel): + name: str | None = Field(default=None, min_length=1, max_length=200) + description: str | None = None + exam_id: int | None = None + sort_order: int | None = None + is_published: bool | None = None + + +def _get_plan(db: Session, plan_id: int) -> StudyPlan: + plan = db.get(StudyPlan, plan_id) + if not plan: + raise HTTPException(404, "Study plan not found") + return plan + + +def _get_block(db: Session, block_id: int) -> StudyPlanBlock: + block = db.get(StudyPlanBlock, block_id) + if not block: + raise HTTPException(404, "Block not found") + return block + + +@router.post("/", status_code=201) +def create_plan(data: PlanWrite, db: Session = Depends(get_db), + current_user: User = Depends(require_moderator)): + slug = data.slug.strip().lower() + if not SLUG_RE.match(slug): + raise HTTPException(400, "A slug is lowercase words joined by hyphens") + if db.query(StudyPlan.id).filter(StudyPlan.slug == slug).first(): + raise HTTPException(409, "A plan with that slug already exists") + if data.kind not in ("set", "mixed"): + raise HTTPException(400, "Kind must be set or mixed") + plan = StudyPlan(slug=slug, name=data.name.strip(), description=data.description, + kind=data.kind, exam_id=data.exam_id, sort_order=data.sort_order, + is_published=1 if data.is_published else 0) + db.add(plan) + db.commit() + return {"id": plan.id, "slug": plan.slug, "name": plan.name} + + +@router.patch("/{plan_id}") +def update_plan(plan_id: int, data: PlanUpdate, db: Session = Depends(get_db), + current_user: User = Depends(require_moderator)): + plan = _get_plan(db, plan_id) + values = data.model_dump(exclude_unset=True) + if "is_published" in values: + plan.is_published = 1 if values.pop("is_published") else 0 + for field, value in values.items(): + setattr(plan, field, value.strip() if isinstance(value, str) else value) + db.commit() + return {"id": plan.id} + + +@router.delete("/{plan_id}", status_code=204) +def delete_plan(plan_id: int, db: Session = Depends(get_db), + current_user: User = Depends(require_moderator)): + """Remove a plan and its blocks. Tests already generated from it survive. + + A learner part-way through keeps the quizzes they started; deleting a plan + is retiring a route through the bank, not confiscating anyone's work. + """ + db.delete(_get_plan(db, plan_id)) + db.commit() + + +class BlockWrite(BaseModel): + title: str = Field(min_length=1, max_length=200) + question_ids: list[int] = [] + + +@router.post("/{plan_id}/blocks", status_code=201) +def add_block(plan_id: int, data: BlockWrite, db: Session = Depends(get_db), + current_user: User = Depends(require_moderator)): + plan = _get_plan(db, plan_id) + position = max((b.position for b in plan.blocks), default=-1) + 1 + block = StudyPlanBlock(plan_id=plan.id, position=position, title=data.title.strip(), + question_ids=list(dict.fromkeys(data.question_ids))) + db.add(block) + db.commit() + return {"id": block.id, "position": block.position, "title": block.title} + + +class BlockUpdate(BaseModel): + title: str | None = Field(default=None, min_length=1, max_length=200) + question_ids: list[int] | None = None + + +@router.patch("/blocks/{block_id}") +def update_block(block_id: int, data: BlockUpdate, db: Session = Depends(get_db), + current_user: User = Depends(require_moderator)): + block = _get_block(db, block_id) + values = data.model_dump(exclude_unset=True) + if values.get("title"): + block.title = values["title"].strip() + if values.get("question_ids") is not None: + ids = list(dict.fromkeys(values["question_ids"])) + known = {qid for (qid,) in db.query(Question.id).filter(Question.id.in_(ids)).all()} + missing = [qid for qid in ids if qid not in known] + if missing: + raise HTTPException(400, f"No such question: {', '.join(str(q) for q in missing[:5])}") + block.question_ids = ids + db.commit() + return {"id": block.id, "question_count": len(block.question_ids or [])} + + +@router.delete("/blocks/{block_id}", status_code=204) +def delete_block(block_id: int, db: Session = Depends(get_db), + current_user: User = Depends(require_moderator)): + """Delete a block and close the gap it leaves in the numbering.""" + block = _get_block(db, block_id) + plan_id, position = block.plan_id, block.position + db.delete(block) + db.flush() + # Positions are unique per plan, so the survivors have to shuffle down or + # the next insert collides with a number nothing occupies. + for other in db.query(StudyPlanBlock).filter( + StudyPlanBlock.plan_id == plan_id, + StudyPlanBlock.position > position).order_by(StudyPlanBlock.position).all(): + other.position -= 1 + db.commit() + + +class BlockOrder(BaseModel): + block_ids: list[int] = Field(min_length=1) + + +@router.post("/{plan_id}/blocks/order") +def reorder_blocks(plan_id: int, data: BlockOrder, db: Session = Depends(get_db), + current_user: User = Depends(require_moderator)): + """Set the order of a plan's blocks in one go.""" + plan = _get_plan(db, plan_id) + blocks = {block.id: block for block in plan.blocks} + if set(data.block_ids) != set(blocks): + raise HTTPException(400, "List every block of this plan exactly once") + # Two passes through a unique (plan_id, position) constraint: park the rows + # out of range first, or the first move collides with a position still held. + for offset, block_id in enumerate(data.block_ids): + blocks[block_id].position = -1000 - offset + db.flush() + for position, block_id in enumerate(data.block_ids): + blocks[block_id].position = position + db.commit() + return {"plan_id": plan.id, "blocks": data.block_ids} + + +class MoveQuestions(BaseModel): + question_ids: list[int] = Field(min_length=1) + to_block_id: int + + +@router.post("/blocks/{block_id}/move") +def move_questions(block_id: int, data: MoveQuestions, db: Session = Depends(get_db), + current_user: User = Depends(require_moderator)): + """Move questions from one block to another within the same plan.""" + source = _get_block(db, block_id) + target = _get_block(db, data.to_block_id) + if source.id == target.id: + raise HTTPException(400, "Pick a different block to move into") + if source.plan_id != target.plan_id: + raise HTTPException(400, "Blocks belong to different plans") + moving = [qid for qid in data.question_ids if qid in (source.question_ids or [])] + if not moving: + raise HTTPException(400, "None of those questions are in this block") + source.question_ids = [qid for qid in (source.question_ids or []) if qid not in moving] + target.question_ids = list(dict.fromkeys([*(target.question_ids or []), *moving])) + db.commit() + return {"moved": len(moving), "from": source.id, "to": target.id} + + +class BlockArticleIn(BaseModel): + article_id: int + + +@router.post("/blocks/{block_id}/articles", status_code=201) +def attach_article(block_id: int, data: BlockArticleIn, db: Session = Depends(get_db), + current_user: User = Depends(require_moderator)): + """Attach reading to a block, appended after whatever is already there.""" + block = _get_block(db, block_id) + if not db.get(Article, data.article_id): + raise HTTPException(404, "Article not found") + if db.query(StudyPlanBlockArticle.id).filter_by( + block_id=block.id, article_id=data.article_id).first(): + raise HTTPException(409, "That article is already on this block") + position = (db.query(StudyPlanBlockArticle).filter_by(block_id=block.id).count()) + link = StudyPlanBlockArticle(block_id=block.id, article_id=data.article_id, position=position) + db.add(link) + db.commit() + return {"link_id": link.id, "block_id": block.id, "article_id": data.article_id} + + +@router.delete("/reading/{link_id}", status_code=204) +def detach_article(link_id: int, db: Session = Depends(get_db), + current_user: User = Depends(require_moderator)): + link = db.get(StudyPlanBlockArticle, link_id) + if not link: + raise HTTPException(404, "That reading is not part of a block") + db.delete(link) + db.commit() diff --git a/backend/tests/test_study_plan_editing.py b/backend/tests/test_study_plan_editing.py new file mode 100644 index 0000000..204fb87 --- /dev/null +++ b/backend/tests/test_study_plan_editing.py @@ -0,0 +1,182 @@ +"""Editing study plans, and the reading attached to their blocks. + +Disposable SQLite; no network or AI. The interesting cases are the ones where a +naive implementation leaves the data inconsistent: deleting a block out of the +middle, reordering through a unique constraint, and moving questions between +blocks of different plans. +""" +import unittest + +import test_quiz_builder as fixtures +from app.models.article import Article +from app.models.study_plan import ( + StudyPlan, StudyPlanArticleRead, StudyPlanBlock, StudyPlanBlockArticle, +) +from app.routers import study_plans + + +class StudyPlanEditingTests(unittest.TestCase): + def setUp(self): + self.bank = fixtures.BuilderTests() + self.bank.setUp() + self.client = self.bank.client + self.client.app.include_router(study_plans.router, prefix='/study-plans') + self.db = self.bank.db + + self.db.add(StudyPlan(id=1, slug='prep-2025', name='PREP 2025', kind='set', is_published=1)) + self.db.add(StudyPlan(id=2, slug='other-plan', name='Other', kind='set', is_published=1)) + self.db.flush() + for position, title, qids in [(0, 'Block 1', [1, 2]), (1, 'Block 2', [5]), (2, 'Block 3', [])]: + self.db.add(StudyPlanBlock(plan_id=1, position=position, title=title, question_ids=qids)) + self.db.add(StudyPlanBlock(id=90, plan_id=2, position=0, title='Elsewhere', question_ids=[6])) + self.db.add(Article(id=1, slug='asthma', title='Asthma', status='published', + sections=[], user_id=3)) + self.db.add(Article(id=2, slug='draft-note', title='Draft note', status='draft', + sections=[], user_id=3)) + self.db.commit() + self.blocks = {b.title: b.id for b in self.db.query(StudyPlanBlock).all()} + self.bank.user = self.bank.mod + + def tearDown(self): + self.bank.tearDown() + + def positions(self, plan_id=1): + return [(b.title, b.position) for b in self.db.query(StudyPlanBlock).filter_by( + plan_id=plan_id).order_by(StudyPlanBlock.position).all()] + + # ── blocks ──────────────────────────────────────────────────────────────── + + def test_deleting_a_block_closes_the_gap_it_leaves(self): + response = self.client.delete(f"/study-plans/blocks/{self.blocks['Block 2']}") + self.assertEqual(response.status_code, 204, response.text) + self.db.expire_all() + # Positions are unique per plan, so a hole would collide with the next insert. + self.assertEqual(self.positions(), [('Block 1', 0), ('Block 3', 1)]) + self.assertEqual(self.client.post('/study-plans/1/blocks', + json={'title': 'Block 4'}).status_code, 201) + + def test_reordering_survives_the_unique_position_constraint(self): + order = [self.blocks['Block 3'], self.blocks['Block 1'], self.blocks['Block 2']] + response = self.client.post('/study-plans/1/blocks/order', json={'block_ids': order}) + self.assertEqual(response.status_code, 200, response.text) + self.db.expire_all() + self.assertEqual(self.positions(), [('Block 3', 0), ('Block 1', 1), ('Block 2', 2)]) + + def test_a_partial_order_is_refused_rather_than_half_applied(self): + response = self.client.post('/study-plans/1/blocks/order', + json={'block_ids': [self.blocks['Block 1']]}) + self.assertEqual(response.status_code, 400) + self.db.expire_all() + self.assertEqual(self.positions(), [('Block 1', 0), ('Block 2', 1), ('Block 3', 2)]) + + def test_questions_move_between_blocks_of_the_same_plan_only(self): + source, target = self.blocks['Block 1'], self.blocks['Block 2'] + response = self.client.post(f'/study-plans/blocks/{source}/move', + json={'question_ids': [1], 'to_block_id': target}) + self.assertEqual(response.status_code, 200, response.text) + self.db.expire_all() + self.assertEqual(self.db.get(StudyPlanBlock, source).question_ids, [2]) + self.assertEqual(self.db.get(StudyPlanBlock, target).question_ids, [5, 1]) + + # A different plan is a different route through the bank. + self.assertEqual(self.client.post(f'/study-plans/blocks/{source}/move', + json={'question_ids': [2], 'to_block_id': 90}).status_code, 400) + + def test_a_block_only_holds_questions_that_exist(self): + block = self.blocks['Block 3'] + self.assertEqual(self.client.patch(f'/study-plans/blocks/{block}', + json={'question_ids': [1, 9999]}).status_code, 400) + self.db.expire_all() + self.assertEqual(self.db.get(StudyPlanBlock, block).question_ids, []) + + # ── reading ─────────────────────────────────────────────────────────────── + + def test_reading_is_attached_once_and_appears_on_the_block(self): + block = self.blocks['Block 1'] + link_id = self.client.post(f'/study-plans/blocks/{block}/articles', + json={'article_id': 1}).json()['link_id'] + self.assertEqual(self.client.post(f'/study-plans/blocks/{block}/articles', + json={'article_id': 1}).status_code, 409) + plan = self.client.get('/study-plans/1').json() + first = next(b for b in plan['blocks'] if b['id'] == block) + self.assertEqual([a['title'] for a in first['articles']], ['Asthma']) + self.assertFalse(first['articles'][0]['read']) + self.assertEqual(first['articles'][0]['link_id'], link_id) + + def test_marking_read_is_per_learner_and_reversible(self): + block = self.blocks['Block 1'] + link_id = self.client.post(f'/study-plans/blocks/{block}/articles', + json={'article_id': 1}).json()['link_id'] + + self.bank.user = self.bank.owner + self.client.post(f'/study-plans/reading/{link_id}/read', params={'read': True}) + read_by_owner = next(a for b in self.client.get('/study-plans/1').json()['blocks'] + for a in b['articles']) + self.assertTrue(read_by_owner['read']) + + # Another learner's progress is their own. + self.bank.user = self.bank.peer + self.assertFalse(next(a for b in self.client.get('/study-plans/1').json()['blocks'] + for a in b['articles'])['read']) + + self.bank.user = self.bank.owner + self.client.post(f'/study-plans/reading/{link_id}/read', params={'read': False}) + self.assertFalse(next(a for b in self.client.get('/study-plans/1').json()['blocks'] + for a in b['articles'])['read']) + self.assertEqual(self.db.query(StudyPlanArticleRead).count(), 0) + + def test_a_draft_article_is_not_offered_to_a_learner_as_a_dead_link(self): + block = self.blocks['Block 1'] + self.client.post(f'/study-plans/blocks/{block}/articles', json={'article_id': 2}) + self.bank.user = self.bank.owner + titles = [a['title'] for b in self.client.get('/study-plans/1').json()['blocks'] + for a in b['articles']] + self.assertEqual(titles, []) + self.bank.user = self.bank.mod + titles = [a['title'] for b in self.client.get('/study-plans/1').json()['blocks'] + for a in b['articles']] + self.assertEqual(titles, ['Draft note']) + + def test_detaching_reading_takes_the_progress_with_it(self): + block = self.blocks['Block 1'] + link_id = self.client.post(f'/study-plans/blocks/{block}/articles', + json={'article_id': 1}).json()['link_id'] + self.bank.user = self.bank.owner + self.client.post(f'/study-plans/reading/{link_id}/read', params={'read': True}) + self.bank.user = self.bank.mod + self.assertEqual(self.client.delete(f'/study-plans/reading/{link_id}').status_code, 204) + self.assertEqual(self.db.query(StudyPlanBlockArticle).count(), 0) + self.assertEqual(self.db.query(StudyPlanArticleRead).count(), 0) + + # ── plans and permissions ──────────────────────────────────────────────── + + def test_an_unpublished_plan_is_the_educators_alone(self): + self.client.patch('/study-plans/1', json={'is_published': False}) + self.bank.user = self.bank.owner + self.assertEqual([p['id'] for p in self.client.get('/study-plans/').json()], [2]) + self.assertEqual(self.client.get('/study-plans/1').status_code, 404) + self.bank.user = self.bank.mod + self.assertIn(1, [p['id'] for p in self.client.get('/study-plans/').json()]) + self.assertEqual(self.client.get('/study-plans/1').status_code, 200) + + def test_editing_is_moderator_only(self): + self.bank.user = self.bank.owner + block = self.blocks['Block 1'] + for call in [ + lambda: self.client.post('/study-plans/', json={'name': 'X', 'slug': 'x'}), + lambda: self.client.patch('/study-plans/1', json={'name': 'X'}), + lambda: self.client.delete('/study-plans/1'), + lambda: self.client.post('/study-plans/1/blocks', json={'title': 'B'}), + lambda: self.client.patch(f'/study-plans/blocks/{block}', json={'title': 'B'}), + lambda: self.client.delete(f'/study-plans/blocks/{block}'), + lambda: self.client.post(f'/study-plans/blocks/{block}/articles', json={'article_id': 1}), + ]: + self.assertEqual(call().status_code, 403) + + def test_a_slug_has_to_be_a_slug_and_has_to_be_free(self): + self.assertEqual(self.client.post('/study-plans/', json={ + 'name': 'New', 'slug': 'Not A Slug'}).status_code, 400) + self.assertEqual(self.client.post('/study-plans/', json={ + 'name': 'New', 'slug': 'prep-2025'}).status_code, 409) + self.assertEqual(self.client.post('/study-plans/', json={ + 'name': 'New', 'slug': 'prep-2026'}).status_code, 201) diff --git a/docs/TODO.md b/docs/TODO.md index 28fefbd..889d17d 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -42,10 +42,18 @@ Updated 2026-09-10. ## Content and editing -- [ ] **Admin can edit everything** — study plans (rename, reorder, add/remove - blocks, move questions between blocks) and attach articles to a block. -- [ ] **Study plan blocks carry articles**, not only questions: "Articles" with - *Mark as read*, then "Sessions" with Study/Exam mode. +- [x] **Study plans have a front end at all** — done 2026-09-10. 13 plans were + seeded with an API to serve them and no page that called it. `/study-plans` + lists them with progress in blocks; `/study-plans/:id` is one plan. +- [x] **Admin can edit study plans** — done 2026-09-10. Create (as a draft), + rename, publish/unpublish, delete; add, rename, reorder and remove blocks; + move questions between blocks of the same plan; attach and detach reading. + Editing is inline on the learner's own page, so there is no second layout + to keep in step. +- [x] **Study plan blocks carry articles** — done 2026-09-10. Each block shows + Articles with a reversible *Mark as read*, then Sessions with Study and + Exam mode. Reading progress is per learner and separate from + `article_views`: opening an article is not the claim that you finished it. - [ ] **Admin settings page revamp** — currently ugly; needs restructuring. - [x] **Image libraries** — done 2026-09-10. Libraries, per-library grants, tags on the shared vocabulary, and MinIO behind a storage service. diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 11b5f2e..e3a59b1 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -33,6 +33,8 @@ const FlashcardsPage = lazy(() => import('./pages/FlashcardsPage')) const ArticlesPage = lazy(() => import('./pages/ArticlesPage')) const SearchPage = lazy(() => import('./pages/SearchPage')) const MediaPage = lazy(() => import('./pages/MediaPage')) +const StudyPlansPage = lazy(() => import('./pages/StudyPlansPage')) +const StudyPlanPage = lazy(() => import('./pages/StudyPlanPage')) const ArticlePage = lazy(() => import('./pages/ArticlesPage').then(m => ({ default: m.ArticlePage }))) const PublicQuizPage = lazy(() => import('./pages/PublicQuizPage')) const FlashcardStudyPage = lazy(() => import('./pages/FlashcardStudyPage')) @@ -103,6 +105,8 @@ function AppRoutes() { } /> } /> } /> + } /> + } /> } /> } /> {/* Cross-references in article prose address a topic by slug, which diff --git a/frontend/src/components/Navbar.jsx b/frontend/src/components/Navbar.jsx index 482308c..4afb168 100644 --- a/frontend/src/components/Navbar.jsx +++ b/frontend/src/components/Navbar.jsx @@ -107,6 +107,7 @@ export default function Navbar({ onSignIn, onRegister }) { { to: '/question-bank', label: 'Question Bank' }, ...(canManageQuestions ? [{ to: '/questions/manage', label: 'Manage Qs' }, { to: '/media', label: 'Images' }] : []), + { to: '/study-plans', label: 'Study plans' }, { to: '/articles', label: 'Reading' }, { to: '/flashcards', label: 'Cards' }, { to: '/courses', label: 'Courses' }, diff --git a/frontend/src/pages/StudyPlanPage.jsx b/frontend/src/pages/StudyPlanPage.jsx new file mode 100644 index 0000000..f946303 --- /dev/null +++ b/frontend/src/pages/StudyPlanPage.jsx @@ -0,0 +1,323 @@ +import { useCallback, useEffect, useState } from 'react' +import { Link, useNavigate, useParams } from 'react-router-dom' +import api from '../api/client' +import { useAuth } from '../context/AuthContext' +import './StudyPlansPage.css' + +const apiError = (err, fallback) => { + const detail = err?.response?.data?.detail + if (typeof detail === 'string') return detail + if (Array.isArray(detail)) return detail.map(d => d?.msg).filter(Boolean).join('; ') || fallback + return fallback +} + +/** + * One plan: its blocks, each with the reading that prepares you for it. + * + * Reading sits above the questions in every block because that is the order it + * is meant to be done in, and "Mark as read" is the learner's own claim — the + * plan does not decide you have read something because you opened it. + * + * Editing is inline for moderators rather than a separate builder screen: the + * thing you are changing and the thing a learner sees are then the same object, + * and there is no second layout to keep in step. + */ +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('') + const [busy, setBusy] = useState(false) + const [editing, setEditing] = useState(false) + const [draftName, setDraftName] = useState('') + const [blockTitle, setBlockTitle] = useState('') + const [addingBlock, setAddingBlock] = useState(false) + const [editingBlock, setEditingBlock] = useState(null) + const [blockDraft, setBlockDraft] = useState('') + const [attachTo, setAttachTo] = useState(null) + const [articleQuery, setArticleQuery] = useState('') + const [articleHits, setArticleHits] = useState([]) + + const canEdit = !!user?.is_moderator + + const load = useCallback(() => { + setLoading(true) + api.get(`/study-plans/${id}`) + .then(res => { setPlan(res.data); setDraftName(res.data.name) }) + .catch(err => setError(apiError(err, 'Could not load this plan'))) + .finally(() => setLoading(false)) + }, [id]) + + 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(`/quizzes/${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. + setPlan(prev => ({ + ...prev, + blocks: prev.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() } + } + + const savePlan = async () => { + setBusy(true); setError('') + try { + await api.patch(`/study-plans/${id}`, { name: draftName.trim() }) + setEditing(false); load() + } catch (err) { setError(apiError(err, 'Could not save the plan')) } + finally { setBusy(false) } + } + + const setPublished = async (published) => { + setBusy(true); setError('') + try { + await api.patch(`/study-plans/${id}`, { is_published: published }) + load() + } catch (err) { setError(apiError(err, 'Could not change that')) } + finally { setBusy(false) } + } + + const addBlock = async () => { + if (!blockTitle.trim()) return + setBusy(true); setError('') + try { + await api.post(`/study-plans/${id}/blocks`, { title: blockTitle.trim(), question_ids: [] }) + setBlockTitle(''); setAddingBlock(false); load() + } catch (err) { setError(apiError(err, 'Could not add that block')) } + finally { setBusy(false) } + } + + const renameBlock = async (block) => { + setBusy(true); setError('') + try { + await api.patch(`/study-plans/blocks/${block.id}`, { title: blockDraft.trim() }) + setEditingBlock(null); load() + } catch (err) { setError(apiError(err, 'Could not rename that block')) } + finally { setBusy(false) } + } + + const removeBlock = async (block) => { + setBusy(true); setError('') + try { + await api.delete(`/study-plans/blocks/${block.id}`) + load() + } catch (err) { setError(apiError(err, 'Could not remove that block')) } + finally { setBusy(false) } + } + + const moveBlock = async (index, delta) => { + const order = plan.blocks.map(b => b.id) + const target = index + delta + if (target < 0 || target >= order.length) return + ;[order[index], order[target]] = [order[target], order[index]] + setBusy(true); setError('') + try { + await api.post(`/study-plans/${id}/blocks/order`, { block_ids: order }) + load() + } catch (err) { setError(apiError(err, 'Could not reorder the blocks')) } + finally { setBusy(false) } + } + + const searchArticles = async (text) => { + setArticleQuery(text) + if (text.trim().length < 2) { setArticleHits([]); return } + try { + const res = await api.get('/articles/', { params: { q: text.trim() } }) + setArticleHits((res.data || []).slice(0, 8)) + } catch { setArticleHits([]) } + } + + const attachArticle = async (block, article) => { + setBusy(true); setError('') + try { + await api.post(`/study-plans/blocks/${block.id}/articles`, { article_id: article.id }) + setAttachTo(null); setArticleQuery(''); setArticleHits([]); load() + } catch (err) { setError(apiError(err, 'Could not attach that article')) } + finally { setBusy(false) } + } + + const detachArticle = async (link) => { + setBusy(true); setError('') + try { + await api.delete(`/study-plans/reading/${link.link_id}`) + load() + } catch (err) { setError(apiError(err, 'Could not remove that reading')) } + finally { setBusy(false) } + } + + if (loading) return
+ if (!plan) return
{error || 'Plan not found.'}
+ + return ( +
+ + +
+
+ {editing ? ( +
+ setDraftName(e.target.value)} + onKeyDown={e => { if (e.key === 'Enter') savePlan() }} /> + + +
+ ) : ( +

{plan.name} {!plan.is_published && draft}

+ )} + {plan.description &&

{plan.description}

} +
+ {canEdit && !editing && ( +
+ + + +
+ )} +
+ + {addingBlock && ( +
+ setBlockTitle(e.target.value)} + onKeyDown={e => { if (e.key === 'Enter') addBlock() }} /> + + +
+ )} + + {error &&

{error}

} + + {plan.blocks.length === 0 ? ( +
This plan has no blocks yet.
+ ) : ( +
    + {plan.blocks.map((block, index) => ( +
  1. +
    + {editingBlock === block.id ? ( +
    + setBlockDraft(e.target.value)} + onKeyDown={e => { if (e.key === 'Enter') renameBlock(block) }} /> + + +
    + ) : ( + <> +

    {block.title}

    + + {block.question_count} question{block.question_count === 1 ? '' : 's'} + {block.completed && ' · done'} + + {canEdit && ( + + + + + + + )} + + )} +
    + + {/* Reading first: it is the order the block is meant to be done in. */} + {(block.articles.length > 0 || canEdit) && ( +
    +

    Articles

    + {block.articles.length === 0 &&

    No reading attached.

    } +
      + {block.articles.map(link => ( +
    • + + {canEdit && ( + + )} +
    • + ))} +
    + {canEdit && (attachTo === block.id ? ( +
    + searchArticles(e.target.value)} /> + {articleHits.length > 0 && ( +
      + {articleHits.map(article => ( +
    • + +
    • + ))} +
    + )} + +
    + ) : ( + + ))} +
    + )} + +
    +

    Sessions

    + {block.quiz_id ? ( + + {block.completed ? 'Review this block' : 'Continue this block'} + + ) : block.question_count === 0 ? ( +

    No questions in this block yet.

    + ) : ( +
    + + +
    + )} +
    +
  2. + ))} +
+ )} +
+ ) +} diff --git a/frontend/src/pages/StudyPlanPage.test.jsx b/frontend/src/pages/StudyPlanPage.test.jsx new file mode 100644 index 0000000..a2ffb8b --- /dev/null +++ b/frontend/src/pages/StudyPlanPage.test.jsx @@ -0,0 +1,174 @@ +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 StudyPlansPage from './StudyPlansPage' +import StudyPlanPage from './StudyPlanPage' +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 plans = [ + { id: 1, slug: 'prep-2025', name: 'PREP 2025', kind: 'set', exam_name: 'Pediatrics Boards', + is_published: true, block_count: 4, question_count: 200, blocks_completed: 1 }, + { id: 13, slug: 'prep-mixed', name: 'PREP Mixed', kind: 'mixed', exam_name: null, + is_published: true, block_count: 1, question_count: 300, blocks_completed: 0 }, +] + +const plan = { + id: 1, slug: 'prep-2025', name: 'PREP 2025', description: null, kind: 'set', is_published: true, + blocks: [ + { id: 10, position: 0, title: 'Block 1', question_count: 50, quiz_id: 77, completed: true, + articles: [{ link_id: 100, article_id: 5, slug: 'asthma', title: 'Asthma', status: 'published', read: true }] }, + { id: 11, position: 1, title: 'Block 2', question_count: 50, quiz_id: null, completed: false, + articles: [{ link_id: 101, article_id: 6, slug: 'croup', title: 'Croup', status: 'published', read: false }] }, + { id: 12, position: 2, title: 'Block 3', question_count: 0, quiz_id: null, completed: false, articles: [] }, + ], +} + +const mockApi = (detail = plan) => api.get.mockImplementation(url => { + if (url === '/study-plans/') return Promise.resolve({ data: plans }) + if (url === '/study-plans/1') return Promise.resolve({ data: detail }) + return Promise.resolve({ data: [] }) +}) + +const mountList = () => render( + + } /> + ) + +const mountPlan = () => render( + + } /> + ) + +describe('study plans', () => { + beforeEach(() => { + vi.clearAllMocks() + currentUser = { id: 1, name: 'Learner', is_moderator: false } + mockApi() + }) + + it('states progress in blocks, which is something you can act on', async () => { + mountList() + const card = (await screen.findByText('PREP 2025')).closest('.plan-card') + expect(within(card).getByText('1 of 4 blocks done')).toBeInTheDocument() + expect(within(card).getByText(/4 blocks · 200 questions/)).toBeInTheDocument() + }) + + it('puts reading above the questions it prepares you for', async () => { + mountPlan() + const block = (await screen.findByText('Block 2')).closest('.block') + const headings = [...block.querySelectorAll('h3')].map(h => h.textContent) + expect(headings).toEqual(['Articles', 'Sessions']) + }) + + it('marks reading as read, and lets that be taken back', async () => { + mountPlan() + await screen.findByText('Block 2') + 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() + + // Reversible: a learner who ticks the wrong row fixes it themselves. + await userEvent.click(screen.getByRole('checkbox', { name: 'Mark Asthma as read' })) + await waitFor(() => expect(api.post).toHaveBeenCalledWith( + '/study-plans/reading/100/read', null, { params: { read: false } })) + }) + + it('offers both modes on a fresh block and continues one already started', async () => { + mountPlan() + const started = (await screen.findByText('Block 1')).closest('.block') + expect(within(started).getByRole('link', { name: 'Review this block' })).toHaveAttribute('href', '/quizzes/77') + + 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' } })) + }) + + it('says a block is empty rather than offering a test with nothing in it', async () => { + mountPlan() + const empty = (await screen.findByText('Block 3')).closest('.block') + expect(within(empty).getByText('No questions in this block yet.')).toBeInTheDocument() + expect(within(empty).queryByRole('button', { name: 'Study mode' })).not.toBeInTheDocument() + }) + + it('keeps editing out of a learner\'s way', async () => { + mountPlan() + await screen.findByText('Block 1') + expect(screen.queryByRole('button', { name: 'Add block' })).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Rename Block 1' })).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: '+ Add reading' })).not.toBeInTheDocument() + }) +}) + +describe('study plans, as an educator', () => { + beforeEach(() => { + vi.clearAllMocks() + currentUser = { id: 9, name: 'Mod', is_moderator: true } + mockApi() + }) + + it('creates a plan as a draft, because an empty plan is not for a learner', async () => { + mountList() + await screen.findByText('PREP 2025') + api.post.mockResolvedValue({ data: { id: 20 } }) + await userEvent.click(screen.getByRole('button', { name: 'New plan' })) + await userEvent.type(screen.getByLabelText('New plan name'), 'PREP 2026') + await userEvent.click(screen.getByRole('button', { name: 'Create' })) + await waitFor(() => expect(api.post).toHaveBeenCalledWith('/study-plans/', { + name: 'PREP 2026', slug: 'prep-2026', kind: 'set', is_published: false, + })) + }) + + it('reorders blocks by sending the whole order', async () => { + mountPlan() + await screen.findByText('Block 1') + api.post.mockResolvedValue({ data: {} }) + await userEvent.click(screen.getByRole('button', { name: 'Move Block 2 up' })) + await waitFor(() => expect(api.post).toHaveBeenCalledWith( + '/study-plans/1/blocks/order', { block_ids: [11, 10, 12] })) + }) + + it('cannot move the first block up or the last block down', async () => { + mountPlan() + await screen.findByText('Block 1') + expect(screen.getByRole('button', { name: 'Move Block 1 up' })).toBeDisabled() + expect(screen.getByRole('button', { name: 'Move Block 3 down' })).toBeDisabled() + }) + + it('attaches reading found by searching, not by id', async () => { + mountPlan() + await screen.findByText('Block 3') + api.get.mockImplementation(url => { + if (url === '/study-plans/1') return Promise.resolve({ data: plan }) + if (url === '/articles/') return Promise.resolve({ data: [{ id: 8, title: 'Bronchiolitis' }] }) + return Promise.resolve({ data: [] }) + }) + api.post.mockResolvedValue({ data: {} }) + + const block = screen.getByText('Block 3').closest('.block') + await userEvent.click(within(block).getByRole('button', { name: '+ Add reading' })) + await userEvent.type(screen.getByLabelText('Search articles to add to Block 3'), 'bronch') + await userEvent.click(await screen.findByRole('button', { name: 'Bronchiolitis' })) + await waitFor(() => expect(api.post).toHaveBeenCalledWith( + '/study-plans/blocks/12/articles', { article_id: 8 })) + }) + + it('publishes and unpublishes a plan', async () => { + mountPlan() + await screen.findByText('Block 1') + api.patch.mockResolvedValue({ data: {} }) + await userEvent.click(screen.getByRole('button', { name: 'Unpublish' })) + await waitFor(() => expect(api.patch).toHaveBeenCalledWith('/study-plans/1', { is_published: false })) + }) +}) diff --git a/frontend/src/pages/StudyPlansPage.css b/frontend/src/pages/StudyPlansPage.css new file mode 100644 index 0000000..92b35da --- /dev/null +++ b/frontend/src/pages/StudyPlansPage.css @@ -0,0 +1,84 @@ +/* Study plans: the list, and one plan's blocks. */ + +.plans-page { max-width: 860px; margin: 0 auto; padding-bottom: 48px; } +.plans-header { display: flex; justify-content: space-between; align-items: flex-end; gap: 12px; flex-wrap: wrap; margin-bottom: 16px; } +.plans-header h1 { margin: 0 0 4px; font-size: 1.35rem; } +.plans-header p { margin: 0; color: var(--text-muted); font-size: 0.87rem; } +.plans-header-actions { display: flex; gap: 8px; flex-wrap: wrap; } + +.plans-create { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; margin-bottom: 14px; } +.plans-create input { + flex: 1; min-width: 180px; padding: 8px 12px; border: 1px solid var(--border); + border-radius: 8px; background: var(--input-bg); color: var(--text); font-size: 0.88rem; +} +.plans-hint { font-size: 0.78rem; color: var(--text-muted); } +.plans-error { color: var(--wrong-fg); font-size: 0.85rem; } +.plans-empty { background: var(--card-bg); border: 1px solid var(--border); border-radius: 12px; padding: 32px; text-align: center; color: var(--text-muted); } + +.plans-grid { list-style: none; margin: 0; padding: 0; display: grid; gap: 12px; grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); } +.plan-card { + display: flex; flex-direction: column; gap: 6px; height: 100%; padding: 14px 16px; + background: var(--card-bg); border: 1px solid var(--border); border-radius: 12px; + text-decoration: none; color: inherit; +} +.plan-card:hover { border-color: var(--primary); } +.plan-card-head { display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap; } +.plan-card-head h2 { margin: 0; font-size: 1rem; } +.plan-card-desc { margin: 0; font-size: 0.83rem; color: var(--text-muted); } +.plan-card-meta { margin: 0; font-size: 0.78rem; color: var(--text-subtle); } +.plan-tag { + font-size: 0.63rem; font-weight: 700; letter-spacing: 0.05em; text-transform: uppercase; + padding: 1px 7px; border-radius: 10px; background: var(--option-sel-bg); color: var(--primary); +} +.plan-tag.is-draft { background: #fef3c7; color: #92400e; } + +.plan-progress { margin-top: auto; padding-top: 8px; } +.plan-progress-bar { height: 5px; border-radius: 3px; background: var(--border); overflow: hidden; } +.plan-progress-bar span { display: block; height: 100%; background: var(--primary); } +/* Blocks, not a percentage: "3 of 6 blocks" is something you can act on. */ +.plan-progress-text { display: block; margin-top: 4px; font-size: 0.75rem; color: var(--text-subtle); } + +.block-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 12px; } +.block { background: var(--card-bg); border: 1px solid var(--border); border-radius: 12px; padding: 14px 16px; } +.block.is-done { border-left: 3px solid var(--primary); } +.block-head { display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; margin-bottom: 10px; } +.block-head h2 { margin: 0; font-size: 1rem; } +.block-meta { font-size: 0.78rem; color: var(--text-subtle); } +.block-admin { margin-left: auto; display: flex; gap: 5px; flex-wrap: wrap; } + +.block-reading, .block-sessions { padding-top: 10px; border-top: 1px solid var(--border); margin-top: 10px; } +.block-reading h3, .block-sessions h3 { + margin: 0 0 6px; font-size: 0.7rem; font-weight: 700; letter-spacing: 0.06em; + text-transform: uppercase; color: var(--text-subtle); +} +.block-reading ul { list-style: none; margin: 0 0 8px; padding: 0; display: flex; flex-direction: column; gap: 4px; } +.block-reading li { display: flex; align-items: center; gap: 8px; min-height: 36px; } +.block-read { display: flex; align-items: center; gap: 8px; flex: 1; min-width: 0; cursor: pointer; font-size: 0.88rem; } +.block-read input { width: 17px; height: 17px; flex-shrink: 0; } +.block-read a { color: var(--text); text-decoration: none; overflow-wrap: anywhere; } +.block-read a:hover { color: var(--primary); } +.block-read input:checked ~ a { color: var(--text-muted); text-decoration: line-through; } +.block-empty { margin: 0 0 8px; font-size: 0.83rem; color: var(--text-muted); } + +.block-attach { display: flex; flex-direction: column; gap: 6px; margin-top: 6px; } +.block-attach input { + padding: 7px 11px; border: 1px solid var(--border); border-radius: 8px; + background: var(--input-bg); color: var(--text); font-size: 0.86rem; +} +.block-attach-hits { list-style: none; margin: 0; padding: 0; border: 1px solid var(--border); border-radius: 8px; overflow: hidden; } +.block-attach-hits button { + width: 100%; min-height: 38px; padding: 8px 11px; background: none; border: 0; + border-bottom: 1px solid var(--border); font: inherit; font-size: 0.85rem; + text-align: left; color: var(--text); cursor: pointer; +} +.block-attach-hits li:last-child button { border-bottom: 0; } +.block-attach-hits button:hover { background: var(--bg); color: var(--primary); } + +.block-start { display: flex; gap: 8px; flex-wrap: wrap; } + +@media (max-width: 640px) { + .plans-header-actions { width: 100%; } + .plans-header-actions .btn { flex: 1; } + .block-admin { margin-left: 0; width: 100%; } + .block-start .btn { flex: 1; } +} diff --git a/frontend/src/pages/StudyPlansPage.jsx b/frontend/src/pages/StudyPlansPage.jsx new file mode 100644 index 0000000..a474b5e --- /dev/null +++ b/frontend/src/pages/StudyPlansPage.jsx @@ -0,0 +1,116 @@ +import { useCallback, useEffect, useState } from 'react' +import { Link } from 'react-router-dom' +import api from '../api/client' +import { useAuth } from '../context/AuthContext' +import './StudyPlansPage.css' + +const apiError = (err, fallback) => { + const detail = err?.response?.data?.detail + if (typeof detail === 'string') return detail + if (Array.isArray(detail)) return detail.map(d => d?.msg).filter(Boolean).join('; ') || fallback + return fallback +} + +const slugify = (name) => name.toLowerCase().trim() + .replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 120) + +/** + * The plans a learner can work through. + * + * Progress is stated in blocks rather than a percentage: "3 of 6 blocks" is a + * thing you can act on, where "50%" only tells you how you feel about it. + */ +export default function StudyPlansPage() { + const { user } = useAuth() + const [plans, setPlans] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState('') + const [creating, setCreating] = useState(false) + const [name, setName] = useState('') + const [busy, setBusy] = useState(false) + + const load = useCallback(() => { + setLoading(true) + api.get('/study-plans/') + .then(res => setPlans(res.data || [])) + .catch(err => setError(apiError(err, 'Could not load study plans'))) + .finally(() => setLoading(false)) + }, []) + + useEffect(() => { load() }, [load]) + + const create = async () => { + if (!name.trim()) return + setBusy(true); setError('') + try { + await api.post('/study-plans/', { + name: name.trim(), slug: slugify(name), kind: 'set', is_published: false, + }) + setName(''); setCreating(false); load() + } catch (err) { setError(apiError(err, 'Could not create that plan')) } + finally { setBusy(false) } + } + + return ( +
+
+
+

Study plans

+

Worked through a block at a time — read first, then sit the questions.

+
+ {user?.is_moderator && ( + + )} +
+ + {creating && ( +
+ setName(e.target.value)} + onKeyDown={e => { if (e.key === 'Enter') create() }} /> + {/* Created unpublished: a plan with no blocks is not something to + put in front of a learner. */} + Starts as a draft until you add blocks. + + +
+ )} + + {error &&

{error}

} + + {loading ?
+ : plans.length === 0 ? ( +
No study plans yet.
+ ) : ( +
    + {plans.map(plan => ( +
  • + +
    +

    {plan.name}

    + {plan.kind === 'mixed' && mixed} + {!plan.is_published && draft} +
    + {plan.description &&

    {plan.description}

    } +

    + {plan.block_count} block{plan.block_count === 1 ? '' : 's'} · {plan.question_count} questions + {plan.exam_name && ` · ${plan.exam_name}`} +

    + {plan.block_count > 0 && ( +
    +
    + +
    + + {plan.blocks_completed} of {plan.block_count} blocks done + +
    + )} + +
  • + ))} +
+ )} +
+ ) +}