From 236547e64632e51635b1e892ea64b5a1cf5eac94 Mon Sep 17 00:00:00 2001 From: Daniel Date: Wed, 9 Sep 2026 03:31:54 +0200 Subject: [PATCH] feat: sample smart-links quiz, bank feedback polish, performance hierarchy Sample quiz demonstrates option explanations, key points with article links and linked cards. Bank study modal shows per-option explanations and key points. Performance shows main categories with an expandable hierarchy. AMBOSS-style picker polish (chevrons, search box, switch, auto title). Mobile spacing fixes. 97 frontend tests pass. --- backend/app/routers/study_tools.py | 2 + backend/scripts/seed_sample_content.py | 21 +++++++ .../src/components/CategoryPerformance.jsx | 58 +++++++++++++++---- frontend/src/pages/CustomQuizPage.css | 14 +++++ frontend/src/pages/CustomQuizPage.jsx | 15 +++-- frontend/src/pages/CustomQuizPage.test.jsx | 2 +- frontend/src/pages/QuestionBankPage.jsx | 19 ++++++ frontend/src/pages/QuizPlayer.css | 9 +++ 8 files changed, 122 insertions(+), 18 deletions(-) diff --git a/backend/app/routers/study_tools.py b/backend/app/routers/study_tools.py index 7c606ec..bf24087 100644 --- a/backend/app/routers/study_tools.py +++ b/backend/app/routers/study_tools.py @@ -191,9 +191,11 @@ def performance_by_category(db: Session = Depends(get_db), user: User = Depends( if is_correct: counts[cid][1] += 1 names = {cat.id: cat.name for cat in db.query(QuestionCategory).all()} + parents = {cat.id: cat.parent_id for cat in db.query(QuestionCategory).all()} categories = [{ "category_id": cid, "name": names.get(cid, "Uncategorized"), + "parent_id": parents.get(cid), "answered": answered, "correct": correct, "accuracy": round(100 * correct / answered, 1) if answered else 0, diff --git a/backend/scripts/seed_sample_content.py b/backend/scripts/seed_sample_content.py index a619d35..ee74c17 100644 --- a/backend/scripts/seed_sample_content.py +++ b/backend/scripts/seed_sample_content.py @@ -119,6 +119,22 @@ def main(): ] db.commit() + # Sample quiz demonstrating the full study feedback (option explanations, + # key points with article links, linked cards). + from app.models.quiz import Quiz + from app.models.quiz_question_link import QuizQuestionLink + from app.models.course import Course # noqa — Quiz.course_id FK needs the table in metadata. + sample_quiz = db.query(Quiz).filter(Quiz.title == "Sample — smart links demo").first() + if not sample_quiz: + sample_quiz = Quiz(title="Sample — smart links demo", user_id=moderator, mode="learning", + questions_count=1, is_published=1, origin="sample") + db.add(sample_quiz) + db.flush() + if not db.query(QuizQuestionLink.question_id).filter_by(quiz_id=sample_quiz.id, question_id=sample_q.id).first(): + db.add(QuizQuestionLink(quiz_id=sample_quiz.id, question_id=sample_q.id, position=0)) + db.commit() + print("Created sample smart-links quiz") + # ── Sample deck and cards ─────────────────────────────────────── deck = db.query(FlashcardDeck).filter_by(title="Sample cards", user_id=moderator).first() if not deck: @@ -165,6 +181,11 @@ def main(): if bank_ids and not db.query(FlashcardQuestionLink.id).filter_by( flashcard_id=card.id, question_id=bank_ids[1 % len(bank_ids)]).first(): db.add(FlashcardQuestionLink(flashcard_id=card.id, question_id=bank_ids[1 % len(bank_ids)])) + # Link the first sample card to the sample smart-link question as well. + if cards and not db.query(FlashcardQuestionLink.id).filter_by( + flashcard_id=cards[0].id, question_id=sample_q.id).first(): + db.add(FlashcardQuestionLink(flashcard_id=cards[0].id, question_id=sample_q.id)) + print(f"Sample card linked to the sample question") db.commit() # ── Deep-link lab references to the sample sections ───────────── diff --git a/frontend/src/components/CategoryPerformance.jsx b/frontend/src/components/CategoryPerformance.jsx index 8ec2bf6..9a86436 100644 --- a/frontend/src/components/CategoryPerformance.jsx +++ b/frontend/src/components/CategoryPerformance.jsx @@ -1,26 +1,62 @@ -import { useEffect, useState } from 'react' +import { useEffect, useMemo, useState } from 'react' import api from '../api/client' export default function CategoryPerformance() { const [data, setData] = useState(null) + const [showAll, setShowAll] = useState(false) useEffect(() => { api.get('/study-tools/performance-by-category') .then(res => setData(res.data)) .catch(() => setData(null)) }, []) - if (!data || !data.categories?.length) return null + const rows = data?.categories || [] + const main = useMemo(() => rows.filter(row => !row.parent_id).sort((a, b) => b.answered - a.answered), [rows]) + const childrenOf = useMemo(() => { + const map = {} + for (const row of rows) { (map[row.parent_id] ||= []).push(row) } + return map + }, [rows]) + if (!data || !rows.length) return null + + const renderRow = (row) => ( +
+ {row.name} + + {row.accuracy}% + {row.correct}/{row.answered} +
+ ) + const renderBranch = (parentId, depth = 0) => { + const branch = (childrenOf[parentId] || []).sort((a, b) => b.answered - a.answered) + if (!branch.length) return null + return branch.map(row => ( +
0 ? 16 : 0 }}> + {renderRow(row)} + {renderBranch(row.category_id, depth + 1)} +
+ )) + } + return (
-

Performance by category

-

{data.basis}

- {data.categories.map(category => ( -
- {category.name} - - {category.accuracy}% - {category.correct}/{category.answered} +
+

Performance by category

+ +
+

{data.basis}

+ {!showAll && main.map(renderRow)} + {showAll && ( +
+ {main.map(row => ( +
+ {renderRow(row)} + {renderBranch(row.category_id, 1)} +
+ ))}
- ))} + )}
) } diff --git a/frontend/src/pages/CustomQuizPage.css b/frontend/src/pages/CustomQuizPage.css index 63cd3be..400ff80 100644 --- a/frontend/src/pages/CustomQuizPage.css +++ b/frontend/src/pages/CustomQuizPage.css @@ -32,3 +32,17 @@ details[open] > .custom-test-branch::before { content: '▾ '; } .custom-test-filters-body { display: none; } .custom-test-filters-body.open { display: block; } } +.custom-test-search { position: relative; margin-bottom: 8px; } +.custom-test-search-icon { position: absolute; left: 10px; top: 50%; transform: translateY(-50%); font-size: .8rem; opacity: .6; pointer-events: none; } +.custom-test-search input { width: 100%; padding: 7px 10px 7px 30px; border: 1px solid var(--border); border-radius: 8px; background: var(--input-bg); color: var(--text); font-size: .84rem; } +.custom-test-search input:focus { outline: none; border-color: var(--primary); box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.15); } +.custom-test-chevron { display: inline-block; width: 8px; height: 8px; border-right: 2px solid var(--text-muted); border-bottom: 2px solid var(--text-muted); transform: rotate(-45deg); margin-right: 7px; transition: transform .15s ease; vertical-align: middle; } +details[open] > .custom-test-branch .custom-test-chevron { transform: rotate(45deg); } +.custom-test-title-input { font-size: 1.15rem; font-weight: 600; padding: 9px 12px; border: 1px solid var(--border); border-radius: 10px; } +.custom-test-adaptive { display: flex; align-items: center; gap: 10px; cursor: pointer; } +.custom-test-adaptive input { position: absolute; opacity: 0; width: 0; height: 0; } +.custom-test-switch { width: 38px; height: 21px; border-radius: 999px; background: var(--border); position: relative; transition: background .15s ease; flex-shrink: 0; } +.custom-test-switch span { position: absolute; top: 2px; left: 2px; width: 17px; height: 17px; border-radius: 50%; background: #fff; transition: transform .15s ease; box-shadow: 0 1px 3px rgba(0,0,0,.25); } +.custom-test-adaptive input:checked + .custom-test-switch { background: var(--primary); } +.custom-test-adaptive input:checked + .custom-test-switch span { transform: translateX(17px); } +.custom-test-branch { display: flex; align-items: center; } diff --git a/frontend/src/pages/CustomQuizPage.jsx b/frontend/src/pages/CustomQuizPage.jsx index 22a9b72..fe5a6f2 100644 --- a/frontend/src/pages/CustomQuizPage.jsx +++ b/frontend/src/pages/CustomQuizPage.jsx @@ -12,7 +12,7 @@ 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('My Custom Test') + const [title, setTitle] = useState(() => `Custom test from ${new Date().toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}`) const [mode, setMode] = useState('learning') const [time, setTime] = useState('') const [count, setCount] = useState(20) @@ -96,7 +96,7 @@ export default function CustomQuizPage() { if (!kids.length) return
  • {node}
  • return
  • - {node} + {renderTree(cat.id)}
  • @@ -116,15 +116,18 @@ export default function CustomQuizPage() {

    Topics

    Parent categories include all their subcategories.

    - setCatSearch(e.target.value)} - placeholder="Search topics…" aria-label="Search topics" className="input" /> +
    + + setCatSearch(e.target.value)} + placeholder="Search topics…" aria-label="Search topics" className="input" /> +
    {renderTree(0)}
    - + - + {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.

    }