feat: analysis recommendations, category grants, compact question header

Analysis / recommendations (AMBOSS parity, verified on next.amboss.com):
- GET /study-tools/recommendations ranks focus areas by the study time most
  likely to raise the score. Readiness is the learner's accuracy in a category
  shrunk toward their own overall accuracy in proportion to sample size, so two
  unlucky answers do not read as a knowledge gap; it unlocks after 40 answers.
  Relevance is the share of the bank a category holds. Counts roll up through
  the category tree, so a system inherits its children's questions.
  It is deliberately not called EPC and does not claim to predict an exam.
- New /analysis page: Performance and Recommendations tabs, readiness summary,
  adaptive-session box, and expandable focus rows showing questions seen,
  answered correctly, the linked article and a per-topic practice action.

Per-category educator grants:
- category_grants table (migration p8b9c0d1e253) plus utils/category_grants.py
  resolving a grant to the category and all of its descendants.
- Question create, edit, delete, bulk and the manager summary now accept a
  moderator OR an educator granted the affected categories, and refuse moves
  that would push a question out of the holder's scope. Summary counts are
  scoped to the grant.
- Moderator endpoints to list, add and revoke grants, plus /my-grants driving
  the nav link and the manager's scope banner; grantable-users avoids handing
  moderators the admin-only user list.
- GrantsPanel in the question manager: grant, list and revoke with inline
  confirmation.

Question page:
- The category trail was a fixed 78px band that wrapped into several rows and
  pushed the stem down the page, followed by three more stacked strips. It is
  now one scrollable meta line (breadcrumb + difficulty + type) and a single
  AMBOSS-style action bar (Mark / Listen / Listen through / Clear) between the
  stem and the options. Difficulty is exposed on the runner payload.

Deploy fix: index.html shipped with no cache header, so browsers kept serving
the previous bundle references and a release looked like nothing had changed.
nginx now sends no-cache for HTML and immutable long-cache for hashed assets.

Tests: 16 new backend (recommendation shrinkage, roll-up, locking, grant scope
across create/edit/delete/bulk/summary, moderator gate) and 10 new frontend.
Full suites green: 88 backend, 116 frontend, build clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014yhHB8Pc7oQqyqn2Vo9DXA
This commit is contained in:
Daniel 2026-09-09 18:47:32 +02:00
parent 741ed9ba30
commit 16dc431066
24 changed files with 1486 additions and 50 deletions

View file

@ -0,0 +1,30 @@
"""Per-category editorial grants for non-moderator educators.
Revision ID: p8b9c0d1e253
Revises: n7a8b9c0d142
"""
from alembic import op
revision = "p8b9c0d1e253"
down_revision = "n7a8b9c0d142"
branch_labels = None
depends_on = None
def upgrade():
op.execute("""
CREATE TABLE IF NOT EXISTS category_grants (
id SERIAL PRIMARY KEY,
category_id INTEGER NOT NULL REFERENCES question_categories(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
granted_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT uq_category_grant UNIQUE (category_id, user_id)
)
""")
op.execute("CREATE INDEX IF NOT EXISTS ix_category_grants_category_id ON category_grants(category_id)")
op.execute("CREATE INDEX IF NOT EXISTS ix_category_grants_user_id ON category_grants(user_id)")
def downgrade():
op.execute("DROP TABLE IF EXISTS category_grants")

View file

@ -168,6 +168,7 @@ def setup_pgvector():
# Import new models so create_all picks them up
from app.models import quiz_category, quiz_question_link, question_category, favorite # noqa
from app.models import flashcard, course # noqa
from app.models import category_grant # noqa
# Kill stale idle-in-transaction connections from previous killed startups.
# They hold DDL locks and cause ALTER TABLE below to hang indefinitely.

View file

