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%;