From 01337c8c254beb99f8576c8566146195b097fe3e Mon Sep 17 00:00:00 2001 From: Daniel Date: Tue, 8 Sep 2026 16:03:45 +0200 Subject: [PATCH] feat: performance by category dashboard Accuracy per category from completed non-expired general-bank answers, counting each question in its primary and additional categories. 56 backend and 90 frontend tests pass. --- backend/app/routers/study_tools.py | 39 +++++++++++++++++++ backend/tests/test_study_tools.py | 18 +++++++++ .../src/components/CategoryPerformance.jsx | 26 +++++++++++++ .../components/CategoryPerformance.test.jsx | 36 +++++++++++++++++ frontend/src/index.css | 11 ++++++ frontend/src/pages/DashboardPage.jsx | 3 ++ 6 files changed, 133 insertions(+) create mode 100644 frontend/src/components/CategoryPerformance.jsx create mode 100644 frontend/src/components/CategoryPerformance.test.jsx diff --git a/backend/app/routers/study_tools.py b/backend/app/routers/study_tools.py index ae8934a..15aa733 100644 --- a/backend/app/routers/study_tools.py +++ b/backend/app/routers/study_tools.py @@ -11,6 +11,7 @@ from app.database import get_db from app.models.attempt import AttemptAnswer, QuizAttempt from app.models.lab_reference import LabReference from app.models.question import Question +from app.models.question_category import QuestionCategory, QuestionCategoryLink from app.models.quiz import Quiz from app.models.user import User from app.utils.auth import get_current_user, require_moderator @@ -101,6 +102,44 @@ def delete_lab_value(entry_id: int, db: Session = Depends(get_db), user: User = db.commit() +@router.get("/performance-by-category") +def performance_by_category(db: Session = Depends(get_db), user: User = Depends(get_current_user)): + """Accuracy per category from the user's completed, non-expired general-bank answers. + A question counts in its primary category and every additional category link.""" + from collections import defaultdict + from app.models.question_category import QuestionCategoryLink + rows = db.query(AttemptAnswer.question_id, AttemptAnswer.is_correct, Question.question_category_id).join( + QuizAttempt, QuizAttempt.id == AttemptAnswer.attempt_id).join(Quiz, Quiz.id == QuizAttempt.quiz_id).join( + Question, Question.id == AttemptAnswer.question_id).filter( + QuizAttempt.user_id == user.id, QuizAttempt.completed_at.isnot(None), + or_(QuizAttempt.expired == 0, QuizAttempt.expired.is_(None)), Quiz.course_id.is_(None), + ).all() + extra: dict[int, set[int]] = defaultdict(set) + for qid, cid in db.query(QuestionCategoryLink.question_id, QuestionCategoryLink.category_id).all(): + extra[qid].add(cid) + counts: dict[int, list[int]] = defaultdict(lambda: [0, 0]) + for qid, is_correct, primary in rows: + categories = extra.get(qid, set()) | ({primary} if primary else set()) + for cid in categories: + counts[cid][0] += 1 + if is_correct: + counts[cid][1] += 1 + names = {cat.id: cat.name for cat in db.query(QuestionCategory).all()} + categories = [{ + "category_id": cid, + "name": names.get(cid, "Uncategorized"), + "answered": answered, + "correct": correct, + "accuracy": round(100 * correct / answered, 1) if answered else 0, + } for cid, (answered, correct) in counts.items()] + categories.sort(key=lambda row: (-row["answered"], row["name"])) + return { + "total_answered": sum(row["answered"] for row in categories), + "categories": categories, + "basis": "Your completed, non-expired general test answers; a question counts in every category it belongs to.", + } + + @router.get("/attempts/{attempt_id}/questions/{question_id}/responses") def question_responses(attempt_id: int, question_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)): attempt = db.query(QuizAttempt).filter_by(id=attempt_id, user_id=user.id).first() diff --git a/backend/tests/test_study_tools.py b/backend/tests/test_study_tools.py index 2f14f3d..f52cb64 100644 --- a/backend/tests/test_study_tools.py +++ b/backend/tests/test_study_tools.py @@ -151,6 +151,24 @@ class StudyToolTests(unittest.TestCase): response = self.client.get(f'/study-tools/attempts/{peer_study}/questions/1/responses').json() self.assertEqual(response['sample_size'], 0) + def test_performance_by_category_expands_links_and_excludes_irrelevant(self): + quiz = self.bank.generate(is_shared=True, category_ids=[1]).json()['id'] + self.bank.answer(1, True, quiz_id=quiz) + self.bank.answer(1, True, quiz_id=quiz, expired=1) # Expired attempt excluded. + quiz2 = self.bank.generate(is_shared=True, category_ids=[2], count=1).json()['id'] + self.bank.answer(2, False, quiz_id=quiz2) + self.client.patch('/questions/3', json={'additional_category_ids': [2]}) # Owner edits own question. + quiz3 = self.bank.generate(category_ids=[3], count=1).json()['id'] # Private owner test: own attempts still count. + self.bank.answer(3, True, quiz_id=quiz3) + self.bank.answer(5, True, quiz_id=2) # Course quiz excluded. + data = self.client.get('/study-tools/performance-by-category').json() + by_id = {row['category_id']: row for row in data['categories']} + self.assertEqual([by_id[1]['answered'], by_id[1]['correct'], by_id[1]['accuracy']], [1, 1, 100.0]) + self.assertEqual([by_id[2]['answered'], by_id[2]['correct'], by_id[2]['accuracy']], [2, 1, 50.0]) + self.assertEqual([by_id[3]['answered'], by_id[3]['correct']], [1, 1]) + self.assertEqual(data['total_answered'], 4) + self.assertEqual(data['categories'][0]['category_id'], 2) # Most answered first. + def test_lab_reference_permissions_validation_and_publication(self): payload = dict(name='Example test', group='Blood', reference_range='Example interval', units='example units', age_group='Defined study population', specimen='Serum', source='Educator-supplied source', source_url='https://example.test/reference') diff --git a/frontend/src/components/CategoryPerformance.jsx b/frontend/src/components/CategoryPerformance.jsx new file mode 100644 index 0000000..8ec2bf6 --- /dev/null +++ b/frontend/src/components/CategoryPerformance.jsx @@ -0,0 +1,26 @@ +import { useEffect, useState } from 'react' +import api from '../api/client' + +export default function CategoryPerformance() { + const [data, setData] = useState(null) + useEffect(() => { + api.get('/study-tools/performance-by-category') + .then(res => setData(res.data)) + .catch(() => setData(null)) + }, []) + if (!data || !data.categories?.length) return null + return ( +
+