@ -0,0 +1,22 @@
from datetime import datetime
from sqlalchemy import Column, DateTime, ForeignKey, Integer, UniqueConstraint
from app.database import Base
class CategoryGrant(Base):
"""Lets a non-moderator edit the questions inside one category and its descendants.
A grant is scoped editorial access, not a role: the holder can create, edit
and delete questions filed under the granted category, and nothing else.
"""
__tablename__ = "category_grants"
__table_args__ = (UniqueConstraint("category_id", "user_id", name="uq_category_grant"),)
id = Column(Integer, primary_key=True, index=True)
category_id = Column(Integer, ForeignKey("question_categories.id", ondelete="CASCADE"), nullable=False, index=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
granted_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
created_at = Column(DateTime, default=datetime.utcnow)

View file

@ -6,11 +6,13 @@ from sqlalchemy.orm import Session
from app.database import get_db
from app.models.question import Question
from app.models.category_grant import CategoryGrant
from app.models.question_category import QuestionCategory, QuestionCategoryLink
from app.models.user import User
from app.services.quiz_builder import (bank_query, filtered_bank_query, category_descendants, category_breadcrumbs,
validate_parent, GenerateTestRequest, generate_test, bank_question_predicate)
from app.utils.auth import get_current_user, require_moderator
from app.utils.category_grants import manageable_categories
router = APIRouter()
@ -65,6 +67,110 @@ def _category_totals(db, user, by_id):
return {cid: len(question_set) for cid, question_set in totals.items()}
class GrantIn(BaseModel):
user_id: int
def _grant_json(grant, users, categories):
user = users.get(grant.user_id)
return {
"id": grant.id,
"category_id": grant.category_id,
"category_name": categories.get(grant.category_id),
"user_id": grant.user_id,
"user_name": user.name if user else None,
"user_email": user.email if user else None,
"created_at": grant.created_at.isoformat() if grant.created_at else None,
}
@router.get("/grants")
def list_category_grants(
db: Session = Depends(get_db),
current_user: User = Depends(require_moderator),
):
"""Every per-category editorial grant, for the question-manager admin panel."""
grants = db.query(CategoryGrant).order_by(CategoryGrant.id).all()
categories = {cat.id: cat.name for cat in db.query(QuestionCategory).all()}
users = {user.id: user for user in db.query(User).filter(
User.id.in_({g.user_id for g in grants}))} if grants else {}
return [_grant_json(grant, users, categories) for grant in grants]
@router.get("/grantable-users")
def grantable_users(
db: Session = Depends(get_db),
current_user: User = Depends(require_moderator),
):
"""Users a grant can be given to — moderators already manage everything."""
rows = db.query(User.id, User.name, User.email, User.role).order_by(User.name).all()
return [{"id": uid, "name": name, "email": email}
for uid, name, email, role in rows if role not in ("admin", "moderator")]
@router.post("/{cat_id}/grants", status_code=201)
def add_category_grant(
cat_id: int,
data: GrantIn,
db: Session = Depends(get_db),
current_user: User = Depends(require_moderator),
):
"""Let one user edit the questions in this category and everything under it."""
category = db.get(QuestionCategory, cat_id)
if not category:
raise HTTPException(404, "Category not found")
user = db.get(User, data.user_id)
if not user:
raise HTTPException(404, "User not found")
if user.is_moderator:
raise HTTPException(400, "Moderators already manage every category")
existing = db.query(CategoryGrant).filter_by(category_id=cat_id, user_id=data.user_id).first()
if existing:
raise HTTPException(409, "This user already has a grant for this category")
grant = CategoryGrant(category_id=cat_id, user_id=data.user_id, granted_by=current_user.id)
db.add(grant)
db.commit()
db.refresh(grant)
return _grant_json(grant, {user.id: user}, {category.id: category.name})
@router.delete("/{cat_id}/grants/{user_id}", status_code=204)
def remove_category_grant(
cat_id: int,
user_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_moderator),
):
grant = db.query(CategoryGrant).filter_by(category_id=cat_id, user_id=user_id).first()
if not grant:
raise HTTPException(404, "Grant not found")
db.delete(grant)
db.commit()
@router.get("/my-grants")
def my_category_grants(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""What the signed-in user may edit — drives the question-manager entry point."""
scope = manageable_categories(db, current_user)
if scope is None:
return {"is_moderator": True, "can_manage_questions": True, "categories": []}
names = {cat.id: cat.name for cat in db.query(QuestionCategory).filter(
QuestionCategory.id.in_(scope))} if scope else {}
granted = {row[0] for row in db.query(CategoryGrant.category_id).filter(
CategoryGrant.user_id == current_user.id).all()}
return {
"is_moderator": False,
"can_manage_questions": bool(scope),
"categories": sorted(
({"id": cid, "name": names.get(cid), "direct": cid in granted} for cid in scope),
key=lambda row: (row["name"] or ""),
),
}
@router.get("/", response_model=list[QCatResponse])
def list_question_categories(db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
cats = db.query(QuestionCategory).order_by(QuestionCategory.name).all()

View file

@ -26,6 +26,8 @@ from app.models.favorite import Favorite
from app.services.quiz_builder import (bank_query, category_descendants, filtered_bank_query,
CreateFromBankRequest, GenerateTestRequest, create_saved_test, generate_test)
from app.utils.auth import get_current_user, require_moderator
from app.utils.category_grants import (assert_can_manage_category, assert_can_manage_questions,
is_question_manager, manageable_categories, question_in_scope, require_question_manager)
router = APIRouter()
@ -51,7 +53,9 @@ def delete_question(
if not question:
raise HTTPException(status_code=404, detail="Question not found")
is_mod = current_user.role in ("admin", "moderator")
if not is_mod:
scope = manageable_categories(db, current_user)
granted = scope is not None and bool(scope) and question_in_scope(db, scope, question)
if not is_mod and not granted:
if question.user_id != current_user.id:
raise HTTPException(status_code=403, detail="Not authorized to delete this question")
# Regular users can only delete questions they created (no source quiz)
@ -81,12 +85,19 @@ def edit_question(
question_id: int,
data: QuestionEdit,
db: Session = Depends(get_db),
current_user: User = Depends(require_moderator),
current_user: User = Depends(get_current_user),
):
"""Edit a question. Only admins and educators manage questions."""
"""Edit a question — moderators, or an educator granted its category."""
scope = require_question_manager(db, current_user)
question = db.query(Question).filter(Question.id == question_id).first()
if not question:
raise HTTPException(status_code=404, detail="Question not found")
assert_can_manage_questions(db, scope, [question_id])
if "question_category_id" in data.model_fields_set:
# Moving a question out of your scope would put it beyond your reach.
assert_can_manage_category(scope, data.question_category_id)
for extra_id in (data.additional_category_ids or []):
assert_can_manage_category(scope, extra_id)
values = validate_image_attachments(db, current_user, data.model_dump(exclude_unset=True))
if values.get("question_category_id") is not None and not db.get(QuestionCategory, values["question_category_id"]):
raise HTTPException(400, "Category not found")
@ -427,9 +438,11 @@ class ManualQuestionCreate(BaseModel):
def create_question_manually(
data: ManualQuestionCreate,
db: Session = Depends(get_db),
current_user: User = Depends(require_moderator),
current_user: User = Depends(get_current_user),
):
"""Create a single question manually (not from PDF extraction)."""
"""Create a single question manually — moderators, or an educator in their categories."""
scope = require_question_manager(db, current_user)
assert_can_manage_category(scope, data.question_category_id)
q_text = data.question_text.strip()
if not q_text:
raise HTTPException(status_code=400, detail="Question text is required")
@ -582,14 +595,18 @@ class BulkQuestionAction(BaseModel):
def bulk_question_action(
data: BulkQuestionAction,
db: Session = Depends(get_db),
current_user: User = Depends(require_moderator),
current_user: User = Depends(get_current_user),
):
"""Apply one editorial action to up to 500 checked questions."""
scope = require_question_manager(db, current_user)
ids = list(dict.fromkeys(data.question_ids))
if not ids:
raise HTTPException(400, "No questions selected")
if len(ids) > 500:
raise HTTPException(400, "Select at most 500 questions per action")
assert_can_manage_questions(db, scope, ids)
if data.action == "category":
assert_can_manage_category(scope, data.category_id)
rows = db.query(Question).filter(Question.id.in_(ids))
if data.action == "category":
@ -612,10 +629,20 @@ def bulk_question_action(
@router.get("/manage/summary")
def question_manager_summary(
db: Session = Depends(get_db),
current_user: User = Depends(require_moderator),
current_user: User = Depends(get_current_user),
):
"""Editorial health counters shown as one-click filters in the question manager."""
"""Editorial health counters shown as one-click filters in the question manager.
A granted educator sees the counts for their own categories only.
"""
scope = require_question_manager(db, current_user)
base = db.query(func.count(Question.id))
if scope is not None:
base = base.filter(or_(
Question.question_category_id.in_(scope),
Question.id.in_(select(QuestionCategoryLink.question_id).where(
QuestionCategoryLink.category_id.in_(scope))),
))
blank = ("", " ")
return {
"total": base.scalar() or 0,
@ -625,6 +652,7 @@ def question_manager_summary(
or_(Question.explanation.is_(None), Question.explanation.in_(blank))).scalar() or 0,
"private": base.filter(Question.is_shared == 0).scalar() or 0,
"mine": base.filter(Question.user_id == current_user.id).scalar() or 0,
"scoped": scope is not None,
}

View file

@ -1,8 +1,9 @@
"""Educator-maintained lab references and authorized question response statistics."""
from collections import Counter
from collections import Counter, defaultdict
from datetime import datetime
from typing import Literal
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, Field, HttpUrl, field_validator
from sqlalchemy import func, or_
from sqlalchemy.orm import Session
@ -17,6 +18,7 @@ from app.models.question_category import QuestionCategory, QuestionCategoryLink
from app.models.quiz import Quiz
from app.models.user import User
from app.utils.auth import get_current_user, require_moderator
from app.services.quiz_builder import shareable_question_predicate
from app.utils.quiz_access import general_quiz_visibility, require_quiz_access
from app.utils.quiz_questions import question_in_quiz
@ -208,6 +210,170 @@ def performance_by_category(db: Session = Depends(get_db), user: User = Depends(
}
# Readiness needs enough answers before a per-category estimate means anything.
READINESS_UNLOCK_ANSWERS = 40
# Shrinkage weight: a category with this many answers sits halfway between its
# own accuracy and the learner's overall accuracy.
READINESS_PRIOR_ANSWERS = 8
def _category_rollup(categories):
"""Map every category to itself plus all of its ancestors, for roll-up counting."""
parents = {cat.id: cat.parent_id for cat in categories}
ancestry: dict[int, list[int]] = {}
for cid in parents:
chain, cursor, guard = [], cid, 0
while cursor is not None and guard < 12:
chain.append(cursor)
cursor = parents.get(cursor)
guard += 1
ancestry[cid] = chain
return ancestry
@router.get("/recommendations")
def study_recommendations(
group: Literal["systems", "subtopics"] = "systems",
limit: int = Query(20, ge=1, le=60),
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
"""Focus areas ranked by the study time most likely to raise the learner's score.
Readiness is the learner's accuracy in a category shrunk toward their own
overall accuracy in proportion to how few answers that category has, so a
single unlucky question does not read as a knowledge gap. It is a plain
empirical-Bayes estimate over recorded answers not a psychometric exam
score, and not a prediction of any real examination.
"""
categories = db.query(QuestionCategory).all()
ancestry = _category_rollup(categories)
names = {cat.id: cat.name for cat in categories}
parents = {cat.id: cat.parent_id for cat in categories}
top_level = {cat.id for cat in categories if cat.parent_id is None}
# ── What the learner has answered ──────────────────────────────
answered_rows = db.query(
AttemptAnswer.question_id, AttemptAnswer.is_correct, Question.question_category_id,
).join(QuizAttempt, QuizAttempt.id == AttemptAnswer.attempt_id
).join(Quiz, Quiz.id == QuizAttempt.quiz_id
).join(Question, Question.id == AttemptAnswer.question_id
).filter(
QuizAttempt.user_id == user.id,
QuizAttempt.completed_at.isnot(None),
or_(QuizAttempt.expired == 0, QuizAttempt.expired.is_(None)),
Quiz.course_id.is_(None),
).all()
extra_links: dict[int, set[int]] = defaultdict(set)
for question_id, category_id in db.query(
QuestionCategoryLink.question_id, QuestionCategoryLink.category_id).all():
extra_links[question_id].add(category_id)
def categories_for(question_id, primary):
direct = extra_links.get(question_id, set()) | ({primary} if primary else set())
rolled = set()
for cid in direct:
rolled.update(ancestry.get(cid, [cid]))
return rolled
answered: dict[int, int] = defaultdict(int)
correct: dict[int, int] = defaultdict(int)
seen_questions: dict[int, set[int]] = defaultdict(set)
total_answers = len(answered_rows)
total_correct = sum(1 for _, is_correct, _ in answered_rows if is_correct)
for question_id, is_correct, primary in answered_rows:
for cid in categories_for(question_id, primary):
answered[cid] += 1
seen_questions[cid].add(question_id)
if is_correct:
correct[cid] += 1
# ── How much bank material each category holds ─────────────────
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():
bank_total += 1
for cid in categories_for(question_id, primary):
available[cid] += 1
articles = {}
for article in db.query(Article).filter(Article.status == "published",
Article.category_id.isnot(None)).all():
articles.setdefault(article.category_id, article)
overall_accuracy = (total_correct / total_answers) if total_answers else 0.0
unlocked = total_answers >= READINESS_UNLOCK_ANSWERS
scope = top_level if group == "systems" else {cat.id for cat in categories if cat.parent_id is not None}
rows = []
for cid in scope:
seen = len(seen_questions.get(cid, ()))
pool = available.get(cid, 0)
if pool == 0 and seen == 0:
continue
n = answered.get(cid, 0)
c = correct.get(cid, 0)
accuracy = round(100 * c / n, 1) if n else None
readiness = None
if unlocked and n:
shrunk = (c + READINESS_PRIOR_ANSWERS * overall_accuracy) / (n + READINESS_PRIOR_ANSWERS)
readiness = round(100 * shrunk, 1)
relevance = round(100 * pool / bank_total, 1) if bank_total else 0.0
coverage = round(100 * seen / pool, 1) if pool else 0.0
article = articles.get(cid)
rows.append({
"category_id": cid,
"name": names.get(cid, "Uncategorized"),
"parent_id": parents.get(cid),
"parent_name": names.get(parents.get(cid)),
"answered": n,
"correct": c,
"seen_questions": seen,
"available": pool,
"coverage": coverage,
"accuracy": accuracy,
"readiness": readiness,
"relevance": relevance,
"status": "no_data" if not n else "focus" if (readiness if readiness is not None else accuracy) < 70 else "proficient",
"article_id": article.id if article else None,
"article_title": article.title if article else None,
})
# Priority: weak-and-relevant first, then untouched material by relevance.
baseline = 100 * overall_accuracy if total_answers else 70.0
for row in rows:
score = row["readiness"] if row["readiness"] is not None else row["accuracy"]
gap = (baseline - score) / 100 if score is not None else 0.5 # unseen material sits mid-priority
unseen = 1 - (row["coverage"] / 100)
row["priority"] = round(max(gap, 0.0) * (row["relevance"] / 100) + 0.25 * unseen * (row["relevance"] / 100), 5)
rows.sort(key=lambda row: (-row["priority"], -row["relevance"], row["name"]))
focus_ids = {row["category_id"] for row in rows[:3] if row["answered"]}
for row in rows:
if row["category_id"] in focus_ids:
row["is_focus_area"] = True
else:
row["is_focus_area"] = False
return {
"group": group,
"unlocked": unlocked,
"answers_needed": max(0, READINESS_UNLOCK_ANSWERS - total_answers),
"total_answered": total_answers,
"unique_questions_seen": len({row[0] for row in answered_rows}),
"bank_total": bank_total,
"overall_accuracy": round(100 * overall_accuracy, 1) if total_answers else None,
"focus_areas": rows[:limit],
"basis": (
"Your completed, non-expired general-bank answers, rolled up through the category tree. "
f"Readiness shrinks each category's accuracy toward your overall {round(100 * overall_accuracy)}% "
"so small samples do not overstate a gap; it unlocks after "
f"{READINESS_UNLOCK_ANSWERS} answers. Relevance is the share of the question bank a category holds. "
"These are study hints from your own answers, not an exam score prediction."
),
}
@router.get("/attempts/{attempt_id}/questions/{question_id}/responses")
def question_responses(attempt_id: int, question_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)):
attempt = db.query(QuizAttempt).filter_by(id=attempt_id, user_id=user.id).first()

View file

@ -31,6 +31,7 @@ class QuestionResponse(BaseModel):
options: list[str] | None
image_path: str | None = None
question_category_id: int | None = None
difficulty: str | None = None
category_breadcrumbs: list[dict] = Field(default_factory=list)
class Config:

View file

@ -0,0 +1,86 @@
"""Per-category editorial access for non-moderator educators.
A moderator or admin manages every question. Everyone else manages only the
questions filed under a category they hold a grant on, or under any descendant
of it. `manageable_categories` returns None to mean "no restriction".
"""
from fastapi import HTTPException
from sqlalchemy.orm import Session
from app.models.category_grant import CategoryGrant
from app.models.question import Question
from app.models.question_category import QuestionCategory, QuestionCategoryLink
from app.models.user import User
def _descendants(db: Session, roots: set[int]) -> set[int]:
"""Expand category ids to include every category beneath them."""
if not roots:
return set()
children: dict[int, list[int]] = {}
for cid, parent_id in db.query(QuestionCategory.id, QuestionCategory.parent_id).all():
if parent_id is not None:
children.setdefault(parent_id, []).append(cid)
seen, stack = set(roots), list(roots)
while stack:
for child in children.get(stack.pop(), ()):
if child not in seen:
seen.add(child)
stack.append(child)
return seen
def manageable_categories(db: Session, user: User) -> set[int] | None:
"""Category ids this user may edit questions in; None means all of them."""
if user.is_moderator:
return None
roots = {row[0] for row in db.query(CategoryGrant.category_id).filter(
CategoryGrant.user_id == user.id).all()}
return _descendants(db, roots)
def is_question_manager(db: Session, user: User) -> bool:
"""True when the user may edit questions somewhere."""
scope = manageable_categories(db, user)
return scope is None or bool(scope)
def require_question_manager(db: Session, user: User) -> set[int] | None:
"""Gate a question-management route; returns the caller's category scope."""
scope = manageable_categories(db, user)
if scope is not None and not scope:
raise HTTPException(403, "Question management requires moderator access or a category grant")
return scope
def assert_can_manage_category(scope: set[int] | None, category_id: int | None, what: str = "category") -> None:
if scope is None:
return
if category_id is None or category_id not in scope:
raise HTTPException(403, f"You do not have an editorial grant for this {what}")
def assert_can_manage_questions(db: Session, scope: set[int] | None, question_ids: list[int]) -> None:
"""Every target question must sit in a granted category (primary or additional)."""
if scope is None or not question_ids:
return
primary = dict(db.query(Question.id, Question.question_category_id).filter(
Question.id.in_(question_ids)).all())
extra: dict[int, set[int]] = {}
for qid, cid in db.query(QuestionCategoryLink.question_id, QuestionCategoryLink.category_id).filter(
QuestionCategoryLink.question_id.in_(question_ids)).all():
extra.setdefault(qid, set()).add(cid)
for question_id in question_ids:
if question_id not in primary:
continue # a missing row is reported as 404 by the caller, not as a permission error
owned = ({primary[question_id]} if primary[question_id] else set()) | extra.get(question_id, set())
if not (owned & scope):
raise HTTPException(403, "You do not have an editorial grant for one of these questions")
def question_in_scope(db: Session, scope: set[int], question: Question) -> bool:
"""True when a question sits in a granted category (primary or additional)."""
owned = {question.question_category_id} if question.question_category_id else set()
owned |= {row[0] for row in db.query(QuestionCategoryLink.category_id).filter(
QuestionCategoryLink.question_id == question.id).all()}
return bool(owned & scope)

View file

@ -0,0 +1,188 @@
"""Per-category editorial grants: a non-moderator educator edits only their categories.
Run: DATABASE_URL=sqlite:///:memory: PYTHONPATH=backend python -m unittest discover -s backend/tests
No application startup, external services or AI calls; a disposable SQLite database per test.
"""
import os
os.environ["DATABASE_URL"] = "sqlite:///:memory:"
import unittest
from unittest.mock import patch
from fastapi import FastAPI
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from sqlalchemy.pool import StaticPool
from app.database import Base, get_db
from app.models.category_grant import CategoryGrant
from app.models.question import Question
from app.models.question_category import QuestionCategory, QuestionCategoryLink
from app.models.user import User
from app.routers import question_categories, questions
from app.utils.auth import get_current_user
class CategoryGrantTests(unittest.TestCase):
def setUp(self):
self.engine = create_engine("sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool)
Base.metadata.create_all(self.engine)
self.db = Session(self.engine)
self.mod = User(id=1, name="Mod", email="mod@example.test", hashed_password="unused", role="moderator")
self.educator = User(id=2, name="Educator", email="edu@example.test", hashed_password="unused")
self.outsider = User(id=3, name="Outsider", email="out@example.test", hashed_password="unused")
self.db.add_all([self.mod, self.educator, self.outsider])
# Cardiology > Kawasaki disease · Neurology stands alone.
self.db.add_all([
QuestionCategory(id=1, name="Cardiology", user_id=1),
QuestionCategory(id=2, name="Kawasaki disease", parent_id=1, user_id=1),
QuestionCategory(id=10, name="Neurology", user_id=1),
])
self.db.flush()
for qid, category in [(1, 1), (2, 2), (3, 10), (4, None)]:
self.db.add(Question(id=qid, question_category_id=category, user_id=1, is_shared=1,
question_text=f"Question {qid}", question_type="mcq",
options=["yes", "no"], correct_answer="yes", explanation="Because"))
self.db.commit()
self.user = self.mod
app = FastAPI()
app.include_router(questions.router, prefix="/questions")
app.include_router(question_categories.router, prefix="/question-categories")
app.dependency_overrides[get_db] = lambda: self.db
app.dependency_overrides[get_current_user] = lambda: self.user
self.client = TestClient(app)
def tearDown(self):
self.client.close()
self.db.close()
self.engine.dispose()
def grant(self, category_id=1, user_id=2):
self.db.add(CategoryGrant(category_id=category_id, user_id=user_id, granted_by=1))
self.db.commit()
# ── Granting ───────────────────────────────────────────────────
def test_moderator_grants_and_revokes_a_category(self):
response = self.client.post("/question-categories/1/grants", json={"user_id": 2})
self.assertEqual(response.status_code, 201, response.text)
self.assertEqual(response.json()["category_name"], "Cardiology")
self.assertEqual(response.json()["user_email"], "edu@example.test")
listed = self.client.get("/question-categories/grants").json()
self.assertEqual([(g["category_id"], g["user_id"]) for g in listed], [(1, 2)])
self.assertEqual(self.client.post("/question-categories/1/grants", json={"user_id": 2}).status_code, 409)
self.assertEqual(self.client.delete("/question-categories/1/grants/2").status_code, 204)
self.assertEqual(self.client.get("/question-categories/grants").json(), [])
def test_grants_are_moderator_only_and_validate_their_target(self):
self.assertEqual(self.client.post("/question-categories/999/grants", json={"user_id": 2}).status_code, 404)
self.assertEqual(self.client.post("/question-categories/1/grants", json={"user_id": 999}).status_code, 404)
# A moderator already manages everything; a grant would be meaningless.
self.assertEqual(self.client.post("/question-categories/1/grants", json={"user_id": 1}).status_code, 400)
self.user = self.educator
self.assertEqual(self.client.post("/question-categories/1/grants", json={"user_id": 2}).status_code, 403)
self.assertEqual(self.client.get("/question-categories/grants").status_code, 403)
def test_my_grants_reports_the_scope_including_descendants(self):
self.user = self.educator
self.assertEqual(self.client.get("/question-categories/my-grants").json(),
{"is_moderator": False, "can_manage_questions": False, "categories": []})
self.grant(category_id=1)
scope = self.client.get("/question-categories/my-grants").json()
self.assertTrue(scope["can_manage_questions"])
self.assertEqual([(c["name"], c["direct"]) for c in scope["categories"]],
[("Cardiology", True), ("Kawasaki disease", False)])
self.user = self.mod
self.assertTrue(self.client.get("/question-categories/my-grants").json()["is_moderator"])
# ── Using a grant ──────────────────────────────────────────────
def test_educator_edits_inside_the_grant_and_its_descendants_only(self):
self.grant(category_id=1)
self.user = self.educator
self.assertEqual(self.client.patch("/questions/1", json={"explanation": "Edited"}).status_code, 200)
# Question 2 sits in the child category, still inside the grant.
self.assertEqual(self.client.patch("/questions/2", json={"difficulty": "hard"}).status_code, 200)
# Neurology and the uncategorised question are out of scope.
self.assertEqual(self.client.patch("/questions/3", json={"explanation": "No"}).status_code, 403)
self.assertEqual(self.client.patch("/questions/4", json={"explanation": "No"}).status_code, 403)
self.assertEqual(self.db.get(Question, 3).explanation, "Because")
def test_educator_cannot_move_a_question_out_of_their_scope(self):
self.grant(category_id=1)
self.user = self.educator
self.assertEqual(self.client.patch("/questions/1", json={"question_category_id": 10}).status_code, 403)
self.assertEqual(self.client.patch("/questions/1", json={"additional_category_ids": [10]}).status_code, 403)
self.assertEqual(self.client.patch("/questions/1", json={"question_category_id": 2}).status_code, 200)
self.assertEqual(self.db.get(Question, 1).question_category_id, 2)
def test_educator_creates_only_inside_their_scope(self):
self.grant(category_id=1)
self.user = self.educator
body = {"question_text": "New question", "question_type": "mcq",
"options": ["yes", "no"], "correct_answer": "yes"}
with patch("app.services.embedding_service.embed_question", return_value=None):
allowed = self.client.post("/questions/create", json={**body, "question_category_id": 2})
self.assertEqual(allowed.status_code, 200, allowed.text)
self.assertEqual(self.client.post("/questions/create",
json={**body, "question_category_id": 10}).status_code, 403)
# Uncategorised creation is out of scope too — it would land outside the grant.
self.assertEqual(self.client.post("/questions/create", json=body).status_code, 403)
def test_bulk_actions_are_confined_to_the_grant(self):
self.grant(category_id=1)
self.user = self.educator
mixed = self.client.post("/questions/bulk",
json={"question_ids": [1, 3], "action": "difficulty", "difficulty": "easy"})
self.assertEqual(mixed.status_code, 403)
self.assertIsNone(self.db.get(Question, 1).difficulty) # nothing applied
ok = self.client.post("/questions/bulk",
json={"question_ids": [1, 2], "action": "difficulty", "difficulty": "easy"})
self.assertEqual(ok.status_code, 200, ok.text)
self.assertEqual(self.db.get(Question, 1).difficulty, "easy")
# Reassigning into an ungranted category is refused.
self.assertEqual(self.client.post("/questions/bulk",
json={"question_ids": [1], "action": "category",
"category_id": 10}).status_code, 403)
def test_additional_category_links_bring_a_question_into_scope(self):
self.grant(category_id=10)
self.db.add(QuestionCategoryLink(question_id=1, category_id=10))
self.db.commit()
self.user = self.educator
self.assertEqual(self.client.patch("/questions/1", json={"explanation": "Edited"}).status_code, 200)
def test_educator_deletes_only_inside_the_grant(self):
self.grant(category_id=1)
self.user = self.educator
self.assertEqual(self.client.delete("/questions/3").status_code, 403)
self.assertEqual(self.client.delete("/questions/1").status_code, 204)
self.assertIsNone(self.db.get(Question, 1))
def test_summary_is_scoped_to_the_grant(self):
self.grant(category_id=1)
self.user = self.educator
summary = self.client.get("/questions/manage/summary").json()
self.assertTrue(summary["scoped"])
self.assertEqual(summary["total"], 2) # questions 1 and 2 only
moderator_summary = None
self.user = self.mod
moderator_summary = self.client.get("/questions/manage/summary").json()
self.assertFalse(moderator_summary["scoped"])
self.assertEqual(moderator_summary["total"], 4)
def test_ungranted_user_is_locked_out_of_question_management(self):
self.user = self.outsider
self.assertEqual(self.client.get("/questions/manage/summary").status_code, 403)
self.assertEqual(self.client.patch("/questions/1", json={"explanation": "No"}).status_code, 403)
self.assertEqual(self.client.post("/questions/bulk",
json={"question_ids": [1], "action": "delete"}).status_code, 403)
if __name__ == "__main__":
unittest.main()

View file

@ -20,7 +20,7 @@ from sqlalchemy.orm import Session
from sqlalchemy.pool import StaticPool
from app.database import Base, get_db
from app.models.attempt import QuizAttempt
from app.models.attempt import AttemptAnswer, QuizAttempt
from app.models.course import Course
from app.models.question import Question
from app.models.question_category import QuestionCategory, QuestionCategoryLink
@ -28,7 +28,8 @@ from app.models.quiz import Quiz
from app.models.quiz_category import QuizCategory
from app.models.quiz_question_link import QuizQuestionLink
from app.models.user import User
from app.routers import questions
from app.models.article import Article
from app.routers import questions, study_tools
from app.utils.auth import get_current_user
with patch.dict(sys.modules, {"app.services.quiz_service": ModuleType("app.services.quiz_service")}):
@ -231,5 +232,115 @@ class QuestionManagerTests(unittest.TestCase):
json={"question_ids": [1], "action": "delete"}).status_code, 403)
class RecommendationTests(unittest.TestCase):
"""Focus-area ranking: readiness shrinkage, relevance and roll-up through the tree."""
def setUp(self):
self.engine = create_engine("sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool)
Base.metadata.create_all(self.engine)
self.db = Session(self.engine)
self.user = User(id=1, name="Learner", email="learner@example.test", hashed_password="unused")
self.db.add_all([self.user, User(id=3, name="Mod", email="mod@example.test",
hashed_password="unused", role="moderator")])
self.db.add_all([
QuestionCategory(id=1, name="Cardiology", user_id=3),
QuestionCategory(id=2, name="Kawasaki disease", parent_id=1, user_id=3),
QuestionCategory(id=10, name="Neurology", user_id=3),
])
self.db.add(Quiz(id=1, title="Bank test", user_id=1, is_published=1, questions_count=6))
self.db.flush()
# 6 shareable bank questions: 4 cardiology (all under the Kawasaki child), 2 neurology.
for qid, category in [(1, 2), (2, 2), (3, 2), (4, 2), (5, 10), (6, 10)]:
self.db.add(Question(id=qid, question_category_id=category, user_id=3, is_shared=1,
question_text=f"Question {qid}", question_type="mcq",
options=["yes", "no"], correct_answer="yes"))
self.db.add(Article(id=7, slug="kawasaki", title="Kawasaki disease", sections=[],
category_id=1, user_id=3, status="published"))
self.db.commit()
app = FastAPI()
app.include_router(study_tools.router, prefix="/study-tools")
app.dependency_overrides[get_db] = lambda: self.db
app.dependency_overrides[get_current_user] = lambda: self.user
self.client = TestClient(app)
def tearDown(self):
self.client.close()
self.db.close()
self.engine.dispose()
def answer(self, question_id, correct):
attempt = self.db.query(QuizAttempt).filter_by(user_id=1, quiz_id=1).first()
if attempt is None:
attempt = QuizAttempt(quiz_id=1, user_id=1, mode="study", total_questions=6,
completed_at=datetime(2026, 3, 1))
self.db.add(attempt)
self.db.flush()
self.db.add(AttemptAnswer(attempt_id=attempt.id, question_id=question_id,
user_answer="yes" if correct else "no", is_correct=correct))
self.db.commit()
def recommend(self, **params):
response = self.client.get("/study-tools/recommendations", params=params)
self.assertEqual(response.status_code, 200, response.text)
return response.json()
def test_relevance_and_coverage_roll_up_to_the_parent_system(self):
data = self.recommend()
rows = {row["name"]: row for row in data["focus_areas"]}
# Cardiology owns 4 of 6 bank questions through its child category.
self.assertEqual(rows["Cardiology"]["available"], 4)
self.assertEqual(rows["Cardiology"]["relevance"], 66.7)
self.assertEqual(rows["Neurology"]["available"], 2)
self.assertEqual(data["bank_total"], 6)
# Systems grouping shows top-level categories only.
self.assertNotIn("Kawasaki disease", rows)
self.assertIn("Kawasaki disease", {r["name"] for r in self.recommend(group="subtopics")["focus_areas"]})
def test_readiness_stays_locked_until_enough_answers(self):
self.answer(1, False)
data = self.recommend()
self.assertFalse(data["unlocked"])
self.assertEqual(data["answers_needed"], 39)
self.assertEqual(data["overall_accuracy"], 0.0) # reported, but the client gates on `unlocked`
cardiology = next(r for r in data["focus_areas"] if r["name"] == "Cardiology")
self.assertIsNone(cardiology["readiness"])
self.assertEqual(cardiology["accuracy"], 0.0) # raw accuracy still reported
self.assertEqual(cardiology["status"], "focus")
def test_readiness_shrinks_a_small_sample_toward_overall_accuracy(self):
# 40 answers unlocks readiness: 38 correct in neurology, 2 wrong in cardiology.
for _ in range(19):
self.answer(5, True)
self.answer(6, True)
self.answer(1, False)
self.answer(2, False)
data = self.recommend()
self.assertTrue(data["unlocked"])
rows = {row["name"]: row for row in data["focus_areas"]}
cardiology = rows["Cardiology"]
self.assertEqual(cardiology["accuracy"], 0.0)
# Two wrong answers alone must not read as 0% knowledge; shrinkage pulls it up
# toward the learner's ~95% overall, but it still ranks below neurology.
self.assertGreater(cardiology["readiness"], 0.0)
self.assertLess(cardiology["readiness"], rows["Neurology"]["readiness"])
self.assertGreater(cardiology["priority"], rows["Neurology"]["priority"])
self.assertEqual(data["focus_areas"][0]["name"], "Cardiology")
def test_untouched_categories_report_no_data_and_link_their_article(self):
data = self.recommend()
cardiology = next(r for r in data["focus_areas"] if r["name"] == "Cardiology")
self.assertEqual(cardiology["status"], "no_data")
self.assertEqual(cardiology["answered"], 0)
self.assertEqual(cardiology["article_id"], 7)
self.assertEqual(cardiology["article_title"], "Kawasaki disease")
self.assertFalse(cardiology["is_focus_area"]) # focus areas need recorded answers
def test_grouping_is_validated(self):
self.assertEqual(self.client.get("/study-tools/recommendations",
params={"group": "nonsense"}).status_code, 422)
if __name__ == "__main__":
unittest.main()

View file

@ -58,8 +58,23 @@ server {
add_header X-Content-Type-Options "nosniff" always;
}
# Hashed build assets are immutable a new build gets a new filename.
location /assets/ {
expires 1y;
add_header Cache-Control "public, immutable" always;
add_header X-Content-Type-Options "nosniff" always;
}
# index.html must never be cached, or a deploy keeps serving the old
# bundle references and the app appears unchanged after a release.
location = /index.html {
add_header Cache-Control "no-cache, must-revalidate" always;
add_header X-Content-Type-Options "nosniff" always;
}
# SPA fallback
location / {
add_header Cache-Control "no-cache, must-revalidate" always;
try_files $uri $uri/ /index.html;
}
}

