From 73ef007e0ad12a9987f203732255d1508f56a7ab Mon Sep 17 00:00:00 2001 From: Daniel Date: Wed, 9 Sep 2026 04:31:40 +0200 Subject: [PATCH] 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. --- .../versions/n7a8b9c0d142_collections.py | 33 ++++++ backend/app/main.py | 3 +- backend/app/models/__init__.py | 3 + backend/app/models/collection.py | 22 ++++ backend/app/routers/collections.py | 100 +++++++++++++++++ backend/app/routers/question_categories.py | 51 +++++++-- backend/app/routers/questions.py | 17 ++- backend/app/services/quiz_builder.py | 28 ++++- docs/quiz-revamp-plan.md | 13 +++ .../src/components/CategoryPerformance.jsx | 42 ++++++-- .../components/CategoryPerformance.test.jsx | 20 +++- frontend/src/components/CategoryTree.jsx | 55 ++++++++++ frontend/src/components/CommentSection.jsx | 4 +- .../src/components/CommentSection.test.jsx | 2 +- frontend/src/index.css | 14 +++ frontend/src/pages/ArticlesPage.jsx | 4 +- frontend/src/pages/ArticlesPage.test.jsx | 2 +- frontend/src/pages/CustomQuizPage.css | 3 + frontend/src/pages/CustomQuizPage.jsx | 101 +++++++++++++++--- frontend/src/pages/CustomQuizPage.test.jsx | 16 +-- frontend/src/pages/QuestionBankPage.css | 13 +++ frontend/src/pages/QuestionBankPage.jsx | 79 ++++++++++---- frontend/src/pages/QuestionBankPage.test.jsx | 11 +- frontend/src/pages/QuizPage.jsx | 20 +++- frontend/src/pages/QuizPlayer.css | 1 + 25 files changed, 572 insertions(+), 85 deletions(-) create mode 100644 backend/alembic/versions/n7a8b9c0d142_collections.py create mode 100644 backend/app/models/collection.py create mode 100644 backend/app/routers/collections.py create mode 100644 frontend/src/components/CategoryTree.jsx 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

{data.basis}

- {!showAll && main.map(renderRow)} +
+ + + {main.length > 0 && ( + + )} +
+ {main.map(renderRow)} {showAll && ( -
- {main.map(row => ( -
- {renderRow(row)} - {renderBranch(row.category_id, 1)} +
+
+
+

All categories

+ +
+
+ {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
    + {branch.map(cat => { + const excluded = cat.id === excludedId + const node = ( + + ) + const kids = (childrenOf[cat.id] || []).filter(child => visible.includes(child)) + if (!kids.length) return
  • {node}
  • + return
  • +
    + + {renderBranch(cat.id)} +
    +
  • + })} +
+ } + 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() {
-

Topics

+

Filters

+

Status

+
+ {[['all', 'All'], ['unused', 'Unused'], ['incorrect', 'Incorrect'], ['bookmarked', 'Saved']].map(([value, label]) => ( + + ))} +
+

Difficulty

+ +

Exams

+ +

Disciplines

+
+ {(tags.subjects || []).slice(0, 40).map(tag => ( + + ))} +
+

Symptoms & keywords

+
+ {(tags.keywords || []).slice(0, 40).map(tag => ( + + ))} +
+

Systems

Parent categories include all their subcategories.

@@ -123,19 +187,32 @@ export default function CustomQuizPage() {
{renderTree(0)} +

Saved

+
+ + {collections.map(collection => ( + + ))} +
+

Articles

+
+ {(articles || []).map(article => ( + + ))} + {(articles || []).length === 0 &&

No articles yet.

} +
- - {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.

} 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.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 && (
@@ -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 }) {
)}
- - setForm(f => ({ ...f, question_category_id: e.target.value }))} aria-label="Primary category"> {categories.map(c => )} -
-
- -

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

Articles

+
+ {articles.map(article => ( + + ))} + {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 + {restartConfirm ? ( + Restart from the beginning? + + + + ) : ( + + )} {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; }