diff --git a/frontend/src/components/SessionProgress.css b/frontend/src/components/SessionProgress.css new file mode 100644 index 0000000..9b79513 --- /dev/null +++ b/frontend/src/components/SessionProgress.css @@ -0,0 +1,7 @@ +.sp-wrap { display: flex; flex-direction: column; gap: 5px; min-width: 0; } +.sp-track { display: flex; height: 4px; border-radius: 2px; background: var(--border); overflow: hidden; } +.sp-track > span { display: block; height: 100%; } +.sp-correct { background: #16a34a; } +.sp-wrong { background: #ef4444; } +.sp-seen { background: var(--primary); } +.sp-label { font-size: .72rem; font-weight: 700; letter-spacing: .04em; text-transform: uppercase; color: var(--text-muted); } diff --git a/frontend/src/components/SessionProgress.jsx b/frontend/src/components/SessionProgress.jsx new file mode 100644 index 0000000..e5266d7 --- /dev/null +++ b/frontend/src/components/SessionProgress.jsx @@ -0,0 +1,25 @@ +import './SessionProgress.css' + +/** + * Session progress as correct / incorrect / remaining, not one flat fill. + * + * A single bar cannot distinguish "answered 15, all right" from "answered 15, + * half wrong", which is the thing worth seeing at a glance. + */ +export default function SessionProgress({ answered = 0, total = 0, correct = null, label = true }) { + const seen = Math.min(answered, total) + const right = correct === null ? null : Math.min(correct, seen) + const wrong = right === null ? 0 : Math.max(0, seen - right) + const pct = (value) => (total > 0 ? (value / total) * 100 : 0) + + return ( +
+ + {right !== null && } + {right !== null && wrong > 0 && } + {right === null && } + + {label && {seen}/{total} questions} +
+ ) +} diff --git a/frontend/src/pages/AnalysisPage.css b/frontend/src/pages/AnalysisPage.css index 64a6317..44eb7b2 100644 --- a/frontend/src/pages/AnalysisPage.css +++ b/frontend/src/pages/AnalysisPage.css @@ -1,3 +1,26 @@ +/* Session rail beside the analysis, as in a Qbank analysis view. */ +.an-layout { display: grid; grid-template-columns: 260px minmax(0, 1fr); gap: 24px; align-items: start; } +.an-layout.rail-closed { grid-template-columns: 52px minmax(0, 1fr); } +.an-rail { position: sticky; top: 16px; background: var(--card-bg); border: 1px solid var(--border); border-radius: 12px; overflow: hidden; } +.an-rail-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 12px 14px; border-bottom: 1px solid var(--border); } +.an-rail-head h2 { margin: 0; font-size: .92rem; font-weight: 650; white-space: nowrap; } +.an-layout.rail-closed .an-rail-head h2 { display: none; } +.an-rail-head button { background: none; border: none; cursor: pointer; color: var(--text-muted); font-size: 1.1rem; line-height: 1; padding: 2px 4px; } +.an-rail-list { list-style: none; margin: 0; padding: 0; max-height: 70vh; overflow-y: auto; } +.an-rail-list > li { border-bottom: 1px solid var(--border); } +.an-rail-list > li:last-child { border-bottom: 0; } +.an-rail-list a { display: flex; flex-direction: column; gap: 7px; padding: 12px 14px; text-decoration: none; color: var(--text); } +.an-rail-list a:hover { background: var(--bg); } +.an-rail-title { font-size: .84rem; line-height: 1.4; overflow-wrap: anywhere; } +.an-rail-title strong { font-weight: 650; } +.an-rail-empty { margin: 0; padding: 12px 14px; font-size: .82rem; color: var(--text-muted); } + +@media (max-width: 1100px) { + .an-layout, .an-layout.rail-closed { grid-template-columns: 1fr; } + .an-rail { position: static; } + .an-rail-list { max-height: 260px; } +} + /* Performance analysis — readiness summary and ranked focus areas. Mobile-first: the focus table collapses to stacked cards under 760px. */ diff --git a/frontend/src/pages/AnalysisPage.jsx b/frontend/src/pages/AnalysisPage.jsx index 5cfeabf..5bad3bb 100644 --- a/frontend/src/pages/AnalysisPage.jsx +++ b/frontend/src/pages/AnalysisPage.jsx @@ -2,6 +2,7 @@ import { useState, useEffect, useCallback } from 'react' import { Link, useNavigate } from 'react-router-dom' import api from '../api/client' import CategoryPerformance from '../components/CategoryPerformance' +import SessionProgress from '../components/SessionProgress' import './AnalysisPage.css' const STATUS_LABEL = { focus: 'Focus area', proficient: 'Proficient', no_data: 'No data yet' } @@ -68,6 +69,8 @@ export default function AnalysisPage() { const [loading, setLoading] = useState(true) const [error, setError] = useState('') const [count, setCount] = useState(10) + const [sessions, setSessions] = useState([]) + const [railOpen, setRailOpen] = useState(true) const navigate = useNavigate() const load = useCallback(() => { @@ -81,10 +84,43 @@ export default function AnalysisPage() { useEffect(() => { load() }, [load]) + useEffect(() => { + api.get('/quizzes/sessions') + .then(res => setSessions((Array.isArray(res.data) ? res.data : []).slice(0, 12))) + .catch(() => setSessions([])) + }, []) + const startAdaptive = () => navigate(`/quizzes/create?adaptive=1&count=${count}`) const startCategory = (categoryId) => navigate(`/quizzes/create?category=${categoryId}&count=${count}`) return ( +
+ +

Your performance analysis

@@ -184,5 +220,6 @@ export default function AnalysisPage() { )}
+
) } diff --git a/frontend/src/pages/QuizPage.jsx b/frontend/src/pages/QuizPage.jsx index e32011c..836321a 100644 --- a/frontend/src/pages/QuizPage.jsx +++ b/frontend/src/pages/QuizPage.jsx @@ -428,6 +428,8 @@ export default function QuizPage() { const timerRef = useRef(null) const toastRef = useRef(null) const hasStarted = useRef(false) + // Indexes the learner has opened, so the rail reveals text gradually. + const [seenIndexes, setSeenIndexes] = useState(() => new Set([0])) const ttsCacheRef = useRef(new Map()) const autoAdvanceRef = useRef(null) const savedHighlightSelectionRef = useRef(null) @@ -702,6 +704,10 @@ export default function QuizPage() { return startAttempt(mode, voice, timerMinutes) } + useEffect(() => { + setSeenIndexes(prev => (prev.has(currentIdx) ? prev : new Set(prev).add(currentIdx))) + }, [currentIdx]) + const timerStarted = timeLeft !== null useEffect(() => { if (!timerStarted) return @@ -943,21 +949,26 @@ const timerStarted = timeLeft !== null const isActive = i === currentIdx const isDone = !!answers[q.id] const marked = favorites.includes(q.id) - const excerpt = questionStem(q).replace(/\s+/g, ' ').trim() + // Only questions the learner has reached show their text. Previewing one + // they have not opened would give away the case before they read it. + const seen = seenIndexes.has(i) + const excerpt = seen ? questionStem(q).replace(/\s+/g, ' ').trim() : '' return ( ) } diff --git a/frontend/src/pages/QuizPage.test.jsx b/frontend/src/pages/QuizPage.test.jsx index 9948255..511f01f 100644 --- a/frontend/src/pages/QuizPage.test.jsx +++ b/frontend/src/pages/QuizPage.test.jsx @@ -65,24 +65,29 @@ async function begin(study = true) { } describe('quiz player', () => { - it('lists every question in the rail with a number, excerpt and difficulty', async () => { + it('reveals a rail excerpt only once the question has been reached', async () => { await begin() - const rail = document.querySelector('.quiz-rail-list') - expect(rail).toBeInTheDocument() - - const items = rail.querySelectorAll('.quiz-rail-item') + const items = document.querySelectorAll('.quiz-rail-item') expect(items).toHaveLength(2) + + // The current question shows its text, difficulty and label. expect(within(items[0]).getByText('Question 1')).toBeInTheDocument() expect(within(items[0]).getByText('Full first clinical question.')).toBeInTheDocument() expect(within(items[0]).getByText('hard')).toBeInTheDocument() expect(items[0]).toHaveAttribute('aria-current', 'true') + + // The unopened one is a bare number — previewing it would spoil the case. + expect(items[1].className).toMatch(/is-unseen/) + expect(within(items[1]).queryByText('Full second clinical question.')).not.toBeInTheDocument() expect(items[1]).not.toHaveAttribute('aria-current') - // The rail navigates, and the active row follows. + // Reaching it reveals the excerpt and moves the active row. await userEvent.click(items[1]) await findStem('Full second clinical question.') const after = document.querySelectorAll('.quiz-rail-item') expect(after[1]).toHaveAttribute('aria-current', 'true') + expect(after[1].className).not.toMatch(/is-unseen/) + expect(within(after[1]).getByText('Full second clinical question.')).toBeInTheDocument() }) it('marks answered questions in the rail', async () => { diff --git a/frontend/src/pages/QuizPlayer.css b/frontend/src/pages/QuizPlayer.css index 3f6f7a4..df82025 100644 --- a/frontend/src/pages/QuizPlayer.css +++ b/frontend/src/pages/QuizPlayer.css @@ -16,6 +16,7 @@ font: inherit; text-align: left; cursor: pointer; color: #4a5058; } .quiz-rail-item:hover { background: #f4f6fb; } +.quiz-rail-item.is-unseen { padding: 7px 8px; } .quiz-rail-item.is-active { background: #eaf0fa; border-left-color: #496fa5; color: #253038; } .quiz-rail-num { flex-shrink: 0; width: 26px; height: 26px; border-radius: 50%;