diff --git a/backend/alembic/versions/n7a8b9c0d142_collections.py b/backend/alembic/versions/n7a8b9c0d142_collections.py
new file mode 100644
index 0000000..b6a5bc6
--- /dev/null
+++ b/backend/alembic/versions/n7a8b9c0d142_collections.py
@@ -0,0 +1,33 @@
+"""Personal question libraries.
+
+Revision ID: n7a8b9c0d142
+Revises: m6a7b8c9d031
+"""
+from alembic import op
+
+revision = "n7a8b9c0d142"
+down_revision = "m6a7b8c9d031"
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+ op.execute("""
+ CREATE TABLE IF NOT EXISTS user_collections (
+ id SERIAL PRIMARY KEY,
+ user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ title VARCHAR(200) NOT NULL,
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
+ )""")
+ op.execute("""
+ CREATE TABLE IF NOT EXISTS user_collection_questions (
+ id SERIAL PRIMARY KEY,
+ collection_id INTEGER NOT NULL REFERENCES user_collections(id) ON DELETE CASCADE,
+ question_id INTEGER NOT NULL REFERENCES questions(id) ON DELETE CASCADE,
+ CONSTRAINT uq_collection_question UNIQUE (collection_id, question_id)
+ )""")
+
+
+def downgrade():
+ op.execute("DROP TABLE IF EXISTS user_collection_questions")
+ op.execute("DROP TABLE IF EXISTS user_collections")
diff --git a/backend/app/main.py b/backend/app/main.py
index 6d00537..f423cf0 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -11,7 +11,7 @@ from app.logging_config import setup_logging
setup_logging(settings.LOG_LEVEL)
from app.database import engine, Base, SessionLocal
from app.routers import auth, documents, quizzes, attempts, admin, tts, nextcloud, categories, questions, question_categories, favorites, teach, contact, tags, flashcards, courses, mobile, mynote
-from app.routers import study_tools, uploads, articles, comments, share
+from app.routers import study_tools, uploads, articles, comments, share, collections
from app.utils.auth import get_password_hash
from app.utils.scheduler import start_scheduler, stop_scheduler
@@ -619,6 +619,7 @@ app.include_router(auth.router, prefix="/api/auth", tags=["auth"])
app.include_router(articles.router, prefix="/api/articles", tags=["articles"])
app.include_router(comments.router, prefix="/api/comments", tags=["comments"])
app.include_router(share.router, prefix="/api/share", tags=["share"])
+app.include_router(collections.router, prefix="/api/collections", tags=["collections"])
app.include_router(documents.router, prefix="/api/documents", tags=["documents"])
app.include_router(quizzes.router, prefix="/api/quizzes", tags=["quizzes"])
app.include_router(attempts.router, prefix="/api/attempts", tags=["attempts"])
diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py
index cba7335..4eba7fc 100644
--- a/backend/app/models/__init__.py
+++ b/backend/app/models/__init__.py
@@ -13,6 +13,7 @@ from app.models.article import Article, QuestionArticleLink
from app.models.comment import Comment
from app.models.flashcard import FlashcardDeck, Flashcard, FlashcardDeckRating, FlashcardQuestionLink, FlashcardArticleLink
from app.models.question_category import QuestionCategory, QuestionCategoryLink
+from app.models.collection import UserCollection, UserCollectionQuestion
__all__ = [
"User",
@@ -38,4 +39,6 @@ __all__ = [
"FlashcardArticleLink",
"QuestionCategory",
"QuestionCategoryLink",
+ "UserCollection",
+ "UserCollectionQuestion",
]
diff --git a/backend/app/models/collection.py b/backend/app/models/collection.py
new file mode 100644
index 0000000..c669ffd
--- /dev/null
+++ b/backend/app/models/collection.py
@@ -0,0 +1,22 @@
+from datetime import datetime
+
+from sqlalchemy import Column, DateTime, ForeignKey, Integer, String, UniqueConstraint
+from app.database import Base
+
+
+class UserCollection(Base):
+ __tablename__ = "user_collections"
+
+ id = Column(Integer, primary_key=True, index=True)
+ user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
+ title = Column(String(200), nullable=False)
+ created_at = Column(DateTime, default=datetime.utcnow)
+
+
+class UserCollectionQuestion(Base):
+ __tablename__ = "user_collection_questions"
+ __table_args__ = (UniqueConstraint("collection_id", "question_id", name="uq_collection_question"),)
+
+ id = Column(Integer, primary_key=True, index=True)
+ collection_id = Column(Integer, ForeignKey("user_collections.id", ondelete="CASCADE"), nullable=False)
+ question_id = Column(Integer, ForeignKey("questions.id", ondelete="CASCADE"), nullable=False)
diff --git a/backend/app/routers/collections.py b/backend/app/routers/collections.py
new file mode 100644
index 0000000..83689d1
--- /dev/null
+++ b/backend/app/routers/collections.py
@@ -0,0 +1,100 @@
+"""Personal question libraries (saved questions)."""
+from fastapi import APIRouter, Depends, HTTPException
+from pydantic import BaseModel, field_validator
+from sqlalchemy.orm import Session
+
+from app.database import get_db
+from app.models.collection import UserCollection, UserCollectionQuestion
+from app.models.question import Question
+from app.models.user import User
+from app.services.quiz_builder import bank_question_predicate
+from app.utils.auth import get_current_user
+
+router = APIRouter()
+
+
+class CollectionCreate(BaseModel):
+ title: str
+
+ @field_validator("title")
+ @classmethod
+ def title_shape(cls, value):
+ value = value.strip()
+ if not value or len(value) > 200:
+ raise ValueError("Collection title is required (max 200 characters)")
+ return value
+
+
+class CollectionQuestionIn(BaseModel):
+ question_id: int
+
+
+def _own(db, user, collection_id):
+ collection = db.get(UserCollection, collection_id)
+ if not collection:
+ raise HTTPException(404, "Collection not found")
+ if collection.user_id != user.id:
+ raise HTTPException(403, "Not your collection")
+ return collection
+
+
+@router.get("/")
+def list_collections(db: Session = Depends(get_db), user: User = Depends(get_current_user)):
+ rows = db.query(UserCollection).filter(UserCollection.user_id == user.id).order_by(
+ UserCollection.created_at.desc()).all()
+ return [{"id": c.id, "title": c.title, "question_count": db.query(UserCollectionQuestion).filter(
+ UserCollectionQuestion.collection_id == c.id).count()} for c in rows]
+
+
+@router.post("/", status_code=201)
+def create_collection(data: CollectionCreate, db: Session = Depends(get_db), user: User = Depends(get_current_user)):
+ collection = UserCollection(user_id=user.id, title=data.title)
+ db.add(collection)
+ db.commit()
+ db.refresh(collection)
+ return {"id": collection.id, "title": collection.title, "question_count": 0}
+
+
+@router.patch("/{collection_id}")
+def rename_collection(collection_id: int, data: CollectionCreate, db: Session = Depends(get_db),
+ user: User = Depends(get_current_user)):
+ collection = _own(db, user, collection_id)
+ collection.title = data.title
+ db.commit()
+ return {"id": collection.id, "title": collection.title}
+
+
+@router.delete("/{collection_id}", status_code=204)
+def delete_collection(collection_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)):
+ db.delete(_own(db, user, collection_id))
+ db.commit()
+
+
+@router.get("/{collection_id}/questions")
+def collection_questions(collection_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)):
+ collection = _own(db, user, collection_id)
+ rows = db.query(Question).join(UserCollectionQuestion, UserCollectionQuestion.question_id == Question.id).filter(
+ UserCollectionQuestion.collection_id == collection.id).all()
+ return [{"id": q.id, "question_text": q.question_text} for q in rows]
+
+
+@router.put("/{collection_id}/questions/{question_id}")
+def add_collection_question(collection_id: int, question_id: int, db: Session = Depends(get_db),
+ user: User = Depends(get_current_user)):
+ collection = _own(db, user, collection_id)
+ if not db.query(Question.id).filter(Question.id == question_id, bank_question_predicate(user)).first():
+ raise HTTPException(404, "Question not found")
+ if db.query(UserCollectionQuestion.id).filter_by(collection_id=collection.id, question_id=question_id).first():
+ return {"added": False}
+ db.add(UserCollectionQuestion(collection_id=collection.id, question_id=question_id))
+ db.commit()
+ return {"added": True}
+
+
+@router.delete("/{collection_id}/questions/{question_id}", status_code=204)
+def remove_collection_question(collection_id: int, question_id: int, db: Session = Depends(get_db),
+ user: User = Depends(get_current_user)):
+ _own(db, user, collection_id)
+ db.query(UserCollectionQuestion).filter_by(collection_id=collection_id, question_id=question_id).delete(
+ synchronize_session=False)
+ db.commit()
diff --git a/backend/app/routers/question_categories.py b/backend/app/routers/question_categories.py
index d8c59c4..e708304 100644
--- a/backend/app/routers/question_categories.py
+++ b/backend/app/routers/question_categories.py
@@ -1,7 +1,7 @@
"""Question category management; saved quiz membership is never changed here."""
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
-from sqlalchemy import or_, select
+from sqlalchemy import or_, select, func
from sqlalchemy.orm import Session
from app.database import get_db
@@ -9,7 +9,7 @@ from app.models.question import Question
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)
+ validate_parent, GenerateTestRequest, generate_test, bank_question_predicate)
from app.utils.auth import get_current_user, require_moderator
router = APIRouter()
@@ -32,21 +32,50 @@ class QCatResponse(BaseModel):
def category_response(db, user, cat, cats):
ids = category_descendants(cats, [cat.id])
- extra = select(QuestionCategoryLink.question_id).where(QuestionCategoryLink.category_id.in_(ids))
- count = bank_query(db, user).filter(or_(
- Question.question_category_id.in_(ids),
- Question.id.in_(extra),
- )).count()
return QCatResponse(id=cat.id, name=cat.name, description=cat.description, parent_id=cat.parent_id,
- breadcrumbs=category_breadcrumbs(cats, cat.id),
- question_count=count)
+ breadcrumbs=category_breadcrumbs(cats, cat.id), question_count=0)
+
+
+def _category_totals(db, user, by_id):
+ """Distinct question totals per category via ancestor set unions (fast, no N+1)."""
+ from collections import defaultdict
+ rows = bank_query(db, user).with_entities(Question.id, Question.question_category_id).all()
+ qids = [row[0] for row in rows]
+ direct = defaultdict(set)
+ for qid, cid in rows:
+ if cid is not None:
+ direct[cid].add(qid)
+ if qids:
+ for qid, cid in db.query(QuestionCategoryLink.question_id, QuestionCategoryLink.category_id).filter(
+ QuestionCategoryLink.question_id.in_(qids)).all():
+ direct[cid].add(qid)
+ ancestors = {}
+ for cat_id in by_id:
+ path, seen = [cat_id], set()
+ node = by_id[cat_id]
+ while node.parent_id and node.parent_id in by_id and node.parent_id not in seen:
+ seen.add(node.parent_id)
+ path.append(node.parent_id)
+ node = by_id[node.parent_id]
+ ancestors[cat_id] = path
+ totals = defaultdict(set)
+ for cid, question_set in direct.items():
+ for ancestor in ancestors.get(cid, [cid]):
+ totals[ancestor] |= question_set
+ return {cid: len(question_set) for cid, question_set in totals.items()}
@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()
- # ponytail: one count per category; aggregate counts if the taxonomy becomes large.
- return [category_response(db, current_user, cat, cats) for cat in cats]
+ by_id = {cat.id: cat for cat in cats}
+ totals = _category_totals(db, current_user, by_id)
+ return [
+ QCatResponse(id=cat.id, name=cat.name, description=cat.description, parent_id=cat.parent_id,
+ breadcrumbs=category_breadcrumbs(cats, cat.id),
+ question_count=totals.get(cat.id, 0))
+ for cat in cats
+ ]
def validate_category(db, data, cat_id=None):
diff --git a/backend/app/routers/questions.py b/backend/app/routers/questions.py
index 12cc6eb..bf94ec6 100644
--- a/backend/app/routers/questions.py
+++ b/backend/app/routers/questions.py
@@ -234,6 +234,8 @@ def get_question_bank(
uncategorized: bool = Query(False),
favorites_only: bool = Query(False),
my_questions: bool = Query(False, description="Show only questions created by current user"),
+ difficulty: Literal["easy", "medium", "hard"] | None = Query(None),
+ article_ids: str | None = Query(None, description="Comma-separated article IDs (OR filter)"),
tag_ids: str | None = Query(None, description="Comma-separated tag IDs (AND filter)"),
search_mode: str = Query("hybrid"), # "keyword" | "semantic" | "hybrid"
limit: int = Query(50, le=200),
@@ -246,6 +248,15 @@ def get_question_bank(
if my_questions:
query = query.filter(Question.user_id == current_user.id)
+ if difficulty:
+ query = query.filter(Question.difficulty == difficulty)
+ if article_ids:
+ article_list = [int(part) for part in article_ids.split(",") if part.strip().isdigit()]
+ if article_list:
+ from app.models.article import QuestionArticleLink
+ query = query.filter(Question.id.in_(select(QuestionArticleLink.question_id).where(
+ QuestionArticleLink.article_id.in_(article_list))))
+
if quiz_id:
query = query.filter(Question.source_quiz_id == quiz_id)
@@ -504,10 +515,14 @@ def count_builder_questions(
state: Literal["all", "unused", "incorrect", "bookmarked"] = "all",
is_shared: bool = False,
difficulty: Literal["easy", "medium", "hard"] | None = Query(None),
+ article_ids: str | None = Query(None, description="Comma-separated article IDs (OR filter)"),
+ tag_ids: str | None = Query(None, description="Comma-separated tag IDs (AND filter)"),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
- return {"count": filtered_bank_query(db, current_user, category_ids, state, is_shared, difficulty).count()}
+ ids = [int(part) for part in (article_ids or "").split(",") if part.strip().isdigit()]
+ tag_list = [int(part) for part in (tag_ids or "").split(",") if part.strip().isdigit()]
+ return {"count": filtered_bank_query(db, current_user, category_ids, state, is_shared, difficulty, ids, tag_list).count()}
@router.post("/builder")
diff --git a/backend/app/services/quiz_builder.py b/backend/app/services/quiz_builder.py
index 91d274f..1a292be 100644
--- a/backend/app/services/quiz_builder.py
+++ b/backend/app/services/quiz_builder.py
@@ -66,10 +66,27 @@ def bank_query(db, user):
return db.query(Question).filter(bank_question_predicate(user))
-def filtered_bank_query(db, user, category_ids=(), state="all", shared=False, difficulty=None):
+def filtered_bank_query(db, user, category_ids=(), state="all", shared=False, difficulty=None, article_ids=(), tag_ids=()):
query = bank_query(db, user)
if difficulty:
query = query.filter(Question.difficulty == difficulty)
+ if article_ids:
+ from app.models.article import QuestionArticleLink
+ query = query.filter(Question.id.in_(select(QuestionArticleLink.question_id).where(
+ QuestionArticleLink.article_id.in_(article_ids))))
+ if tag_ids:
+ from sqlalchemy import text as sa_text
+ tag_list = list(dict.fromkeys(tag_ids))
+ matching = list(db.execute(sa_text("""
+ SELECT question_id FROM question_tag_links
+ WHERE tag_id = ANY(:tag_ids)
+ GROUP BY question_id
+ HAVING COUNT(DISTINCT tag_id) = :cnt
+ """), {"tag_ids": tag_list, "cnt": len(tag_list)}).scalars())
+ if matching:
+ query = query.filter(Question.id.in_(matching))
+ else:
+ query = query.filter(Question.id.is_(None)) # No questions match all tags.
if category_ids:
ids = category_descendants(db.query(QuestionCategory).all(), category_ids)
query = query.filter(or_(
@@ -127,6 +144,9 @@ class GenerateTestRequest(TestOptions):
expected_count: int | None = Field(default=None, ge=0)
difficulty: Literal["easy", "medium", "hard"] | None = None
algorithm: Literal["random", "adaptive"] = "random"
+ article_ids: list[int] = Field(default_factory=list)
+ tag_ids: list[int] = Field(default_factory=list)
+ explicit_ids: list[int] = Field(default_factory=list)
def create_saved_test(db, user, data, question_ids):
@@ -203,9 +223,9 @@ def generate_test(db, user, data):
if len(ids) < data.count:
raise HTTPException(400, f"Only {len(ids)} questions available; requested {data.count}")
return create_saved_test(db, user, data, ids)
- query = filtered_bank_query(db, user, data.category_ids, data.state, data.is_shared)
- if data.difficulty:
- query = query.filter(Question.difficulty == data.difficulty)
+ query = filtered_bank_query(db, user, data.category_ids, data.state, data.is_shared, data.difficulty, data.article_ids, data.tag_ids)
+ if data.explicit_ids:
+ query = query.filter(Question.id.in_(list(dict.fromkeys(data.explicit_ids))))
ids = [row[0] for row in query.with_entities(Question.id).all()]
if data.expected_count is not None and data.expected_count != len(ids):
raise HTTPException(409, "Available count changed. Refresh the count and try again")
diff --git a/docs/quiz-revamp-plan.md b/docs/quiz-revamp-plan.md
index f4a36de..b47e250 100644
--- a/docs/quiz-revamp-plan.md
+++ b/docs/quiz-revamp-plan.md
@@ -70,3 +70,16 @@ Run frontend build and targeted/full relevant suites; obtain independent review,
- Lab-reference support includes educator-managed entries and source/age/unit fields. Do not publish unsourced or AI-invented ranges. An honest empty state is permitted until verified clinical data is supplied.
- The user has authorized implementation. Continue each completed milestone into verification, review, Git synchronization and the next milestone; stop only for a genuine blocker.
- No migrations or deployments were performed during the initial scoping/Git synchronization. Subsequent implementation and validation evidence must be recorded in focused commits.
+
+## Pending backlog (2026-09-09)
+
+1. Category management page (with search): organize parents/subparents, create new parent, delete with move_to, move questions between categories; link from bank/create pages.
+2. Question editor: use shared CategoryTree facets (primary select + checkbox tree) — in progress.
+3. Create Custom Test facets: Status / Difficulty / Systems / Articles sections — code in progress; update tests (state is now buttons, not select).
+4. Upload flow: document → matched BANK questions via keywords/embeddings; min 10MB, max 30 questions, 5-day auto-delete.
+5. AI "describe what to study" → quiz from matched bank questions (all logged-in users).
+6. Quiz action buttons: analysis / rename / repeat / delete.
+7. Study recommendations from weakest categories.
+8. Dedicated question-management CMS page with per-category educator grants.
+9. Vision review finding: question-card row consistency (Study/Share alignment, answer-chip widths).
+10. Runner header spacing polish (question review / X of Y) — CSS added, verify visually.
diff --git a/frontend/src/components/CategoryPerformance.jsx b/frontend/src/components/CategoryPerformance.jsx
index 9a86436..1155320 100644
--- a/frontend/src/components/CategoryPerformance.jsx
+++ b/frontend/src/components/CategoryPerformance.jsx
@@ -1,9 +1,12 @@
import { useEffect, useMemo, useState } from 'react'
+import { Link, useNavigate } from 'react-router-dom'
import api from '../api/client'
export default function CategoryPerformance() {
const [data, setData] = useState(null)
const [showAll, setShowAll] = useState(false)
+ const [adaptiveCount, setAdaptiveCount] = useState(10)
+ const navigate = useNavigate()
useEffect(() => {
api.get('/study-tools/performance-by-category')
.then(res => setData(res.data))
@@ -42,19 +45,42 @@ export default function CategoryPerformance() {
Performance by category
setShowAll(v => !v)}>
- {showAll ? 'Show main categories' : `Show all (${rows.length})`}
+ {showAll ? 'Close' : `Show all (${rows.length})`}
{data.basis}
- {!showAll && main.map(renderRow)}
+
+ Adaptive session
+ setAdaptiveCount(parseInt(e.target.value, 10))} aria-label="Adaptive question count">
+ {[3, 5, 10, 15, 20].map(n => {n} )}
+
+
+ navigate(`/quizzes/create?adaptive=1&count=${adaptiveCount}`)}>Start adaptive session
+ {main.length > 0 && (
+ navigate(`/quizzes/create?adaptive=1&count=${adaptiveCount}&${main.slice(0, 3).map(row => `category=${row.category_id}`).join('&')}`)}>
+ On my weakest topics
+
+ )}
+
+ {main.map(renderRow)}
{showAll && (
-
- {main.map(row => (
-
- {renderRow(row)}
- {renderBranch(row.category_id, 1)}
+
+
+
+ All categories
+ setShowAll(false)}>✕
+
+
+ {main.map(row => (
+
+ {renderRow(row)}
+ {renderBranch(row.category_id, 1)}
+
+ ))}
- ))}
+
)}
diff --git a/frontend/src/components/CategoryPerformance.test.jsx b/frontend/src/components/CategoryPerformance.test.jsx
index af661b7..a16f0ae 100644
--- a/frontend/src/components/CategoryPerformance.test.jsx
+++ b/frontend/src/components/CategoryPerformance.test.jsx
@@ -1,5 +1,7 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
-import { render, screen, waitFor } from '@testing-library/react'
+import { render, screen, waitFor, within } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { MemoryRouter } from 'react-router-dom'
import CategoryPerformance from './CategoryPerformance'
import api from '../api/client'
@@ -13,22 +15,30 @@ describe('category performance', () => {
total_answered: 3,
basis: 'Your completed, non-expired general test answers.',
categories: [
- { category_id: 2, name: 'Clinical reasoning', answered: 2, correct: 1, accuracy: 50 },
- { category_id: 1, name: 'Pediatrics', answered: 1, correct: 1, accuracy: 100 },
+ { category_id: 2, name: 'Clinical reasoning', parent_id: null, answered: 2, correct: 1, accuracy: 50 },
+ { category_id: 1, name: 'Pediatrics', parent_id: null, answered: 1, correct: 1, accuracy: 100 },
+ { category_id: 3, name: 'Neonatal', parent_id: 1, answered: 1, correct: 0, accuracy: 0 },
],
} })
- render(
)
+ render(
)
expect(await screen.findByTestId('category-performance')).toBeInTheDocument()
expect(screen.getByText('Clinical reasoning')).toBeInTheDocument()
expect(screen.getByText('50%')).toBeInTheDocument()
expect(screen.getByText('1/2')).toBeInTheDocument()
expect(screen.getByText('100%')).toBeInTheDocument()
expect(screen.getByText(/completed, non-expired/)).toBeInTheDocument()
+ // Child rows stay out of the compact view until the side panel opens.
+ expect(screen.queryByText('Neonatal')).not.toBeInTheDocument()
+ await userEvent.click(screen.getByRole('button', { name: 'Show all (3)' }))
+ const panel = await screen.findByRole('dialog', { name: 'All category performance' })
+ expect(within(panel).getByText('Neonatal')).toBeInTheDocument()
+ await userEvent.click(within(panel).getByRole('button', { name: 'Close all categories' }))
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
})
it('renders nothing without category data', async () => {
api.get.mockResolvedValue({ data: { total_answered: 0, categories: [] } })
- const { container } = render(
)
+ const { container } = render(
)
await waitFor(() => expect(api.get).toHaveBeenCalled())
await new Promise(resolve => setTimeout(resolve, 0))
expect(container.querySelector('[data-testid="category-performance"]')).toBeNull()
diff --git a/frontend/src/components/CategoryTree.jsx b/frontend/src/components/CategoryTree.jsx
new file mode 100644
index 0000000..9385270
--- /dev/null
+++ b/frontend/src/components/CategoryTree.jsx
@@ -0,0 +1,55 @@
+import { useState } from 'react'
+
+export default function CategoryTree({ categories, selectedIds, onToggle, excludedId = null, searchable = false }) {
+ const [query, setQuery] = useState('')
+ const childrenOf = {}
+ for (const cat of categories) {
+ ;(childrenOf[cat.parent_id || 0] ||= []).push(cat)
+ }
+ const visible = searchable
+ ? categories.filter(cat => [cat.name, ...(cat.breadcrumbs || []).map(b => b.name)].join(' ').toLowerCase().includes(query.toLowerCase()))
+ : categories
+ const descendantSelected = (cat) => {
+ const ids = []
+ const walk = (id) => { for (const child of childrenOf[id] || []) { ids.push(child.id); walk(child.id) } }
+ walk(cat.id)
+ return ids.some(id => selectedIds.includes(id))
+ }
+ const renderBranch = (parentId) => {
+ const branch = (childrenOf[parentId] || []).filter(cat => visible.includes(cat))
+ if (!branch.length) return null
+ return
+ }
+ return (
+
+ {searchable && (
+
+ 🔍
+ setQuery(e.target.value)}
+ placeholder="Search topics…" aria-label="Search topics" className="input" />
+
+ )}
+ {renderBranch(0)}
+
+ )
+}
diff --git a/frontend/src/components/CommentSection.jsx b/frontend/src/components/CommentSection.jsx
index 7e94f69..2143cb8 100644
--- a/frontend/src/components/CommentSection.jsx
+++ b/frontend/src/components/CommentSection.jsx
@@ -18,7 +18,7 @@ export default function CommentSection({ articleId, questionId }) {
const load = useCallback(async (off = 0) => {
try {
- const res = await api.get('/comments', { params: { ...params, limit: LIMIT, offset: off } })
+ const res = await api.get('/comments/', { params: { ...params, limit: LIMIT, offset: off } })
const list = res.data?.comments || []
setComments(prev => off === 0 ? list : [...prev, ...list])
setTotal(res.data?.total || 0)
@@ -33,7 +33,7 @@ export default function CommentSection({ articleId, questionId }) {
setError('')
setSubmitting(true)
try {
- const res = await api.post('/comments', { ...params, content: draft })
+ const res = await api.post('/comments/', { ...params, content: draft })
setComments(prev => [res.data, ...prev.filter(c => c.id !== res.data.id)])
setTotal(t => t + 1)
setDraft('')
diff --git a/frontend/src/components/CommentSection.test.jsx b/frontend/src/components/CommentSection.test.jsx
index 7524024..ee544d7 100644
--- a/frontend/src/components/CommentSection.test.jsx
+++ b/frontend/src/components/CommentSection.test.jsx
@@ -31,7 +31,7 @@ describe('comment section', () => {
await screen.findByRole('heading', { name: /Discussion/ })
await userEvent.type(screen.getByLabelText('Comment text'), 'New note')
await userEvent.click(screen.getByRole('button', { name: 'Post comment' }))
- await waitFor(() => expect(api.post).toHaveBeenCalledWith('/comments', { question_id: 5, content: 'New note' }))
+ await waitFor(() => expect(api.post).toHaveBeenCalledWith('/comments/', { question_id: 5, content: 'New note' }))
expect(await screen.findByText('New note')).toBeInTheDocument()
})
diff --git a/frontend/src/index.css b/frontend/src/index.css
index b33406e..4693e49 100644
--- a/frontend/src/index.css
+++ b/frontend/src/index.css
@@ -746,3 +746,17 @@ body {
.category-performance-row { grid-template-columns: 1fr 56px; }
.cp-track { grid-column: 1 / -1; grid-row: 2; }
}
+.cp-heading { display: flex; justify-content: space-between; align-items: center; gap: 10px; flex-wrap: wrap; }
+.cp-overlay { position: fixed; inset: 0; background: rgba(15, 23, 42, 0.35); z-index: 1050; display: flex; justify-content: flex-end; }
+.cp-panel { background: var(--card-bg); width: min(520px, 92vw); height: 100%; display: flex; flex-direction: column; box-shadow: -12px 0 40px rgba(0,0,0,0.18); }
+.cp-panel-header { display: flex; justify-content: space-between; align-items: center; padding: 14px 18px; border-bottom: 1px solid var(--border); }
+.cp-panel-header h2 { margin: 0; font-size: 1rem; }
+.cp-panel-header button { background: none; border: none; font-size: 1.1rem; cursor: pointer; color: var(--text-muted); }
+.cp-panel-body { flex: 1; overflow-y: auto; padding: 14px 18px; }
+@media (max-width: 640px) {
+ .cp-overlay { align-items: flex-end; }
+ .cp-panel { width: 100%; height: 85vh; border-radius: 16px 16px 0 0; }
+}
+.cp-adaptive-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-bottom: 12px; padding: 10px; border: 1px solid var(--border); border-radius: 10px; background: var(--input-bg); }
+.cp-adaptive-label { display: flex; align-items: center; gap: 6px; font-size: .82rem; }
+.cp-adaptive-label select { padding: 4px 8px; border: 1px solid var(--border); border-radius: 6px; }
diff --git a/frontend/src/pages/ArticlesPage.jsx b/frontend/src/pages/ArticlesPage.jsx
index d99b22f..1575e63 100644
--- a/frontend/src/pages/ArticlesPage.jsx
+++ b/frontend/src/pages/ArticlesPage.jsx
@@ -49,7 +49,7 @@ export default function ArticlesPage() {
const params = {}
if (categoryId) params.category_id = categoryId
if (query.trim()) params.q = query.trim()
- api.get('/articles', { params }).then(res => setArticles(res.data)).finally(() => setLoading(false))
+ api.get('/articles/', { params }).then(res => setArticles(res.data)).finally(() => setLoading(false))
}, [categoryId, query])
useEffect(() => { load() }, [load])
@@ -61,7 +61,7 @@ export default function ArticlesPage() {
setError('')
if (!title.trim() || !slug.trim()) { setError('Title and slug are required'); return }
try {
- const res = await api.post('/articles', { title, slug: slug.trim().toLowerCase(), content: '', sections: [] })
+ const res = await api.post('/articles/', { title, slug: slug.trim().toLowerCase(), content: '', sections: [] })
setShowCreate(false)
navigate(`/articles/${res.data.id}?edit=1`)
} catch (err) {
diff --git a/frontend/src/pages/ArticlesPage.test.jsx b/frontend/src/pages/ArticlesPage.test.jsx
index f14bdc3..be6a42f 100644
--- a/frontend/src/pages/ArticlesPage.test.jsx
+++ b/frontend/src/pages/ArticlesPage.test.jsx
@@ -22,7 +22,7 @@ beforeEach(() => {
vi.resetAllMocks()
api.get.mockImplementation(url => {
if (url === '/question-categories') return Promise.resolve({ data: [] })
- if (url === '/articles') return Promise.resolve({ data: [article] })
+ if (url === '/articles/') return Promise.resolve({ data: [article] })
if (url === '/articles/linked') return Promise.resolve({ data: [article] })
if (url === '/articles/1') return Promise.resolve({ data: article })
if (url === '/articles/1/questions') return Promise.resolve({ data: [{ question_id: 1, question_text: 'Linked question text', correct_answer: 'yes', explanation: 'Why', section_id: null }] })
diff --git a/frontend/src/pages/CustomQuizPage.css b/frontend/src/pages/CustomQuizPage.css
index 22b40e1..34f23af 100644
--- a/frontend/src/pages/CustomQuizPage.css
+++ b/frontend/src/pages/CustomQuizPage.css
@@ -52,3 +52,6 @@ details[open] > .custom-test-branch .custom-test-chevron { transform: rotate(45d
.custom-test-actions .btn-primary { flex: 1; background: var(--primary); color: var(--primary-fg); font-weight: 600; padding: 10px 16px; }
.custom-test-main > p { margin: 8px 0 0; }
.custom-test-main > .custom-test-share { margin-top: 8px; }
+.custom-test-tags { display: flex; flex-direction: column; gap: 2px; max-height: 24vh; overflow-y: auto; }
+.custom-test-tags label { display: flex; gap: 6px; align-items: baseline; font-size: .82rem; cursor: pointer; }
+.custom-test-exam { display: flex; gap: 6px; align-items: center; font-size: .84rem; font-weight: 600; color: var(--primary); margin: 4px 0; }
diff --git a/frontend/src/pages/CustomQuizPage.jsx b/frontend/src/pages/CustomQuizPage.jsx
index 3cd2669..f62a660 100644
--- a/frontend/src/pages/CustomQuizPage.jsx
+++ b/frontend/src/pages/CustomQuizPage.jsx
@@ -12,12 +12,18 @@ export default function CustomQuizPage() {
const [categoryIds, setCategoryIds] = useState(() => [...new Set(searchParams.getAll('category').map(Number).filter(id => Number.isSafeInteger(id) && id > 0))])
const [state, setState] = useState('all')
const [shared, setShared] = useState(false)
- const [title, setTitle] = useState(() => `Custom test from ${new Date().toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}`)
+ const [title, setTitle] = useState(() => {
+ const now = new Date()
+ return `Custom test from ${now.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}, ${now.toLocaleTimeString('en-US', { hour: 'numeric', hour12: true })}`
+ })
const [mode, setMode] = useState('learning')
const [time, setTime] = useState('')
- const [count, setCount] = useState(20)
+ const [count, setCount] = useState(() => {
+ const fromUrl = parseInt(searchParams.get('count'), 10)
+ return Number.isInteger(fromUrl) && fromUrl >= 1 && fromUrl <= 200 ? fromUrl : 20
+ })
const [difficulty, setDifficulty] = useState('')
- const [adaptive, setAdaptive] = useState(false)
+ const [adaptive, setAdaptive] = useState(() => searchParams.get('adaptive') === '1')
const [available, setAvailable] = useState(null)
const [countKey, setCountKey] = useState(null)
const [error, setError] = useState('')
@@ -40,6 +46,8 @@ export default function CustomQuizPage() {
const params = new URLSearchParams({ state, is_shared: String(shared) })
if (difficulty) params.append('difficulty', difficulty)
categoryIds.forEach(id => params.append('category_ids', id))
+ articleIds.forEach(id => params.append('article_ids', id))
+ tagIds.forEach(id => params.append('tag_ids', id))
api.get('/questions/builder/count', { params }).then(r => {
if (active) { setAvailable(r.data.count); setCountKey(filterKey) }
}).catch(() => { if (active) setCountError('Could not load available count. Try refreshing.') })
@@ -58,6 +66,7 @@ export default function CustomQuizPage() {
title: title.trim(), category_ids: categoryIds, state, count: Number(count),
expected_count: available, mode, time_limit_minutes: mode === 'timed' && time ? Number(time) : null,
is_shared: shared, difficulty: difficulty || null, algorithm: adaptive ? 'adaptive' : 'random',
+ article_ids: articleIds, tag_ids: tagIds, explicit_ids: explicitIds,
})
navigate(`/quizzes/${result.data.id}`)
} catch (err) {
@@ -69,6 +78,26 @@ export default function CustomQuizPage() {
const [catSearch, setCatSearch] = useState('')
const [filtersOpen, setFiltersOpen] = useState(true)
+ const [articleIds, setArticleIds] = useState([])
+ const [tagIds, setTagIds] = useState([])
+ const [articles, setArticles] = useState([])
+ const [tags, setTags] = useState({ subjects: [], keywords: [] })
+ const [presetIds, setPresetIds] = useState([])
+ const [collections, setCollections] = useState([])
+ useEffect(() => {
+ api.get('/articles/').then(res => setArticles(Array.isArray(res.data) ? res.data : [])).catch(() => setArticles([]))
+ api.get('/tags').then(res => setTags(res.data && res.data.subjects ? res.data : { subjects: [], keywords: [] })).catch(() => {})
+ api.get('/collections/').then(res => setCollections(Array.isArray(res.data) ? res.data : [])).catch(() => setCollections([]))
+ }, [])
+ const togglePreset = async (collection) => {
+ if (presetIds.includes(collection.id)) { setPresetIds(ids => ids.filter(id => id !== collection.id)); return }
+ setPresetIds(ids => [...ids, collection.id])
+ if (!collection._loaded) {
+ const res = await api.get(`/collections/${collection.id}/questions`)
+ setCollections(prev => prev.map(c => c.id === collection.id ? { ...c, _loaded: true, question_ids: res.data.map(q => q.id) } : c))
+ }
+ }
+ const explicitIds = [...new Set(collections.filter(c => presetIds.includes(c.id)).flatMap(c => c.question_ids || []))]
const childrenOf = {}
for (const cat of categories) {
;(childrenOf[cat.parent_id || 0] ||= []).push(cat)
@@ -114,7 +143,42 @@ export default function CustomQuizPage() {
setFiltersOpen(v => !v)}>{filtersOpen ? '✕ Hide filters' : '☰ Filters'}
-
Topics
+
Filters
+
Status
+
+ {[['all', 'All'], ['unused', 'Unused'], ['incorrect', 'Incorrect'], ['bookmarked', 'Saved']].map(([value, label]) => (
+ setState(value)}>{label}
+ ))}
+
+
Difficulty
+
setDifficulty(e.target.value)} className="input" aria-label="Difficulty">
+ Any Easy
+ Medium Hard
+
+
Exams
+
Pediatrics Boards
+
Disciplines
+
+ {(tags.subjects || []).slice(0, 40).map(tag => (
+
+ setTagIds(ids => e.target.checked ? [...ids, tag.id] : ids.filter(id => id !== tag.id))} />
+ {tag.name}
+
+ ))}
+
+
Symptoms & keywords
+
+ {(tags.keywords || []).slice(0, 40).map(tag => (
+
+ setTagIds(ids => e.target.checked ? [...ids, tag.id] : ids.filter(id => id !== tag.id))} />
+ {tag.name}
+
+ ))}
+
+
Systems
Parent categories include all their subcategories.
🔍
@@ -123,19 +187,32 @@ export default function CustomQuizPage() {
{renderTree(0)}
setCategoryIds([])}>Clear categories
+
Saved
+
+ setState(e.target.checked ? 'bookmarked' : 'all')} /> Bookmarked questions
+ {collections.map(collection => (
+
+ togglePreset(collection)} />
+ {collection.title} ({collection.question_count})
+
+ ))}
+
+
Articles
+
+ {(articles || []).map(article => (
+
+ setArticleIds(ids => e.target.checked ? [...ids, article.id] : ids.filter(id => id !== article.id))} />
+ {article.title}
+
+ ))}
+ {(articles || []).length === 0 &&
No articles yet.
}
+
Title setTitle(e.target.value)} className="custom-test-title-input" />
-
Question state setState(e.target.value)}>
- All Unused
- Incorrect Bookmarked
-
-
Difficulty setDifficulty(e.target.value)}>
- Any Easy
- Medium Hard
-
setAdaptive(e.target.checked)} /> Adaptive session
{adaptive &&
Adaptive picks your weakest topics first: it prefers unanswered questions, then recycles older incorrect ones, and moves between weak areas instead of repeating one.
}
Number of questions setCount(e.target.value)} />
diff --git a/frontend/src/pages/CustomQuizPage.test.jsx b/frontend/src/pages/CustomQuizPage.test.jsx
index d3ebcd1..8f54a8c 100644
--- a/frontend/src/pages/CustomQuizPage.test.jsx
+++ b/frontend/src/pages/CustomQuizPage.test.jsx
@@ -13,7 +13,11 @@ const categories = [
{ id: 2, name: 'Neonatal', question_count: 10, breadcrumbs: [{ id: 1, name: 'Pediatrics' }, { id: 2, name: 'Neonatal' }] },
]
function setupCount(count = 30) {
- api.get.mockImplementation(url => Promise.resolve({ data: url === '/question-categories/' ? categories : { count } }))
+ api.get.mockImplementation(url => {
+ if (url === '/question-categories/') return Promise.resolve({ data: categories })
+ if (url.startsWith('/articles') || url.startsWith('/collections') || url.startsWith('/tags')) return Promise.resolve({ data: url.startsWith('/articles') ? [] : url.startsWith('/collections') ? [] : { subjects: [], keywords: [] } })
+ return Promise.resolve({ data: { count } })
+ })
}
function renderBuilder() {
render(
@@ -34,7 +38,7 @@ describe('CustomQuizPage', () => {
expect(screen.queryByLabelText(/Time limit/)).not.toBeInTheDocument()
await userEvent.click(screen.getByLabelText('Pediatrics (30)'))
await userEvent.click(screen.getByLabelText('Neonatal (10)'))
- await userEvent.selectOptions(screen.getByLabelText('Question state'), 'incorrect')
+ await userEvent.click(screen.getByRole('button', { name: 'Unused' }))
await userEvent.selectOptions(screen.getByLabelText('Mode'), 'timed')
fireEvent.change(screen.getByLabelText(/Time limit/), { target: { value: '15' } })
fireEvent.change(screen.getByLabelText('Number of questions'), { target: { value: '10' } })
@@ -43,13 +47,13 @@ describe('CustomQuizPage', () => {
const calls = api.get.mock.calls.filter(([url]) => url === '/questions/builder/count')
const params = calls.at(-1)[1].params
expect(params.getAll('category_ids')).toEqual(['1', '2'])
- expect(params.get('state')).toBe('incorrect')
+ expect(params.get('state')).toBe('unused')
expect(params.get('is_shared')).toBe('true')
await userEvent.click(screen.getByRole('button', { name: 'Create Test' }))
expect(api.post).toHaveBeenCalledWith('/questions/builder', {
- title: `Custom test from ${new Date().toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}`, category_ids: [1, 2], state: 'incorrect', count: 10,
+ title: `Custom test from ${new Date().toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}, ${new Date().toLocaleTimeString('en-US', { hour: 'numeric', hour12: true })}`, category_ids: [1, 2], state: 'unused', count: 10,
mode: 'timed', time_limit_minutes: 15, expected_count: 30, is_shared: true,
- difficulty: null, algorithm: 'random',
+ difficulty: null, algorithm: 'random', article_ids: [], tag_ids: [], explicit_ids: [],
})
await screen.findByRole('heading', { name: 'Saved test' })
})
@@ -92,7 +96,7 @@ describe('CustomQuizPage', () => {
await screen.findByLabelText('Pediatrics (30)')
const old = resolveOld
setupCount(4)
- await userEvent.selectOptions(screen.getByLabelText('Question state'), 'bookmarked')
+ await userEvent.click(screen.getByRole('button', { name: 'Saved' }))
await screen.findByText('4 questions available')
old({ data: { count: 100 } })
await waitFor(() => expect(screen.queryByText('100 questions available')).not.toBeInTheDocument())
diff --git a/frontend/src/pages/QuestionBankPage.css b/frontend/src/pages/QuestionBankPage.css
index df82ed9..dfea4ee 100644
--- a/frontend/src/pages/QuestionBankPage.css
+++ b/frontend/src/pages/QuestionBankPage.css
@@ -21,3 +21,16 @@
.bank-layout { grid-template-columns: 1fr; }
.bank-category-list { max-height: none; }
}
+.category-tree-wrap { margin-top: 6px; }
+.category-tree { list-style: none; margin: 0; padding: 0; max-height: 46vh; overflow-y: auto; }
+.category-tree ul { list-style: none; margin: 0 0 0 16px; padding: 0; }
+.category-tree li { margin: 1px 0; }
+.category-tree label { display: flex; align-items: baseline; gap: 6px; font-size: .84rem; cursor: pointer; }
+.category-tree-count { color: var(--text-muted); font-size: .74rem; }
+.category-tree-excluded { opacity: .5; }
+.category-tree-branch { display: flex; align-items: center; cursor: pointer; list-style: none; }
+.category-tree-branch::-webkit-details-marker { display: none; }
+.category-tree-chevron { display: inline-block; width: 7px; height: 7px; border-right: 2px solid var(--text-muted); border-bottom: 2px solid var(--text-muted); transform: rotate(-45deg); margin-right: 6px; transition: transform .15s ease; flex-shrink: 0; }
+details[open] > .category-tree-branch .category-tree-chevron { transform: rotate(45deg); }
+.bank-articles { display: flex; flex-direction: column; gap: 3px; margin-top: 6px; max-height: 30vh; overflow-y: auto; }
+.bank-articles label { display: flex; gap: 6px; align-items: baseline; font-size: .82rem; cursor: pointer; }
diff --git a/frontend/src/pages/QuestionBankPage.jsx b/frontend/src/pages/QuestionBankPage.jsx
index 5fa7551..404b73d 100644
--- a/frontend/src/pages/QuestionBankPage.jsx
+++ b/frontend/src/pages/QuestionBankPage.jsx
@@ -3,6 +3,7 @@ import { useNavigate, Link } from 'react-router-dom'
import { useAuth } from '../context/AuthContext'
import api from '../api/client'
import Dialog from '../components/Dialog'
+import CategoryTree from '../components/CategoryTree'
import { useDialog } from '../hooks/useDialog'
const TeachChat = lazy(() => import('../components/TeachChat'))
@@ -22,7 +23,7 @@ function stripHtml(html) {
return html.replace(/<[^>]+>/g, ' ').replace(/ /g, ' ').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/\s+/g, ' ').trim()
}
-function QuestionStudyModal({ question, onClose, isFavorited, onToggleFavorite }) {
+function QuestionStudyModal({ question, onClose, isFavorited, onToggleFavorite, collections = [] }) {
const [answered, setAnswered] = useState(null)
return (
<>
@@ -105,6 +106,27 @@ function QuestionStudyModal({ question, onClose, isFavorited, onToggleFavorite }
)}
+
+
Add to library
+
+ {
+ if (!e.target.value) return
+ await api.put(`/collections/${e.target.value}/questions/${question.id}`)
+ e.target.value = ''
+ }} aria-label="Add to collection">
+ Choose collection…
+ {collections.map(c => {c.title} )}
+
+ {
+ if (e.key === 'Enter' && e.target.value.trim()) {
+ const res = await api.post('/collections/', { title: e.target.value.trim() })
+ await api.put(`/collections/${res.data.id}/questions/${question.id}`)
+ setCollections(prev => [...prev, res.data])
+ e.target.value = ''
+ }
+ }} style={{ padding: '4px 10px', border: '1px solid var(--border)', borderRadius: 6, fontSize: '0.8rem' }} />
+
+
{answered && (
setAnswered(null)}>Try again
@@ -323,7 +345,7 @@ function QuestionEditModal({ question, categories, onSaved, onClose }) {
return (
e.target === e.currentTarget && onClose()}>
-
+
Edit Question
✕
@@ -368,28 +390,20 @@ function QuestionEditModal({ question, categories, onSaved, onClose }) {
)}
-
Question Category
-
setForm(f => ({ ...f, question_category_id: e.target.value }))}>
+ Categories
+
+ Primary category drives the main listing; additional categories place the question in every selected branch.
+
+ setForm(f => ({ ...f, question_category_id: e.target.value }))} aria-label="Primary category">
— Uncategorized —
{categories.map(c => {(c.breadcrumbs || []).map(b => b.name).join(' › ') || c.name} )}
-
-
-
Additional subcategories
-
- A question can appear in several categories; the primary category stays the main one.
-
-
- {categories.filter(c => c.id !== (form.question_category_id ? parseInt(form.question_category_id) : null)).map(c => {
- const checked = form.extraCategoryIds.includes(c.id)
- return (
-
- toggleExtra(c.id)} />
- {(c.breadcrumbs || []).map(b => b.name).join(' › ') || c.name}
-
- )
- })}
-
+
Key points (smart links to reading)
@@ -650,6 +664,13 @@ export default function QuestionBankPage() {
const [categories, setCategories] = useState([])
const [searchQuery, setSearchQuery] = useState('')
const [difficulty, setDifficulty] = useState('')
+ const [bankArticleIds, setBankArticleIds] = useState([])
+ const [articles, setArticles] = useState([])
+ const [collections, setCollections] = useState([])
+ useEffect(() => {
+ api.get('/articles/').then(res => setArticles(res.data || [])).catch(() => setArticles([]))
+ api.get('/collections/').then(res => setCollections(res.data || [])).catch(() => setCollections([]))
+ }, [])
const [searchMode, setSearchMode] = useState('hybrid')
const [filterCatIds, setFilterCatIds] = useState([])
const [showUncategorized, setShowUncategorized] = useState(false)
@@ -716,7 +737,7 @@ export default function QuestionBankPage() {
clearTimeout(debounceRef.current)
debounceRef.current = setTimeout(() => loadQuestions(searchQuery, 0, filterCatIds, showUncategorized, showFavorites, searchMode, pageSize, selectedTagIds), 300)
return () => clearTimeout(debounceRef.current)
- }, [searchQuery, catIdsKey, showUncategorized, showFavorites, showMyQuestions, searchMode, pageSize, tagIdsKey, difficulty])
+ }, [searchQuery, catIdsKey, showUncategorized, showFavorites, showMyQuestions, searchMode, pageSize, tagIdsKey, difficulty, bankArticleIds.join(',')])
const toggleTag = (tagId) => {
setSelectedTagIds(prev => {
@@ -739,6 +760,7 @@ export default function QuestionBankPage() {
const params = {}
if (searchQuery.trim()) params.q = searchQuery.trim()
if (difficulty) params.difficulty = difficulty
+ if (bankArticleIds.length) params.article_ids = bankArticleIds.join(',')
if (filterCatIds.length > 0) params.category_ids = filterCatIds.join(',')
if (showUncategorized) params.uncategorized = true
if (showFavorites) params.favorites_only = true
@@ -916,7 +938,7 @@ export default function QuestionBankPage() {
)
})()}
- {studyQuestion &&
setStudyQuestion(null)}
+ {studyQuestion && setStudyQuestion(null)}
isFavorited={favorites.includes(studyQuestion.id)} onToggleFavorite={toggleFavorite} />}
{editQuestion && setQuestions(prev => prev.map(q => q.id === updated.id ? updated : q))}
@@ -1033,6 +1055,17 @@ export default function QuestionBankPage() {
Medium Hard
+ Articles
+
+ {articles.map(article => (
+
+ setBankArticleIds(ids => e.target.checked ? [...ids, article.id] : ids.filter(id => id !== article.id))} />
+ {article.title}
+
+ ))}
+ {articles.length === 0 &&
No articles yet.
}
+
Categories
{categories.map(cat => {
diff --git a/frontend/src/pages/QuestionBankPage.test.jsx b/frontend/src/pages/QuestionBankPage.test.jsx
index 4bdb8e0..7d2a7c1 100644
--- a/frontend/src/pages/QuestionBankPage.test.jsx
+++ b/frontend/src/pages/QuestionBankPage.test.jsx
@@ -217,12 +217,11 @@ describe('QuestionBankPage edit modal multi-category', () => {
api.patch = vi.fn().mockResolvedValue({ data: {} })
renderPage()
await userEvent.click(await screen.findByRole('button', { name: 'Edit' }))
- expect(await screen.findByRole('group', { name: 'Additional subcategories' })).toBeInTheDocument()
- const extras = screen.getByRole('group', { name: 'Additional subcategories' })
- expect(within(extras).getByLabelText('Cardiology')).toBeChecked()
- expect(within(extras).getByLabelText('Renal')).not.toBeChecked()
- expect(within(extras).queryByLabelText('Neonatology')).not.toBeInTheDocument()
- await userEvent.click(within(extras).getByLabelText('Renal'))
+ const modal = await screen.findByRole('dialog', { name: 'Edit Question' })
+ expect(within(modal).getByLabelText(/Cardiology/)).toBeChecked()
+ expect(within(modal).getByLabelText(/Renal/)).not.toBeChecked()
+ expect(within(modal).getByLabelText(/Neonatology/)).toBeDisabled()
+ await userEvent.click(within(modal).getByLabelText(/Renal/))
await userEvent.click(screen.getByRole('button', { name: 'Save Changes' }))
await waitFor(() => expect(api.patch).toHaveBeenCalledWith('/questions/7', expect.objectContaining({
additional_category_ids: [2, 3],
diff --git a/frontend/src/pages/QuizPage.jsx b/frontend/src/pages/QuizPage.jsx
index c39e4e4..3e5870f 100644
--- a/frontend/src/pages/QuizPage.jsx
+++ b/frontend/src/pages/QuizPage.jsx
@@ -423,6 +423,7 @@ export default function QuizPage() {
const [resumedExpired, setResumedExpired] = useState(false)
const [resumeRetry, setResumeRetry] = useState(0)
const [progressError, setProgressError] = useState('')
+ const [restartConfirm, setRestartConfirm] = useState(false)
const timerRef = useRef(null)
const toastRef = useRef(null)
const hasStarted = useRef(false)
@@ -637,7 +638,7 @@ export default function QuizPage() {
return () => clearInterval(timerRef.current)
}, [id, resumeRetry])
- const startAttempt = async (mode, voice, timerMinutes = null) => {
+ const startAttempt = async (mode, voice, timerMinutes = null, fresh = false) => {
hasStarted.current = true
setSelectedVoice(voice)
setStarting(true)
@@ -646,7 +647,7 @@ export default function QuizPage() {
try {
// Start attempt first (may select random question subset)
- const attemptRes = await api.post(`/attempts/start?quiz_id=${id}&mode=${mode}`)
+ const attemptRes = await api.post(`/attempts/start?quiz_id=${id}&mode=${mode}${fresh ? '&fresh=true' : ''}`)
mode = attemptRes.data.mode || mode
setAttemptId(attemptRes.data.id)
const aid = attemptRes.data.id
@@ -1044,6 +1045,21 @@ const timerStarted = timeLeft !== null
setLeaveTarget(returnTo || '/')} title="Save progress and exit">
⏸ Suspend
+ {restartConfirm ? (
+ Restart from the beginning?
+ {
+ setRestartConfirm(false)
+ setAnswers({}); setCurrentIdx(0); setDraftAnswer(''); setStartedAt(null)
+ setTimeLeft(null); setTotalTime(null); setResponseStats(null); setStatsError('')
+ setResumedExpired(false)
+ hasStarted.current = false
+ await startAttempt(quizMode || (quiz?.mode === 'timed' ? 'exam' : 'study'), selectedVoice || null, quiz?.time_limit_minutes || null, true)
+ }}>Yes, restart
+ setRestartConfirm(false)}>Cancel
+
+ ) : (
+ setRestartConfirm(true)} title="Start this quiz over from the beginning">↺ Restart
+ )}
{isModerator && ✏️ Edit}
diff --git a/frontend/src/pages/QuizPlayer.css b/frontend/src/pages/QuizPlayer.css
index eaced57..c17ee8f 100644
--- a/frontend/src/pages/QuizPlayer.css
+++ b/frontend/src/pages/QuizPlayer.css
@@ -92,3 +92,4 @@
.quiz-header-card { padding-bottom: 12px; }
.quiz-response-stat { font-size: .7rem; }
}
+.quiz-restart-confirm { display: inline-flex; align-items: center; gap: 6px; font-size: .8rem; color: var(--wrong-fg); background: var(--wrong-bg); border: 1px solid var(--wrong-bd); padding: 4px 10px; border-radius: 8px; }