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
+ setShowAll(v => !v)}>
+ {showAll ? 'Show main categories' : `Show all (${rows.length})`}
+
+
+
{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}
+ {node}
{renderTree(cat.id)}
@@ -116,15 +116,18 @@ export default function CustomQuizPage() {
-
Title setTitle(e.target.value)} />
+
Title setTitle(e.target.value)} className="custom-test-title-input" />
Question state setState(e.target.value)}>
All Unused
Incorrect Bookmarked
@@ -133,7 +136,7 @@ export default function CustomQuizPage() {
Any Easy
Medium Hard
-
setAdaptive(e.target.checked)} /> Adaptive session
+
setAdaptive(e.target.checked)} /> Adaptive session
{adaptive &&
Adaptive picks your weakest topics first: it prefers unanswered questions, then recycles older incorrect ones, and moves between weak areas instead of repeating one.
}
Number of questions setCount(e.target.value)} />
Mode setMode(e.target.value)}>
diff --git a/frontend/src/pages/CustomQuizPage.test.jsx b/frontend/src/pages/CustomQuizPage.test.jsx
index 59e9bdb..d3ebcd1 100644
--- a/frontend/src/pages/CustomQuizPage.test.jsx
+++ b/frontend/src/pages/CustomQuizPage.test.jsx
@@ -47,7 +47,7 @@ describe('CustomQuizPage', () => {
expect(params.get('is_shared')).toBe('true')
await userEvent.click(screen.getByRole('button', { name: 'Create Test' }))
expect(api.post).toHaveBeenCalledWith('/questions/builder', {
- title: 'My Custom Test', category_ids: [1, 2], state: 'incorrect', count: 10,
+ title: `Custom test from ${new Date().toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}`, category_ids: [1, 2], state: 'incorrect', count: 10,
mode: 'timed', time_limit_minutes: 15, expected_count: 30, is_shared: true,
difficulty: null, algorithm: 'random',
})
diff --git a/frontend/src/pages/QuestionBankPage.jsx b/frontend/src/pages/QuestionBankPage.jsx
index 3ddb813..e4e7676 100644
--- a/frontend/src/pages/QuestionBankPage.jsx
+++ b/frontend/src/pages/QuestionBankPage.jsx
@@ -65,6 +65,9 @@ function QuestionStudyModal({ question, onClose, isFavorited, onToggleFavorite }
style={{ cursor: hasAnswered ? 'default' : 'pointer' }}>
{String.fromCharCode(65 + i)}
{opt}
+ {hasAnswered && question.option_explanations?.[opt] && (
+ {question.option_explanations[opt]}
+ )}
{hasAnswered && isCorrectOpt && ✓ {isSelected ? 'Your answer' : 'Correct'} }
{hasAnswered && isSelected && !isCorrectOpt && ✗ Wrong }
@@ -85,6 +88,22 @@ function QuestionStudyModal({ question, onClose, isFavorited, onToggleFavorite }
)}
)}
+ {answered && (question.key_points || []).length > 0 && (
+
+
Key points
+
+ {question.key_points.map((point, i) => (
+
+ {point.text}
+ {point.article_id && (
+ 📖 Read more
+ )}
+
+ ))}
+
+
+ )}
{answered && (
diff --git a/frontend/src/pages/QuizPlayer.css b/frontend/src/pages/QuizPlayer.css
index d4ff22b..eaced57 100644
--- a/frontend/src/pages/QuizPlayer.css
+++ b/frontend/src/pages/QuizPlayer.css
@@ -83,3 +83,12 @@
.quiz-key-points li { font-size: .86rem; }
.quiz-key-point-link { margin-left: 8px; color: var(--primary); text-decoration: none; font-size: .8rem; font-weight: 600; }
.quiz-key-point-link:hover { text-decoration: underline; }
+.quiz-player .quiz-header-card > div { gap: 10px; }
+.quiz-topbar { gap: 10px; }
+.quiz-nav-controls { gap: 8px; }
+.quiz-review-button { margin-left: auto; }
+@media (max-width: 640px) {
+ .quiz-topbar { flex-wrap: wrap; }
+ .quiz-header-card { padding-bottom: 12px; }
+ .quiz-response-stat { font-size: .7rem; }
+}