fix: reveal rail excerpts gradually; session rail on the analysis page

Runner rail was leaking upcoming questions
It showed an excerpt for every question in the session, including ones the
learner had not opened — so the case was spoiled before they read it, the same
mistake the article page was making with answers. A question now shows its text
only once reached; the rest are bare numbers.

Analysis page
Added the "Latest sessions" rail beside the analysis, collapsible, each row
linking to resume or review.

SessionProgress component
A single flat fill cannot tell "answered 15, all right" from "answered 15, half
wrong", which is the thing worth seeing at a glance. Progress now renders as
correct / incorrect / remaining segments, shared by the analysis rail and the
dashboard panel.

Tests: the rail test now asserts an unopened question shows no excerpt and
reveals one when reached. Full suites green: 113 backend, 136 frontend.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PpfzbZ1QTLMeVYxM2kyq8m
This commit is contained in:
Daniel 2026-09-10 02:11:11 +02:00
parent 48f3ded222
commit 587b23db8f
7 changed files with 122 additions and 13 deletions

View file

@ -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); }

View file

@ -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 (
<div className="sp-wrap">
<span className="sp-track">
{right !== null && <span className="sp-correct" style={{ width: `${pct(right)}%` }} />}
{right !== null && wrong > 0 && <span className="sp-wrong" style={{ width: `${pct(wrong)}%` }} />}
{right === null && <span className="sp-seen" style={{ width: `${pct(seen)}%` }} />}
</span>
{label && <span className="sp-label">{seen}/{total} questions</span>}
</div>
)
}

View file

@ -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. */

View file

@ -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 (
<div className={`an-layout${railOpen ? '' : ' rail-closed'}`}>
<aside className="an-rail">
<div className="an-rail-head">
<h2>Latest sessions</h2>
<button type="button" aria-label={railOpen ? 'Hide sessions' : 'Show sessions'}
aria-expanded={railOpen} onClick={() => setRailOpen(v => !v)}>{railOpen ? '' : ''}</button>
</div>
{railOpen && (
sessions.length === 0
? <p className="an-rail-empty">No sessions yet.</p>
: <ul className="an-rail-list">
{sessions.map(row => (
<li key={row.quiz_id}>
<Link to={row.state === 'completed' && row.last_attempt_id
? `/results/${row.last_attempt_id}` : `/quizzes/${row.quiz_id}`}>
<span className="an-rail-title">
<strong>{row.mode === 'learning' ? 'Study mode:' : 'Exam mode:'}</strong> {row.title}
</span>
<SessionProgress answered={row.answered} total={row.total}
correct={row.state === 'completed' ? row.last_score : null} />
</Link>
</li>
))}
</ul>
)}
</aside>
<div className="an-page">
<div className="an-header">
<h1>Your performance analysis</h1>
@ -184,5 +220,6 @@ export default function AnalysisPage() {
</>
)}
</div>
</div>
)
}

View file

@ -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 (
<button type="button"
className={`quiz-rail-item${isActive ? ' is-active' : ''}${isDone ? ' is-done' : ''}`}
className={`quiz-rail-item${isActive ? ' is-active' : ''}${isDone ? ' is-done' : ''}${seen ? '' : ' is-unseen'}`}
aria-current={isActive ? 'true' : undefined}
onClick={() => { safeNavigate(i); setNavOpen(false) }}>
<span className="quiz-rail-num">
{i + 1}
{marked && <span className="quiz-rail-mark" aria-label="Marked"></span>}
</span>
<span className="quiz-rail-body">
<span className="quiz-rail-label">Question {i + 1}</span>
<span className="quiz-rail-text">{excerpt.slice(0, 64)}{excerpt.length > 64 ? '…' : ''}</span>
{q.difficulty && <span className={`quiz-rail-diff is-${q.difficulty}`}>{q.difficulty}</span>}
</span>
{seen && (
<span className="quiz-rail-body">
<span className="quiz-rail-label">Question {i + 1}</span>
<span className="quiz-rail-text">{excerpt.slice(0, 64)}{excerpt.length > 64 ? '…' : ''}</span>
{q.difficulty && <span className={`quiz-rail-diff is-${q.difficulty}`}>{q.difficulty}</span>}
</span>
)}
</button>
)
}

View file

@ -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 () => {

View file

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