Performance by category

+

{data.basis}

+ {data.categories.map(category => ( +
+ {category.name} + + {category.accuracy}% + {category.correct}/{category.answered} +
+ ))} +
+ ) +} diff --git a/frontend/src/components/CategoryPerformance.test.jsx b/frontend/src/components/CategoryPerformance.test.jsx new file mode 100644 index 0000000..af661b7 --- /dev/null +++ b/frontend/src/components/CategoryPerformance.test.jsx @@ -0,0 +1,36 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { render, screen, waitFor } from '@testing-library/react' +import CategoryPerformance from './CategoryPerformance' +import api from '../api/client' + +vi.mock('../api/client', () => ({ default: { get: vi.fn() } })) + +beforeEach(() => { vi.resetAllMocks() }) + +describe('category performance', () => { + it('renders accuracy rows with counts and the stated basis', async () => { + api.get.mockResolvedValue({ data: { + 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 }, + ], + } }) + 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() + }) + + it('renders nothing without category data', async () => { + api.get.mockResolvedValue({ data: { total_answered: 0, categories: [] } }) + 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/index.css b/frontend/src/index.css index 47f5b9a..b33406e 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -735,3 +735,14 @@ body { .lesson-content code { background: var(--bg); padding: 2px 6px; border-radius: 4px; font-size: 0.88em; } .lesson-content pre { background: var(--bg); padding: 12px 16px; border-radius: 8px; overflow-x: auto; } .lesson-content strong { font-weight: 700; } +.category-performance-row { display: grid; grid-template-columns: minmax(120px, 1fr) minmax(80px, 2fr) 64px 84px; gap: 10px; align-items: center; padding: 8px 0; border-bottom: 1px solid var(--border); font-size: .86rem; } +.category-performance-row:last-child { border-bottom: none; } +.cp-name { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.cp-track { height: 8px; background: var(--bg, #e2e8f0); border-radius: 999px; overflow: hidden; display: block; } +.cp-fill { display: block; height: 100%; background: var(--primary, #2563eb); border-radius: 999px; } +.cp-accuracy { font-weight: 700; text-align: right; } +.cp-count { color: var(--text-muted); text-align: right; } +@media (max-width: 560px) { + .category-performance-row { grid-template-columns: 1fr 56px; } + .cp-track { grid-column: 1 / -1; grid-row: 2; } +} diff --git a/frontend/src/pages/DashboardPage.jsx b/frontend/src/pages/DashboardPage.jsx index 5757f8d..859be95 100644 --- a/frontend/src/pages/DashboardPage.jsx +++ b/frontend/src/pages/DashboardPage.jsx @@ -3,6 +3,7 @@ import { Link } from 'react-router-dom' import api from '../api/client' import LineChart from '../components/LineChart' import InProgressQuizzes from '../components/InProgressQuizzes' +import CategoryPerformance from '../components/CategoryPerformance' import MyNote from '../components/MyNote' import { useAuth } from '../context/AuthContext' @@ -78,6 +79,8 @@ export default function DashboardPage() { + + {/* Performance graph with dropdown */}