feat: facet filters, personal libraries, adaptive shortcuts, restart, rename support

Create/bank pages use AMBOSS-style facets: Exams, Disciplines, Symptoms, Systems, Articles, Saved. Personal question libraries with add-to-library in study modal. Adaptive session shortcuts from performance (including weakest topics). Quiz restart with fresh attempt. Category counts computed with two grouped queries. Migration n7a8b9c0d142. 63 backend and 97 frontend tests pass.
This commit is contained in:
Daniel 2026-09-09 04:31:40 +02:00
parent 6fbfedd8a9
commit 73ef007e0a
25 changed files with 572 additions and 85 deletions

View file

@ -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")

View file

@ -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"])

View file

@ -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",
]

View file

@ -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)

View file

@ -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()

View file

@ -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):

View file

@ -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")

View file

@ -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")

View file

@ -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.

View file

@ -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() {
<div className="cp-heading">
<h2 style={{ margin: 0 }}>Performance by category</h2>
<button type="button" className="btn btn-secondary btn-sm" aria-expanded={showAll} onClick={() => setShowAll(v => !v)}>
{showAll ? 'Show main categories' : `Show all (${rows.length})`}
{showAll ? 'Close' : `Show all (${rows.length})`}
</button>
</div>
<p style={{ color: 'var(--text-muted)', fontSize: '0.8rem', margin: '4px 0 12px' }}>{data.basis}</p>
{!showAll && main.map(renderRow)}
<div className="cp-adaptive-row">
<label className="cp-adaptive-label">Adaptive session
<select value={adaptiveCount} onChange={e => setAdaptiveCount(parseInt(e.target.value, 10))} aria-label="Adaptive question count">
{[3, 5, 10, 15, 20].map(n => <option key={n} value={n}>{n}</option>)}
</select>
</label>
<button type="button" className="btn btn-primary btn-sm"
onClick={() => navigate(`/quizzes/create?adaptive=1&count=${adaptiveCount}`)}>Start adaptive session</button>
{main.length > 0 && (
<button type="button" className="btn btn-secondary btn-sm"
onClick={() => navigate(`/quizzes/create?adaptive=1&count=${adaptiveCount}&${main.slice(0, 3).map(row => `category=${row.category_id}`).join('&')}`)}>
On my weakest topics
</button>
)}
</div>
{main.map(renderRow)}
{showAll && (
<div className="cp-all">
{main.map(row => (
<div key={row.category_id}>
{renderRow(row)}
{renderBranch(row.category_id, 1)}
<div className="cp-overlay" role="dialog" aria-label="All category performance">
<div className="cp-panel">
<header className="cp-panel-header">
<h2>All categories</h2>
<button type="button" aria-label="Close all categories" onClick={() => setShowAll(false)}></button>
</header>
<div className="cp-panel-body">
{main.map(row => (
<div key={row.category_id}>
{renderRow(row)}
{renderBranch(row.category_id, 1)}
</div>
))}
</div>
))}
</div>
</div>
)}
</div>

View file

@ -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(<CategoryPerformance />)
render(<MemoryRouter><CategoryPerformance /></MemoryRouter>)
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(<CategoryPerformance />)
const { container } = render(<MemoryRouter><CategoryPerformance /></MemoryRouter>)
await waitFor(() => expect(api.get).toHaveBeenCalled())
await new Promise(resolve => setTimeout(resolve, 0))
expect(container.querySelector('[data-testid="category-performance"]')).toBeNull()

View file

@ -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 <ul className="category-tree">
{branch.map(cat => {
const excluded = cat.id === excludedId
const node = (
<label className={excluded ? 'category-tree-excluded' : ''}>
<input type="checkbox" checked={selectedIds.includes(cat.id)} disabled={excluded}
onChange={e => onToggle(cat.id, e.target.checked)} />
<span className="category-tree-name">{cat.name}</span>
<span className="category-tree-count">({cat.question_count})</span>
</label>
)
const kids = (childrenOf[cat.id] || []).filter(child => visible.includes(child))
if (!kids.length) return <li key={cat.id}>{node}</li>
return <li key={cat.id}>
<details open={!!query || selectedIds.includes(cat.id) || descendantSelected(cat)}>
<summary className="category-tree-branch"><span className="category-tree-chevron" aria-hidden="true" />{node}</summary>
{renderBranch(cat.id)}
</details>
</li>
})}
</ul>
}
return (
<div className="category-tree-wrap">
{searchable && (
<div className="custom-test-search">
<span className="custom-test-search-icon" aria-hidden="true">🔍</span>
<input type="search" value={query} onChange={e => setQuery(e.target.value)}
placeholder="Search topics…" aria-label="Search topics" className="input" />
</div>
)}
{renderBranch(0)}
</div>
)
}