View file

@ -18,6 +18,7 @@ const AccountPage = lazy(() => import('./pages/AccountPage'))
const SettingsPage = lazy(() => import('./pages/SettingsPage'))
const QuestionBankPage = lazy(() => import('./pages/QuestionBankPage'))
const QuestionManagerPage = lazy(() => import('./pages/QuestionManagerPage'))
const AnalysisPage = lazy(() => import('./pages/AnalysisPage'))
const JobsPage = lazy(() => import('./pages/JobsPage'))
const TrashPage = lazy(() => import('./pages/TrashPage'))
const QuizEditPage = lazy(() => import('./pages/QuizEditPage'))
@ -93,6 +94,8 @@ function AppRoutes() {
<Route path="/account" element={<AccountPage />} />
<Route path="/settings" element={<SettingsPage />} />
<Route path="/question-bank" element={<QuestionBankPage />} />
<Route path="/analysis" element={<AnalysisPage />} />
<Route path="/questions/manage" element={<QuestionManagerPage />} />
<Route path="/flashcards" element={<FlashcardsPage />} />
<Route path="/articles" element={<ArticlesPage />} />
<Route path="/articles/:id" element={<ArticlePage />} />
@ -111,7 +114,6 @@ function AppRoutes() {
<Route path="/quizzes/:id/edit" element={<QuizEditPage />} />
<Route path="/jobs" element={<JobsPage />} />
<Route path="/trash" element={<TrashPage />} />
<Route path="/questions/manage" element={<QuestionManagerPage />} />
</Route>
</Route>

View file

@ -0,0 +1,109 @@
import { useState, useEffect, useCallback } from 'react'
import api from '../api/client'
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
}
/** Moderator panel: who may edit questions in which category. */
export default function GrantsPanel({ categories }) {
const [open, setOpen] = useState(false)
const [grants, setGrants] = useState(null)
const [users, setUsers] = useState([])
const [categoryId, setCategoryId] = useState('')
const [userId, setUserId] = useState('')
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
const [revoking, setRevoking] = useState(null)
const load = useCallback(() => {
api.get('/question-categories/grants').then(res => setGrants(res.data || [])).catch(() => setGrants([]))
}, [])
useEffect(() => {
if (!open || grants !== null) return
load()
api.get('/question-categories/grantable-users').then(res => setUsers(res.data || [])).catch(() => setUsers([]))
}, [open, grants, load])
const add = async () => {
if (!categoryId || !userId) { setError('Pick a category and a user'); return }
setBusy(true)
setError('')
try {
const res = await api.post(`/question-categories/${categoryId}/grants`, { user_id: Number(userId) })
setGrants(prev => [...(prev || []), res.data])
setUserId('')
} catch (err) { setError(apiError(err, 'Could not add this grant')) }
finally { setBusy(false) }
}
const revoke = async (grant) => {
setRevoking(null)
try {
await api.delete(`/question-categories/${grant.category_id}/grants/${grant.user_id}`)
setGrants(prev => prev.filter(g => g.id !== grant.id))
} catch (err) { setError(apiError(err, 'Could not revoke this grant')) }
}
return (
<div className="qm-grants">
<button type="button" className="qm-grants-toggle" aria-expanded={open} onClick={() => setOpen(v => !v)}>
<span>Educator grants{grants !== null && ` (${grants.length})`}</span>
<span aria-hidden="true">{open ? '▲' : '▼'}</span>
</button>
{open && (
<div className="qm-grants-body">
<p className="qm-grants-note">
A grant lets one non-moderator edit, create and delete the questions in a category and everything
beneath it nothing else.
</p>
<div className="qm-grants-form">
<select value={categoryId} aria-label="Category to grant" onChange={e => setCategoryId(e.target.value)}>
<option value="">Category</option>
{categories.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
<select value={userId} aria-label="User to grant" onChange={e => setUserId(e.target.value)}>
<option value="">User</option>
{users.map(u => <option key={u.id} value={u.id}>{u.name} {u.email}</option>)}
</select>
<button className="btn btn-primary btn-sm" disabled={busy} onClick={add}>Grant access</button>
</div>
{error && <p className="qm-error" role="alert">{error}</p>}
{grants === null ? (
<p className="qm-grants-note">Loading</p>
) : grants.length === 0 ? (
<p className="qm-grants-note">No grants yet only moderators can edit questions.</p>
) : (
<ul className="qm-grants-list">
{grants.map(grant => (
<li key={grant.id}>
<span className="qm-grant-user">{grant.user_name || `User ${grant.user_id}`}</span>
<span className="qm-pill">{grant.category_name || `Category ${grant.category_id}`}</span>
<span className="qm-grant-email">{grant.user_email}</span>
{revoking === grant.id ? (
<>
<button className="btn btn-danger btn-sm" onClick={() => revoke(grant)}>Confirm</button>
<button className="btn btn-secondary btn-sm" onClick={() => setRevoking(null)}>Cancel</button>
</>
) : (
<button className="btn btn-secondary btn-sm"
aria-label={`Revoke ${grant.user_name || grant.user_id} on ${grant.category_name}`}
onClick={() => setRevoking(grant.id)}>Revoke</button>
)}
</li>
))}
</ul>
)}
</div>
)}
</div>
)
}

View file

@ -0,0 +1,82 @@
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { expect, it, vi, beforeEach } from 'vitest'
import GrantsPanel from './GrantsPanel'
import api from '../api/client'
vi.mock('../api/client', () => ({ default: { get: vi.fn(), post: vi.fn(), delete: vi.fn() } }))
const CATEGORIES = [{ id: 1, name: 'Cardiology' }, { id: 10, name: 'Neurology' }]
const GRANT = {
id: 5, category_id: 1, category_name: 'Cardiology',
user_id: 2, user_name: 'Educator', user_email: 'edu@example.test', created_at: null,
}
const mockApi = (grants = [GRANT]) => {
api.get.mockImplementation(url => {
if (url === '/question-categories/grants') return Promise.resolve({ data: grants })
if (url === '/question-categories/grantable-users') {
return Promise.resolve({ data: [{ id: 2, name: 'Educator', email: 'edu@example.test' }] })
}
return Promise.resolve({ data: [] })
})
}
beforeEach(() => vi.clearAllMocks())
it('loads grants only once opened', async () => {
mockApi()
render(<GrantsPanel categories={CATEGORIES} />)
expect(api.get).not.toHaveBeenCalled()
await userEvent.click(screen.getByRole('button', { name: /Educator grants/ }))
expect(await screen.findByText('Educator')).toBeInTheDocument()
expect(screen.getByText('Cardiology', { selector: '.qm-pill' })).toBeInTheDocument()
expect(api.get).toHaveBeenCalledWith('/question-categories/grants')
})
it('grants a category to a user', async () => {
mockApi([])
api.post.mockResolvedValue({ data: GRANT })
render(<GrantsPanel categories={CATEGORIES} />)
await userEvent.click(screen.getByRole('button', { name: /Educator grants/ }))
await screen.findByText(/No grants yet/)
await userEvent.selectOptions(screen.getByLabelText('Category to grant'), '1')
await userEvent.selectOptions(screen.getByLabelText('User to grant'), '2')
await userEvent.click(screen.getByRole('button', { name: 'Grant access' }))
await waitFor(() => expect(api.post).toHaveBeenCalledWith('/question-categories/1/grants', { user_id: 2 }))
expect(await screen.findByText('Educator')).toBeInTheDocument()
})
it('refuses an incomplete grant and surfaces server errors', async () => {
mockApi([])
render(<GrantsPanel categories={CATEGORIES} />)
await userEvent.click(screen.getByRole('button', { name: /Educator grants/ }))
await screen.findByText(/No grants yet/)
await userEvent.click(screen.getByRole('button', { name: 'Grant access' }))
expect(screen.getByRole('alert')).toHaveTextContent('Pick a category and a user')
expect(api.post).not.toHaveBeenCalled()
await userEvent.selectOptions(screen.getByLabelText('Category to grant'), '1')
await userEvent.selectOptions(screen.getByLabelText('User to grant'), '2')
api.post.mockRejectedValueOnce({ response: { data: { detail: 'Moderators already manage every category' } } })
await userEvent.click(screen.getByRole('button', { name: 'Grant access' }))
expect(await screen.findByRole('alert')).toHaveTextContent('Moderators already manage every category')
})
it('revokes a grant only after confirmation', async () => {
mockApi()
api.delete.mockResolvedValue({})
render(<GrantsPanel categories={CATEGORIES} />)
await userEvent.click(screen.getByRole('button', { name: /Educator grants/ }))
await screen.findByText('Educator')
await userEvent.click(screen.getByRole('button', { name: 'Revoke Educator on Cardiology' }))
expect(api.delete).not.toHaveBeenCalled()
await userEvent.click(screen.getByRole('button', { name: 'Confirm' }))
await waitFor(() => expect(api.delete).toHaveBeenCalledWith('/question-categories/1/grants/2'))
await waitFor(() => expect(screen.queryByText('Educator')).not.toBeInTheDocument())
})

View file

@ -63,10 +63,20 @@ export default function Navbar({ onSignIn, onRegister }) {
const [jobs, setJobs] = useState([])
const location = useLocation()
const isModerator = user?.role === 'admin' || user?.role === 'moderator'
// Educators granted a category also manage questions, without a moderator role.
const [canManageQuestions, setCanManageQuestions] = useState(false)
// Close menu on route change
useEffect(() => { setMenuOpen(false) }, [location.pathname])
useEffect(() => {
if (!user) { setCanManageQuestions(false); return }
if (isModerator) { setCanManageQuestions(true); return }
api.get('/question-categories/my-grants')
.then(res => setCanManageQuestions(!!res.data?.can_manage_questions))
.catch(() => setCanManageQuestions(false))
}, [user, isModerator])
// Single job-polling instance for the whole navbar
useEffect(() => {
if (!user) return
@ -91,8 +101,9 @@ export default function Navbar({ onSignIn, onRegister }) {
{ to: '/home', label: 'Home' },
{ to: '/', label: 'Dashboard' },
{ to: '/quizzes', label: 'Quizzes' },
{ to: '/analysis', label: 'Analysis' },
{ to: '/question-bank', label: 'Question Bank' },
...(isModerator ? [{ to: '/questions/manage', label: 'Manage Qs' }] : []),
...(canManageQuestions ? [{ to: '/questions/manage', label: 'Manage Qs' }] : []),
{ to: '/articles', label: 'Reading' },
{ to: '/flashcards', label: 'Cards' },
{ to: '/courses', label: 'Courses' },

View file

@ -0,0 +1,111 @@
/* Performance analysis readiness summary and ranked focus areas.
Mobile-first: the focus table collapses to stacked cards under 760px. */
.an-page { max-width: 1080px; margin: 0 auto; }
.an-header { margin-bottom: 12px; }
.an-header h1 { margin: 0 0 4px; font-size: 1.35rem; }
.an-header p { margin: 0; color: var(--text-muted); font-size: 0.87rem; }
.an-tabs { display: flex; gap: 4px; border-bottom: 1px solid var(--border); margin-bottom: 16px; overflow-x: auto; scrollbar-width: none; }
.an-tabs::-webkit-scrollbar { display: none; }
.an-tab {
background: none; border: none; border-bottom: 2px solid transparent;
padding: 9px 14px; font-size: 0.9rem; font-weight: 600;
color: var(--text-muted); cursor: pointer; white-space: nowrap;
}
.an-tab[aria-selected='true'] { color: var(--primary); border-bottom-color: var(--primary); }
/* ── Summary tiles ────────────────────────────────────────────────── */
.an-summary { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 10px; margin-bottom: 14px; }
.an-stat { background: var(--card-bg); border: 1px solid var(--border); border-radius: 10px; padding: 13px 15px; }
.an-stat-value { display: block; font-size: 1.5rem; font-weight: 700; line-height: 1.15; }
.an-stat-label { display: block; font-size: 0.72rem; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.05em; margin-top: 4px; }
.an-stat-sub { display: block; font-size: 0.74rem; color: var(--text-subtle); margin-top: 3px; }
.an-locked {
background: var(--card-bg); border: 1px dashed var(--border); border-radius: 10px;
padding: 16px; margin-bottom: 14px; color: var(--text-muted); font-size: 0.87rem;
}
.an-locked strong { display: block; color: var(--text); margin-bottom: 4px; font-size: 0.95rem; }
/* ── Next step ────────────────────────────────────────────────────── */
.an-nextstep {
background: var(--card-bg); border: 1px solid var(--primary); border-radius: 10px;
padding: 15px 17px; margin-bottom: 16px;
display: flex; gap: 14px; align-items: center; flex-wrap: wrap;
}
.an-nextstep-text { flex: 1; min-width: 200px; }
.an-nextstep h2 { margin: 0 0 3px; font-size: 1rem; }
.an-nextstep p { margin: 0; color: var(--text-muted); font-size: 0.84rem; }
.an-nextstep-controls { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
.an-nextstep select {
padding: 7px 10px; border: 1px solid var(--border); border-radius: 8px;
background: var(--input-bg); color: var(--text); font-size: 0.85rem;
}
/* ── Focus areas ──────────────────────────────────────────────────── */
.an-group-toggle { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 12px; }
.an-group-toggle button {
border: 1px solid var(--border); background: var(--card-bg); color: var(--text-muted);
border-radius: 20px; padding: 6px 14px; font-size: 0.8rem; font-weight: 600; cursor: pointer;
}
.an-group-toggle button[aria-pressed='true'] { background: var(--primary); border-color: var(--primary); color: var(--primary-fg); }
.an-focus-head {
display: grid; grid-template-columns: 28px 1fr 90px 90px 110px;
gap: 12px; padding: 0 14px 7px; align-items: center;
font-size: 0.68rem; font-weight: 700; text-transform: uppercase;
letter-spacing: 0.06em; color: var(--text-subtle);
}
.an-focus-list { display: flex; flex-direction: column; gap: 7px; }
.an-focus {
background: var(--card-bg); border: 1px solid var(--border); border-radius: 10px;
overflow: hidden;
}
.an-focus.is-focus { border-left: 3px solid #d97706; }
.an-focus-row {
display: grid; grid-template-columns: 28px 1fr 90px 90px 110px;
gap: 12px; padding: 12px 14px; align-items: center;
width: 100%; background: none; border: none; text-align: left; font: inherit; cursor: pointer; color: var(--text);
}
.an-focus-row:hover { background: var(--bg); }
.an-focus-toggle { color: var(--text-muted); font-size: 0.8rem; }
.an-focus-name { min-width: 0; }
.an-focus-name strong { display: block; font-size: 0.9rem; font-weight: 650; overflow-wrap: anywhere; }
.an-focus-parent { font-size: 0.73rem; color: var(--text-subtle); }
.an-focus-tag {
display: inline-block; margin-top: 4px; font-size: 0.64rem; font-weight: 700;
text-transform: uppercase; letter-spacing: 0.05em; border-radius: 20px; padding: 2px 8px;
background: #fff7ed; color: #b45309; border: 1px solid #fed7aa;
}
.an-metric { font-size: 0.9rem; font-weight: 650; }
.an-metric small { display: block; font-size: 0.68rem; font-weight: 500; color: var(--text-subtle); text-transform: uppercase; letter-spacing: 0.04em; }
.an-status { font-size: 0.7rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; }
.an-status.is-focus { color: #b45309; }
.an-status.is-proficient { color: var(--correct-fg); }
.an-status.is-no_data { color: var(--text-subtle); }
.an-focus-detail { padding: 0 14px 14px; border-top: 1px solid var(--border); }
.an-detail-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); gap: 14px; margin: 12px 0; }
.an-detail-block-label { font-size: 0.68rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; color: var(--text-subtle); margin-bottom: 5px; }
.an-detail-value { font-size: 0.92rem; font-weight: 650; margin-bottom: 6px; }
.an-bar { height: 7px; border-radius: 4px; background: var(--border); overflow: hidden; }
.an-bar > span { display: block; height: 100%; background: var(--primary); }
.an-bar > span.is-correct { background: var(--correct-fg); }
.an-detail-actions { display: flex; gap: 8px; flex-wrap: wrap; }
.an-basis { color: var(--text-muted); font-size: 0.76rem; line-height: 1.6; margin: 16px 0 0; }
.an-empty { background: var(--card-bg); border: 1px solid var(--border); border-radius: 10px; padding: 30px 18px; text-align: center; color: var(--text-muted); }
@media (max-width: 760px) {
.an-focus-head { display: none; }
.an-focus-row { grid-template-columns: 24px 1fr auto; grid-template-areas: 'tog name metric' 'tog meta metric'; }
.an-focus-row .an-focus-toggle { grid-area: tog; align-self: start; padding-top: 2px; }
.an-focus-row .an-focus-name { grid-area: name; }
.an-focus-row .an-metric-readiness { grid-area: metric; text-align: right; }
.an-focus-row .an-metric-relevance { display: none; }
.an-focus-row .an-status { grid-area: meta; }
.an-nextstep-controls { width: 100%; }
.an-nextstep-controls .btn { flex: 1; }
.an-detail-actions .btn { flex: 1; text-align: center; }
}

View file

@ -0,0 +1,188 @@
import { useState, useEffect, useCallback } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import api from '../api/client'
import CategoryPerformance from '../components/CategoryPerformance'
import './AnalysisPage.css'
const STATUS_LABEL = { focus: 'Focus area', proficient: 'Proficient', no_data: 'No data yet' }
function FocusRow({ row, onStartCategory }) {
const [open, setOpen] = useState(false)
const coverageLabel = `${row.seen_questions}/${row.available}`
const accuracyLabel = row.answered ? `${row.accuracy}% (${row.correct} of ${row.answered})` : 'Not attempted'
return (
<div className={`an-focus${row.is_focus_area ? ' is-focus' : ''}`}>
<button type="button" className="an-focus-row" aria-expanded={open} onClick={() => setOpen(v => !v)}>
<span className="an-focus-toggle" aria-hidden="true">{open ? '▾' : '▸'}</span>
<span className="an-focus-name">
<strong>{row.name}</strong>
{row.parent_name && <span className="an-focus-parent">{row.parent_name}</span>}
{row.is_focus_area && <span className="an-focus-tag">Focus area</span>}
</span>
<span className="an-metric an-metric-readiness">
{row.readiness !== null ? `${row.readiness}%` : row.accuracy !== null ? `${row.accuracy}%` : '—'}
<small>{row.readiness !== null ? 'Readiness' : 'Accuracy'}</small>
</span>
<span className="an-metric an-metric-relevance">
{row.relevance}%
<small>Relevance</small>
</span>
<span className={`an-status is-${row.status}`}>{STATUS_LABEL[row.status]}</span>
</button>
{open && (
<div className="an-focus-detail">
<div className="an-detail-grid">
<div>
<div className="an-detail-block-label">Questions seen</div>
<div className="an-detail-value">{coverageLabel}</div>
<div className="an-bar"><span style={{ width: `${row.coverage}%` }} /></div>
</div>
<div>
<div className="an-detail-block-label">Answered correctly</div>
<div className="an-detail-value">{accuracyLabel}</div>
<div className="an-bar"><span className="is-correct" style={{ width: `${row.accuracy || 0}%` }} /></div>
</div>
</div>
<div className="an-detail-actions">
{row.article_id && (
<Link className="btn btn-secondary btn-sm" to={`/articles/${row.article_id}`}>
Read {row.article_title}
</Link>
)}
<button className="btn btn-primary btn-sm" onClick={() => onStartCategory(row.category_id)}>
Practise this topic
</button>
</div>
</div>
)}
</div>
)
}
export default function AnalysisPage() {
const [tab, setTab] = useState('recommendations')
const [group, setGroup] = useState('systems')
const [data, setData] = useState(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [count, setCount] = useState(10)
const navigate = useNavigate()
const load = useCallback(() => {
setLoading(true)
setError('')
api.get('/study-tools/recommendations', { params: { group } })
.then(res => setData(res.data))
.catch(() => setError('Could not load your analysis'))
.finally(() => setLoading(false))
}, [group])
useEffect(() => { load() }, [load])
const startAdaptive = () => navigate(`/quizzes/create?adaptive=1&count=${count}`)
const startCategory = (categoryId) => navigate(`/quizzes/create?category=${categoryId}&count=${count}`)
return (
<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>
</div>
<div className="an-tabs" role="tablist">
<button className="an-tab" role="tab" aria-selected={tab === 'performance'} onClick={() => setTab('performance')}>
Performance
</button>
<button className="an-tab" role="tab" aria-selected={tab === 'recommendations'} onClick={() => setTab('recommendations')}>
Recommendations
</button>
</div>
{error && <p className="an-empty" role="alert">{error}</p>}
{loading && !data ? (
<div className="loading"><div className="spinner" /> Loading</div>
) : !data ? null : (
<>
<div className="an-summary">
<div className="an-stat">
<span className="an-stat-value">{data.unlocked && data.overall_accuracy !== null ? `${data.overall_accuracy}%` : '—'}</span>
<span className="an-stat-label">Overall accuracy</span>
{!data.unlocked && <span className="an-stat-sub">Unlocks after {data.answers_needed} more answers</span>}
</div>
<div className="an-stat">
<span className="an-stat-value">{data.unique_questions_seen}<span style={{ fontSize: '0.9rem', color: 'var(--text-muted)' }}>/{data.bank_total}</span></span>
<span className="an-stat-label">Questions seen</span>
</div>
<div className="an-stat">
<span className="an-stat-value">{data.total_answered}</span>
<span className="an-stat-label">Answers recorded</span>
</div>
<div className="an-stat">
<span className="an-stat-value">{data.focus_areas.filter(r => r.is_focus_area).length}</span>
<span className="an-stat-label">Focus areas</span>
</div>
</div>
{!data.unlocked && (
<div className="an-locked">
<strong>Readiness is still locked</strong>
Answer {data.answers_needed} more question{data.answers_needed !== 1 ? 's' : ''} and each topic gets a
readiness estimate. Until then the table ranks topics by raw accuracy and how much of the bank they cover.
</div>
)}
<div className="an-nextstep">
<div className="an-nextstep-text">
<h2>Next step: adaptive session</h2>
<p>Practises what you have not seen first, then the topics you get wrong most.</p>
</div>
<div className="an-nextstep-controls">
<label>
<span className="sr-only">Questions</span>
<select value={count} aria-label="Questions" onChange={e => setCount(Number(e.target.value))}>
{[5, 10, 15, 20, 30].map(n => <option key={n} value={n}>{n} questions</option>)}
</select>
</label>
<button className="btn btn-primary" onClick={startAdaptive}>Start adaptive session</button>
</div>
</div>
{tab === 'recommendations' ? (
<>
<div className="an-group-toggle">
{[['systems', 'Systems'], ['subtopics', 'Subtopics']].map(([value, label]) => (
<button key={value} aria-pressed={group === value} onClick={() => setGroup(value)}>{label}</button>
))}
</div>
{data.focus_areas.length === 0 ? (
<div className="an-empty">No categorised questions yet once questions are filed under a system, your focus areas appear here.</div>
) : (
<>
<div className="an-focus-head">
<span />
<span>Topic</span>
<span>Readiness</span>
<span>Relevance</span>
<span>Status</span>
</div>
<div className="an-focus-list">
{data.focus_areas.map(row => (
<FocusRow key={row.category_id} row={row} onStartCategory={startCategory} />
))}
</div>
</>
)}
<p className="an-basis">{data.basis}</p>
</>
) : (
<CategoryPerformance />
)}
</>
)}
</div>
)
}

View file

@ -0,0 +1,73 @@
import { render, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { MemoryRouter } from 'react-router-dom'
import { expect, it, vi, beforeEach } from 'vitest'
import AnalysisPage from './AnalysisPage'
import api from '../api/client'
vi.mock('../api/client', () => ({ default: { get: vi.fn() } }))
vi.mock('../components/CategoryPerformance', () => ({ default: () => <div data-testid="category-performance" /> }))
const area = (over = {}) => ({
category_id: 1, name: 'Cardiology', parent_id: null, parent_name: null,
answered: 10, correct: 5, seen_questions: 8, available: 40,
coverage: 20, accuracy: 50, readiness: 55, relevance: 12,
status: 'focus', is_focus_area: true, article_id: 7, article_title: 'Kawasaki disease', ...over,
})
const payload = (over = {}) => ({
group: 'systems', unlocked: true, answers_needed: 0, total_answered: 60,
unique_questions_seen: 42, bank_total: 330, overall_accuracy: 64.0,
focus_areas: [area()], basis: 'Readiness shrinks each category toward your overall accuracy.', ...over,
})
beforeEach(() => {
vi.clearAllMocks()
api.get.mockResolvedValue({ data: payload() })
})
it('summarises readiness and ranks focus areas', async () => {
render(<MemoryRouter><AnalysisPage /></MemoryRouter>)
expect(await screen.findByText('64%')).toBeInTheDocument()
expect(screen.getByText('42')).toBeInTheDocument() // questions seen
expect(screen.getByText('60')).toBeInTheDocument() // answers recorded
const row = screen.getByText('Cardiology').closest('.an-focus')
expect(within(row).getByText('55%')).toBeInTheDocument() // readiness
expect(within(row).getByText('12%')).toBeInTheDocument() // relevance
expect(within(row).getByText('Focus area', { selector: '.an-status' })).toBeInTheDocument()
})
it('expands a focus area to show coverage and the next step', async () => {
render(<MemoryRouter><AnalysisPage /></MemoryRouter>)
await screen.findByText('Cardiology')
await userEvent.click(screen.getByRole('button', { expanded: false, name: /Cardiology/ }))
expect(screen.getByText('8/40')).toBeInTheDocument()
expect(screen.getByText('50% (5 of 10)')).toBeInTheDocument()
expect(screen.getByRole('link', { name: /Read Kawasaki disease/ })).toHaveAttribute('href', '/articles/7')
expect(screen.getByRole('button', { name: 'Practise this topic' })).toBeInTheDocument()
})
it('explains that readiness is locked until enough answers exist', async () => {
api.get.mockResolvedValue({ data: payload({ unlocked: false, answers_needed: 12, overall_accuracy: null }) })
render(<MemoryRouter><AnalysisPage /></MemoryRouter>)
expect(await screen.findByText('Readiness is still locked')).toBeInTheDocument()
expect(screen.getByText(/Answer 12 more questions/)).toBeInTheDocument()
})
it('reloads focus areas when the grouping changes', async () => {
render(<MemoryRouter><AnalysisPage /></MemoryRouter>)
await screen.findByText('Cardiology')
await userEvent.click(screen.getByRole('button', { name: 'Subtopics' }))
await waitFor(() => expect(api.get).toHaveBeenLastCalledWith('/study-tools/recommendations',
{ params: { group: 'subtopics' } }))
})
it('switches to the performance tab', async () => {
render(<MemoryRouter><AnalysisPage /></MemoryRouter>)
await screen.findByText('Cardiology')
await userEvent.click(screen.getByRole('tab', { name: 'Performance' }))
expect(screen.getByTestId('category-performance')).toBeInTheDocument()
expect(screen.queryByText('Cardiology')).not.toBeInTheDocument()
})

View file

@ -126,3 +126,35 @@
.qm-bulk { left: 0; right: 0; bottom: 0; width: 100%; transform: none; border-radius: 14px 14px 0 0; padding-bottom: calc(10px + env(safe-area-inset-bottom)); }
.qm-bulk-spacer { display: none; }
}
/* ── Grant scope banner ───────────────────────────────────────────── */
.qm-scope { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; margin-bottom: 12px; }
.qm-scope-label { font-size: 0.7rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; color: var(--text-subtle); }
/* ── Educator grants panel ────────────────────────────────────────── */
.qm-grants { background: var(--card-bg); border: 1px solid var(--border); border-radius: 10px; margin-top: 18px; }
.qm-grants-toggle {
display: flex; justify-content: space-between; align-items: center; width: 100%;
background: none; border: none; padding: 13px 15px; font: inherit; font-weight: 650;
font-size: 0.92rem; color: var(--text); cursor: pointer;
}
.qm-grants-body { padding: 0 15px 15px; border-top: 1px solid var(--border); }
.qm-grants-note { color: var(--text-muted); font-size: 0.82rem; line-height: 1.6; margin: 10px 0; }
.qm-grants-form { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; margin-bottom: 10px; }
.qm-grants-form select {
flex: 1; min-width: 160px; padding: 7px 10px; border: 1px solid var(--border);
border-radius: 8px; background: var(--input-bg); color: var(--text); font-size: 0.85rem;
}
.qm-grants-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 7px; }
.qm-grants-list li {
display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
padding: 9px 11px; background: var(--bg); border-radius: 8px; font-size: 0.85rem;
}
.qm-grant-user { font-weight: 650; }
.qm-grant-email { color: var(--text-muted); font-size: 0.78rem; flex: 1; min-width: 120px; }
@media (max-width: 720px) {
.qm-grants-form select { min-width: 100%; }
.qm-grants-form .btn { width: 100%; }
.qm-grants-list li .btn { flex: 1; }
}

View file

@ -1,7 +1,9 @@
import { useState, useEffect, useCallback, useMemo, useRef } from 'react'
import { Link } from 'react-router-dom'
import api from '../api/client'
import { useAuth } from '../context/AuthContext'
import { QuestionEditModal, CreateQuestionModal } from '../components/QuestionEditors'
import GrantsPanel from '../components/GrantsPanel'
import './QuestionManagerPage.css'
const PAGE_SIZE = 25
@ -26,6 +28,9 @@ const TILES = [
]
export default function QuestionManagerPage() {
const { user } = useAuth()
const isModerator = user?.role === 'admin' || user?.role === 'moderator'
const [scope, setScope] = useState(null)
const [questions, setQuestions] = useState([])
const [total, setTotal] = useState(0)
const [summary, setSummary] = useState(null)
@ -76,6 +81,7 @@ export default function QuestionManagerPage() {
useEffect(() => { loadSummary() }, [loadSummary])
useEffect(() => {
api.get('/question-categories').then(res => setCategories(res.data || [])).catch(() => setCategories([]))
api.get('/question-categories/my-grants').then(res => setScope(res.data)).catch(() => setScope(null))
}, [])
const refresh = () => { load(); loadSummary(); setSelected(new Set()) }
@ -136,7 +142,11 @@ export default function QuestionManagerPage() {
<div className="qm-header">
<div>
<h1>Question manager</h1>
<p>Review, categorise and fix gaps across the whole question bank.</p>
<p>
{scope && !scope.is_moderator
? `Your editorial grants cover ${scope.categories.length} categor${scope.categories.length === 1 ? 'y' : 'ies'}.`
: 'Review, categorise and fix gaps across the whole question bank.'}
</p>
</div>
<div className="qm-header-actions">
<Link className="btn btn-secondary" to="/question-bank">Open question bank</Link>
@ -144,6 +154,13 @@ export default function QuestionManagerPage() {
</div>
</div>
{scope && !scope.is_moderator && scope.categories.length > 0 && (
<div className="qm-scope" role="note">
<span className="qm-scope-label">Your categories</span>
{scope.categories.map(c => <span key={c.id} className="qm-pill">{c.name}</span>)}
</div>
)}
<div className="qm-health">
{TILES.map(tile => (
<button key={tile.label} className={`qm-tile${tile.warn ? ' is-warn' : ''}`}
@ -234,6 +251,8 @@ export default function QuestionManagerPage() {
</div>
)}
{isModerator && <GrantsPanel categories={categories} />}
{selected.size > 0 && (
<div className="qm-bulk" role="region" aria-label="Bulk actions">
<span className="qm-bulk-count">{selected.size} selected</span>

View file

@ -6,6 +6,8 @@ import QuestionManagerPage from './QuestionManagerPage'
import api from '../api/client'
vi.mock('../api/client', () => ({ default: { get: vi.fn(), post: vi.fn(), patch: vi.fn(), delete: vi.fn() } }))
vi.mock('../context/AuthContext', () => ({ useAuth: () => ({ user: { id: 1, role: 'moderator' } }) }))
vi.mock('../components/GrantsPanel', () => ({ default: () => <div data-testid="grants-panel" /> }))
const SUMMARY = { total: 40, uncategorized: 7, no_explanation: 3, no_difficulty: 12, private: 2, mine: 5 }
const QUESTIONS = [
@ -17,6 +19,9 @@ const mockApi = (questions = QUESTIONS, total = questions.length) => {
api.get.mockImplementation(url => {
if (url === '/questions/manage/summary') return Promise.resolve({ data: SUMMARY })
if (url === '/questions/bank') return Promise.resolve({ data: { total, questions } })
if (url === '/question-categories/my-grants') {
return Promise.resolve({ data: { is_moderator: true, can_manage_questions: true, categories: [] } })
}
return Promise.resolve({ data: [{ id: 9, name: 'Cardiology' }] })
})
}

View file

@ -1107,9 +1107,24 @@ const timerStarted = timeLeft !== null
boxShadow: activeReadForCurrent ? '0 0 0 3px rgba(59, 130, 246, 0.22)' : undefined,
borderColor: activeReadForCurrent ? '#60a5fa' : undefined,
}}>
<nav className="quiz-breadcrumbs" aria-label="Question categories">{current.category_breadcrumbs?.length ? current.category_breadcrumbs.map((category, index) => <span key={category.id}>{index > 0 && <span aria-hidden="true"> </span>}<Link to={`/quizzes/create?category=${category.id}`} target="_blank" rel="noopener noreferrer">{category.name}</Link></span>) : <span>Uncategorized</span>}</nav>
<div className="quiz-stem" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 8, gap: 12 }}>
<h3 id="quiz-question-heading" style={{ marginBottom: 0, flex: 1 }}>
<div className="quiz-qmeta">
<nav className="quiz-breadcrumbs" aria-label="Question categories">
{current.category_breadcrumbs?.length
? current.category_breadcrumbs.map((category, index) => (
<span key={category.id}>
{index > 0 && <span className="quiz-breadcrumb-sep" aria-hidden="true"></span>}
<Link to={`/quizzes/create?category=${category.id}`} target="_blank" rel="noopener noreferrer">{category.name}</Link>
</span>
))
: <span>Uncategorized</span>}
</nav>
{current.difficulty && <span className={`quiz-meta-pill is-${current.difficulty}`}>{current.difficulty}</span>}
<span className="quiz-meta-pill">
{current.question_type === 'mcq' ? 'Multiple choice' : current.question_type === 'true_false' ? 'True / False' : 'Fill in the blank'}
</span>
</div>
<div className="quiz-stem">
<h3 id="quiz-question-heading" style={{ marginBottom: 0 }}>
<ManualHighlightText
text={questionStem(current)}
textId={`${current.id}::question`}
@ -1118,25 +1133,13 @@ const timerStarted = timeLeft !== null
onRemoveHighlight={removeJoinedHighlight}
/>
</h3>
<button
onClick={() => toggleFavorite(current.id)}
title={favorites.includes(current.id) ? 'Remove from favorites' : 'Add to favorites'}
style={{
background: 'none',
border: 'none',
cursor: 'pointer',
fontSize: '1.4rem',
padding: 4,
lineHeight: 1,
transition: 'transform 0.1s',
}}
onMouseEnter={e => e.currentTarget.style.transform = 'scale(1.15)'}
onMouseLeave={e => e.currentTarget.style.transform = 'none'}
>
{favorites.includes(current.id) ? '⭐' : '☆'}
</button>
</div>
<div style={{ marginBottom: 10, display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'center' }}>
<div className="quiz-actionbar" role="toolbar" aria-label="Question actions">
<button type="button" className={favorites.includes(current.id) ? 'is-on' : ''}
onClick={() => toggleFavorite(current.id)}
title={favorites.includes(current.id) ? 'Remove from favorites' : 'Add to favorites'}>
{favorites.includes(current.id) ? '★' : '☆'} <span>Mark</span>
</button>
{voices.length > 0 && (
<TTSButton
key={`${current.id}_${selectedVoice || 'default'}`}
@ -1160,23 +1163,19 @@ const timerStarted = timeLeft !== null
/>
)}
{voices.length > 0 && (
<button
className={`btn btn-sm ${readThrough ? 'btn-primary' : 'btn-secondary'}`}
<button type="button" className={readThrough ? 'is-on' : ''}
onClick={() => setReadThrough(v => !v)}
title="Read each question aloud and advance automatically"
>
{readThrough ? 'Stop listen-through' : 'Listen through'}
title="Read each question aloud and advance automatically">
<span>{readThrough ? 'Stop' : 'Listen through'}</span>
</button>
)}
<div className="manual-highlight-toolbar" aria-label="Question highlight tools">
<button className="btn btn-secondary btn-sm" onMouseDown={e => e.preventDefault()} onClick={clearCurrentHighlights} disabled={!manualHighlights[current.id]} title="Clear all highlights on this question">
Clear
<button type="button" onMouseDown={e => e.preventDefault()} onClick={clearCurrentHighlights}
disabled={!manualHighlights[current.id]} title="Clear all highlights on this question">
<span>Clear</span>
</button>
</div>
</div>
<span className="badge" style={{ background: '#e0e7ff', color: '#3730a3', margin: '6px 0 12px', display: 'inline-block' }}>
{current.question_type === 'mcq' ? 'Multiple Choice' : current.question_type === 'true_false' ? 'True / False' : 'Fill in the Blank'}
</span>
{current.image_path && (
<button className="question-image-preview" onClick={() => { setImageZoom(1); setExpandedImagePath(current.image_path) }} title="Expand image" type="button">
<img src={uploadUrl(current.image_path, attemptId)} alt="Question illustration"

View file

@ -11,7 +11,7 @@ vi.mock('../components/MyNote', () => ({ default: () => null }))
vi.mock('../components/TeachChat', () => ({ default: ({ attemptId }) => <div data-testid="tutor-context">Study tutor {attemptId}</div> }))
const questions = [
{ id: 1, question_text: 'Full first clinical question.', question_type: 'mcq', options: ['First answer', 'Second answer'], category_breadcrumbs: [{ id: 10, name: 'Pediatrics' }, { id: 11, name: 'Neonatology' }] },
{ id: 1, question_text: 'Full first clinical question.', question_type: 'mcq', difficulty: 'hard', options: ['First answer', 'Second answer'], category_breadcrumbs: [{ id: 10, name: 'Pediatrics' }, { id: 11, name: 'Neonatology' }] },
{ id: 2, question_text: 'Full second clinical question.', question_type: 'mcq', options: ['Third answer', 'Fourth answer'], category_breadcrumbs: [] },
]
let mode
@ -57,6 +57,22 @@ async function begin(study = true) {
}
describe('quiz player', () => {
it('keeps question metadata on one compact strip with a single action bar', async () => {
await begin()
// The category trail used to be a tall wrapping block above the stem.
const meta = document.querySelector('.quiz-qmeta')
expect(meta).toBeInTheDocument()
expect(within(meta).getByRole('link', { name: 'Pediatrics' })).toBeInTheDocument()
expect(within(meta).getByRole('link', { name: 'Neonatology' })).toBeInTheDocument()
expect(within(meta).getByText('hard')).toBeInTheDocument()
expect(within(meta).getByText('Multiple choice')).toBeInTheDocument()
// Mark moved off the stem into the action bar, so the stem is text only.
const bar = screen.getByRole('toolbar', { name: 'Question actions' })
expect(within(bar).getByTitle('Add to favorites')).toBeInTheDocument()
expect(document.querySelector('.quiz-stem button')).toBeNull()
})
it('retries failed resume without offering Start or clearing saved answers', async () => {
mode = 'study'
const originalGet = api.get.getMockImplementation()

View file

@ -20,10 +20,42 @@
.quiz-top-actions .quiz-review-button { background: #496fa5; color: white; font-weight: 650; text-transform: uppercase; padding: 12px 18px; margin-left: 12px; }
.quiz-player button:focus-visible, .quiz-player a:focus-visible, .quiz-results a:focus-visible { outline: 3px solid #779ad1; outline-offset: 3px; }
.quiz-player .question-card { border: 0; padding: 0; border-radius: 0; box-shadow: none; margin-bottom: 22px; }
.quiz-breadcrumbs { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; min-height: 78px; padding: 20px 0; font-size: .95rem; color: #737982; }
/* One compact meta strip the category trail used to be a 78px block that
wrapped into several rows and pushed the stem off the screen. */
.quiz-qmeta { display: flex; align-items: center; gap: 8px; padding: 14px 0 10px; min-width: 0; }
.quiz-breadcrumbs {
display: flex; align-items: center; gap: 6px; min-width: 0; flex: 1;
font-size: .82rem; color: #737982;
white-space: nowrap; overflow-x: auto; scrollbar-width: none;
}
.quiz-breadcrumbs::-webkit-scrollbar { display: none; }
.quiz-breadcrumbs > span { display: inline-flex; align-items: center; gap: 6px; }
.quiz-breadcrumb-sep { color: #b6bcc6; }
.quiz-breadcrumbs a { color: #416fa7; text-decoration: none; }
.quiz-breadcrumbs a:hover { text-decoration: underline; }
.quiz-player .quiz-stem { padding: 12px 0 24px; }
.quiz-meta-pill {
flex-shrink: 0; font-size: .68rem; font-weight: 650; letter-spacing: .04em; text-transform: uppercase;
border: 1px solid #dfe3ea; border-radius: 999px; padding: 3px 10px; color: #6b727d; background: #f8f9fb;
}
.quiz-meta-pill.is-easy { background: #eef7f3; border-color: #bfe0d2; color: #327b64; }
.quiz-meta-pill.is-medium { background: #fdf6e8; border-color: #ecd9ae; color: #8a6417; }
.quiz-meta-pill.is-hard { background: #fbecf0; border-color: #e9bfca; color: #a13c51; }
/* Question action bar — one row of icon buttons instead of stacked strips. */
.quiz-actionbar {
display: flex; align-items: center; gap: 4px; flex-wrap: wrap;
padding: 10px 0; margin: 0 0 4px; border-top: 1px solid var(--border);
}
.quiz-actionbar button, .quiz-actionbar .manual-highlight-toolbar button {
display: inline-flex; align-items: center; gap: 6px;
background: transparent; border: 1px solid transparent; border-radius: 3px;
padding: 7px 10px; font: inherit; font-size: .78rem; font-weight: 600;
letter-spacing: .03em; text-transform: uppercase; color: #6b727d; cursor: pointer;
}
.quiz-actionbar button:hover:not(:disabled) { background: #f4f6fb; color: #333; }
.quiz-actionbar button:disabled { opacity: .4; cursor: not-allowed; }
.quiz-actionbar button.is-on { background: #e5ecf8; color: #365b8d; }
.quiz-player .quiz-stem { padding: 4px 0 14px; }
.quiz-player .question-card h3 { font-size: 1.08rem; line-height: 1.65; font-weight: 400; font-family: inherit; }
.quiz-player .question-card .options { gap: 0; margin-top: 24px !important; padding: 18px 0 8px; border-top: 1px solid var(--border); }
.quiz-player .question-card .option { padding: 15px 12px; border: 0; border-radius: 0; gap: 18px; font: inherit; font-size: 1rem; text-align: left; background: white; }
@ -64,7 +96,10 @@
.quiz-top-actions button[aria-label="Next question"] { font-size: 0; }
.quiz-top-actions button[aria-label="Next question"]::after { content: ''; font-size: 1.25rem; }
.quiz-top-actions .quiz-review-button { padding: 9px; margin-left: 0; font-size: .73rem; }
.quiz-breadcrumbs { min-height: 60px; font-size: .86rem; }
.quiz-qmeta { padding: 10px 0 8px; }
.quiz-breadcrumbs { font-size: .76rem; }
.quiz-actionbar button span, .quiz-actionbar .manual-highlight-toolbar button span { display: none; }
.quiz-actionbar button { padding: 8px 9px; font-size: .9rem; }
.quiz-player .question-card .option { gap: 10px; padding: 13px 8px; }
.quiz-player .option-status { margin-left: 45px; }
.quiz-response-stat { flex-wrap: wrap; gap: 8px; }