View file

@ -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('')

View file

@ -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()
})

View file

@ -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; }

View file

@ -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) {

View file

@ -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 }] })

View file

@ -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; }

View file

@ -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() {
<button type="button" className="custom-test-filters-toggle" aria-expanded={filtersOpen}
onClick={() => setFiltersOpen(v => !v)}>{filtersOpen ? '✕ Hide filters' : '☰ Filters'}</button>
<div className={`custom-test-filters-body ${filtersOpen ? 'open' : ''}`}>
<h2>Topics</h2>
<h2>Filters</h2>
<h3>Status</h3>
<div className="bank-state-buttons">
{[['all', 'All'], ['unused', 'Unused'], ['incorrect', 'Incorrect'], ['bookmarked', 'Saved']].map(([value, label]) => (
<button key={value} type="button" className={`btn btn-sm ${state === value ? 'btn-primary' : 'btn-secondary'}`}
onClick={() => setState(value)}>{label}</button>
))}
</div>
<h3>Difficulty</h3>
<select value={difficulty} onChange={e => setDifficulty(e.target.value)} className="input" aria-label="Difficulty">
<option value="">Any</option><option value="easy">Easy</option>
<option value="medium">Medium</option><option value="hard">Hard</option>
</select>
<h3>Exams</h3>
<label className="custom-test-exam"><input type="checkbox" checked readOnly /> Pediatrics Boards</label>
<h3>Disciplines</h3>
<div className="custom-test-tags">
{(tags.subjects || []).slice(0, 40).map(tag => (
<label key={tag.id}>
<input type="checkbox" checked={tagIds.includes(tag.id)}
onChange={e => setTagIds(ids => e.target.checked ? [...ids, tag.id] : ids.filter(id => id !== tag.id))} />
{tag.name}
</label>
))}
</div>
<h3>Symptoms & keywords</h3>
<div className="custom-test-tags">
{(tags.keywords || []).slice(0, 40).map(tag => (
<label key={tag.id}>
<input type="checkbox" checked={tagIds.includes(tag.id)}
onChange={e => setTagIds(ids => e.target.checked ? [...ids, tag.id] : ids.filter(id => id !== tag.id))} />
{tag.name}
</label>
))}
</div>
<h3>Systems</h3>
<p>Parent categories include all their subcategories.</p>
<div className="custom-test-search">
<span className="custom-test-search-icon" aria-hidden="true">🔍</span>
@ -123,19 +187,32 @@ export default function CustomQuizPage() {
</div>
{renderTree(0)}
<button type="button" className="btn btn-secondary btn-sm" onClick={() => setCategoryIds([])}>Clear categories</button>
<h3>Saved</h3>
<div className="custom-test-tags">
<label><input type="checkbox" checked={state === 'bookmarked'} onChange={e => setState(e.target.checked ? 'bookmarked' : 'all')} /> Bookmarked questions</label>
{collections.map(collection => (
<label key={collection.id}>
<input type="checkbox" checked={presetIds.includes(collection.id)} onChange={() => togglePreset(collection)} />
{collection.title} ({collection.question_count})
</label>
))}
</div>
<h3>Articles</h3>
<div className="custom-test-articles">
{(articles || []).map(article => (
<label key={article.id}>
<input type="checkbox" checked={articleIds.includes(article.id)}
onChange={e => setArticleIds(ids => e.target.checked ? [...ids, article.id] : ids.filter(id => id !== article.id))} />
{article.title}
</label>
))}
{(articles || []).length === 0 && <p style={{ color: 'var(--text-muted)', fontSize: '.8rem' }}>No articles yet.</p>}
</div>
</div>
</aside>
<div className="custom-test-main">
<div className="custom-test-settings card">
<label>Title<input required maxLength={200} value={title} onChange={e => setTitle(e.target.value)} className="custom-test-title-input" /></label>
<label>Question state<select value={state} onChange={e => setState(e.target.value)}>
<option value="all">All</option><option value="unused">Unused</option>
<option value="incorrect">Incorrect</option><option value="bookmarked">Bookmarked</option>
</select></label>
<label>Difficulty<select value={difficulty} onChange={e => setDifficulty(e.target.value)}>
<option value="">Any</option><option value="easy">Easy</option>
<option value="medium">Medium</option><option value="hard">Hard</option>
</select></label>
<label className="custom-test-adaptive"><input type="checkbox" checked={adaptive} onChange={e => setAdaptive(e.target.checked)} /> <span className="custom-test-switch" aria-hidden="true"><span /></span> Adaptive session</label>
{adaptive && <p className="custom-test-adaptive-note">Adaptive picks your weakest topics first: it prefers unanswered questions, then recycles older incorrect ones, and moves between weak areas instead of repeating one.</p>}
<label>Number of questions<input type="number" required min="1" max="200" step="1" value={count} onChange={e => setCount(e.target.value)} /></label>

View file

@ -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(<MemoryRouter initialEntries={['/quizzes/create']}><Routes>
@ -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())

View file

@ -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; }

View file

@ -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(/&nbsp;/g, ' ').replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/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 }
</div>
)}
<QuestionReadingLinks questionId={question.id} />
<div style={{ marginTop: 12, borderTop: '1px solid var(--border)', paddingTop: 10, fontSize: '0.85rem' }}>
<strong>Add to library</strong>
<div style={{ display: 'flex', gap: 8, marginTop: 6, flexWrap: 'wrap' }}>
<select defaultValue="" onChange={async e => {
if (!e.target.value) return
await api.put(`/collections/${e.target.value}/questions/${question.id}`)
e.target.value = ''
}} aria-label="Add to collection">
<option value="">Choose collection</option>
{collections.map(c => <option key={c.id} value={c.id}>{c.title}</option>)}
</select>
<input placeholder="New library…" aria-label="New library name" onKeyDown={async e => {
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' }} />
</div>
</div>
{answered && (
<div style={{ marginTop: 14, display: 'flex', gap: 8 }}>
<button className="btn btn-secondary btn-sm" onClick={() => setAnswered(null)}>Try again</button>
@ -323,7 +345,7 @@ function QuestionEditModal({ question, categories, onSaved, onClose }) {
return (
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.55)', zIndex: 1000, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}
onClick={e => e.target === e.currentTarget && onClose()}>
<div style={{ background: 'var(--card-bg)', borderRadius: 12, padding: 24, maxWidth: 640, width: '100%', maxHeight: '90vh', overflowY: 'auto', boxShadow: '0 20px 60px rgba(0,0,0,0.3)' }}>
<div role="dialog" aria-label="Edit Question" style={{ background: 'var(--card-bg)', borderRadius: 12, padding: 24, maxWidth: 640, width: '100%', maxHeight: '90vh', overflowY: 'auto', boxShadow: '0 20px 60px rgba(0,0,0,0.3)' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
<h2 style={{ fontSize: '1.1rem' }}>Edit Question</h2>
<button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-muted)', fontSize: '1.2rem' }}></button>
@ -368,28 +390,20 @@ function QuestionEditModal({ question, categories, onSaved, onClose }) {
</div>
)}
<div className="form-group">
<label>Question Category</label>
<select value={form.question_category_id} onChange={e => setForm(f => ({ ...f, question_category_id: e.target.value }))}>
<label>Categories</label>
<p style={{ fontSize: '0.78rem', color: 'var(--text-muted)', margin: '0 0 8px' }}>
Primary category drives the main listing; additional categories place the question in every selected branch.
</p>
<select value={form.question_category_id} onChange={e => setForm(f => ({ ...f, question_category_id: e.target.value }))} aria-label="Primary category">
<option value=""> Uncategorized </option>
{categories.map(c => <option key={c.id} value={c.id}>{(c.breadcrumbs || []).map(b => b.name).join(' ') || c.name}</option>)}
</select>
</div>
<div className="form-group">
<label>Additional subcategories</label>
<p style={{ fontSize: '0.78rem', color: 'var(--text-muted)', margin: '0 0 8px' }}>
A question can appear in several categories; the primary category stays the main one.
</p>
<div className="category-chips" role="group" aria-label="Additional subcategories">
{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 (
<label key={c.id} className={checked ? 'category-chip checked' : 'category-chip'}>
<input type="checkbox" checked={checked} onChange={() => toggleExtra(c.id)} />
{(c.breadcrumbs || []).map(b => b.name).join(' ') || c.name}
</label>
)
})}
</div>
<CategoryTree
categories={categories}
selectedIds={form.extraCategoryIds}
excludedId={form.question_category_id ? parseInt(form.question_category_id) : null}
onToggle={toggleExtra}
/>
</div>
<div className="form-group">
<label>Key points (smart links to reading)</label>
@ -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 && <QuestionStudyModal question={studyQuestion} onClose={() => setStudyQuestion(null)}
{studyQuestion && <QuestionStudyModal question={studyQuestion} collections={collections} onClose={() => setStudyQuestion(null)}
isFavorited={favorites.includes(studyQuestion.id)} onToggleFavorite={toggleFavorite} />}
{editQuestion && <QuestionEditModal question={editQuestion} categories={categories}
onSaved={updated => setQuestions(prev => prev.map(q => q.id === updated.id ? updated : q))}
@ -1033,6 +1055,17 @@ export default function QuestionBankPage() {
<option value="medium">Medium</option><option value="hard">Hard</option>
</select>
</label>
<h3>Articles</h3>
<div className="bank-articles">
{articles.map(article => (
<label key={article.id}>
<input type="checkbox" checked={bankArticleIds.includes(article.id)}
onChange={e => setBankArticleIds(ids => e.target.checked ? [...ids, article.id] : ids.filter(id => id !== article.id))} />
{article.title}
</label>
))}
{articles.length === 0 && <p style={{ color: 'var(--text-muted)', fontSize: '.8rem' }}>No articles yet.</p>}
</div>
<h3>Categories</h3>
<div className="bank-category-list">
{categories.map(cat => {

View file

@ -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],

View file

@ -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
<button className="btn btn-secondary btn-sm" onClick={() => setLeaveTarget(returnTo || '/')} title="Save progress and exit">
Suspend
</button>
{restartConfirm ? (
<span className="quiz-restart-confirm" role="alert">Restart from the beginning?
<button className="btn btn-primary btn-sm" onClick={async () => {
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</button>
<button className="btn btn-secondary btn-sm" onClick={() => setRestartConfirm(false)}>Cancel</button>
</span>
) : (
<button className="btn btn-secondary btn-sm" onClick={() => setRestartConfirm(true)} title="Start this quiz over from the beginning"> Restart</button>
)}
{isModerator && <Link to={`/quizzes/${id}/edit`} className="btn btn-secondary btn-sm"> Edit</Link>}
</div>
</div>

View file

@ -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; }