fix: the top bar's vibration, the session rail, and three pieces of old design

The bar vibrated because I made it fight itself
Collapsing it takes 46px out of the page, the browser corrects the scroll
position to compensate, and that correction arrives as an upward scroll — which
shows the bar, lengthens the page, and starts the loop again. The 6px jitter
guard could not help: these were real scroll events, just ones we had caused.
State now cannot change until the layout has settled, and a test reproduces the
echo rather than trusting the fix.

Session history, built properly
"Show all 15" expanded a list in place, which answers a smaller question than
the one being asked. There is now a real /sessions page: a rail that filters by
status and mode and sorts by recency, weakest or best, a running average across
what has actually been attempted, and nothing truncated. The quizzes page keeps
its preview and links straight to it. A session never attempted sorts last under
both "weakest" and "best" — the absence of a score is not a bad one.

The session rail is one row per question
A five-line excerpt per question turned a rail of twenty into a page of its own.
Number, then the stem on one line, truncated. The repeated "Question N" label
said nothing the number had not.

"Manage references and drafts" said nothing useful
"References" there means lab values rather than citations, and "drafts" means
unpublished ones — an editing switch hidden in a learner's lookup, phrased as
neither. It is now an "Edit values" toggle that says, while it is on, that
unpublished values are showing and that changes reach every learner.

Also gone: the "All past attempts" block, which the Sessions view had already
replaced.

248 frontend tests green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
This commit is contained in:
Daniel 2026-09-11 02:51:46 +02:00
parent f2b5e146eb
commit e63c625ed4
12 changed files with 381 additions and 134 deletions

View file

@ -33,6 +33,7 @@ const LandingPage = lazy(() => import('./pages/LandingPage'))
const FlashcardsPage = lazy(() => import('./pages/FlashcardsPage'))
const ArticlesPage = lazy(() => import('./pages/ArticlesPage'))
const SearchPage = lazy(() => import('./pages/SearchPage'))
const SessionsPage = lazy(() => import('./pages/SessionsPage'))
const AiModePage = lazy(() => import('./pages/AiModePage'))
const MediaPage = lazy(() => import('./pages/MediaPage'))
const EditorialPage = lazy(() => import('./pages/EditorialPage'))
@ -94,6 +95,7 @@ function AppRoutes() {
<Route element={<AppLayout />}>
<Route path="/" element={<DashboardPage />} />
<Route path="/quizzes" element={<QuizzesPage />} />
<Route path="/sessions" element={<SessionsPage />} />
<Route path="/quizzes/create" element={<CustomQuizPage />} />
<Route path="/quizzes/:id" element={<QuizPage />} />
<Route path="/results/:id" element={<ResultsPage />} />

View file

@ -14,6 +14,9 @@ vi.mock('./GlobalSearch', () => ({ default: () => <input aria-label="Search Peds
const mount = () => render(<MemoryRouter initialEntries={['/articles']}><Navbar /></MemoryRouter>)
// Long enough for the layout to have settled, which is what the hook waits for.
const settle = () => act(async () => { await new Promise(r => setTimeout(r, 400)) })
const scrollTo = async (y) => {
window.scrollY = y
await act(async () => {
@ -60,10 +63,24 @@ describe('two-bar header', () => {
expect(bar).toHaveClass('is-hidden')
// Scrolling up is the gesture that means "I am looking for something".
await settle()
await scrollTo(320)
expect(bar).not.toHaveClass('is-hidden')
})
it('does not vibrate when collapsing moves the page under it', async () => {
mount()
const bar = document.querySelector('.navbar-sections')
await scrollTo(400)
expect(bar).toHaveClass('is-hidden')
// Losing 46px of bar makes the page shorter and the browser corrects the
// scroll position; that correction arrives as an upward scroll. Acting on it
// would show the bar, lengthen the page, and start the loop over.
await scrollTo(354)
expect(bar).toHaveClass('is-hidden')
})
it('stays put near the top, so a short page never loses it', async () => {
mount()
const bar = document.querySelector('.navbar-sections')

View file

@ -64,3 +64,15 @@
.quiz-lab-age-line:last-child { border-bottom: none; }
.quiz-lab-age { color: var(--text-muted); font-size: .78rem; min-width: 0; overflow-wrap: anywhere; }
.quiz-lab-row { grid-template-columns: minmax(150px, 1fr) minmax(210px, 1.5fr); }
/* Editing inside a lookup has to look like a mode you are in, not a stray
checkbox above the thing it changes. */
.quiz-lab-editing { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin: 4px 0 10px; }
.quiz-lab-edit-toggle {
min-height: 32px; padding: 6px 12px; border-radius: 7px; cursor: pointer;
border: 1px solid var(--border); background: var(--card-bg);
font: inherit; font-size: 0.8rem; font-weight: 600; color: var(--text-muted);
}
.quiz-lab-edit-toggle:hover { border-color: var(--primary); color: var(--primary); }
.quiz-lab-edit-toggle.is-on { background: var(--primary); border-color: var(--primary); color: #fff; }
.quiz-lab-editing-note { font-size: 0.76rem; color: #b45309; }

View file

@ -117,7 +117,19 @@ function LabValues() {
const ordered = [...filtered].sort((a, b) => a.group.localeCompare(b.group) || a.name.localeCompare(b.name))
return <>
<p className="quiz-reference-note">Ranges vary with age, laboratory and method. Confirm against your own laboratory before relying on a value.</p>
{isEducator && <label className="quiz-check"><input type="checkbox" checked={manage} onChange={e => { setManage(e.target.checked); setForm(null); setGroup('All') }} /> Manage references and drafts</label>}
{/* "Manage references and drafts" said nothing useful: "references" here
means lab values, not citations, and "drafts" means unpublished ones.
This is an editing switch inside a lookup, so it says so. */}
{isEducator && <div className="quiz-lab-editing">
<button type="button" className={`quiz-lab-edit-toggle${manage ? ' is-on' : ''}`}
aria-pressed={manage}
onClick={() => { setManage(!manage); setForm(null); setGroup('All') }}>
{manage ? 'Done editing' : 'Edit values'}
</button>
{manage && <span className="quiz-lab-editing-note">
Unpublished values are shown too. Anything you change here every learner sees.
</span>}
</div>}
{error && <p role="alert">{error}</p>}
{linkError && <p role="alert">{linkError}</p>}
<div className="quiz-reference-controls"><label>Search references<input value={query} onChange={e => setQuery(e.target.value)} /></label>
@ -172,8 +184,9 @@ function LabValues() {
</span>
))}
{manage && <span className="quiz-lab-card-link">
<input aria-label={`Card ID to link to ${test.name}`} placeholder="Card ID" value={cardLink[row.id] || ''} onChange={e => setCardLink(prev => ({ ...prev, [row.id]: e.target.value }))} />
<button type="button" onClick={() => linkCard(row.id)}>Link card</button>
<input aria-label={`Card ID to link to ${test.name}`} placeholder="Flashcard ID"
value={cardLink[row.id] || ''} onChange={e => setCardLink(prev => ({ ...prev, [row.id]: e.target.value }))} />
<button type="button" onClick={() => linkCard(row.id)}>Attach card</button>
</span>}
</div>
})()}

View file

@ -54,11 +54,11 @@ describe('lab values panel', () => {
it('lets an educator link and unlink a card per reference', async () => {
renderLabs()
await screen.findByRole('heading', { name: 'CSF' })
await userEvent.click(screen.getByRole('checkbox', { name: /Manage references and drafts/ }))
await userEvent.click(screen.getByRole('button', { name: 'Edit values' }))
const csfSection = screen.getByRole('heading', { name: 'CSF' }).closest('section')
const input = await within(csfSection).findByLabelText('Card ID to link to CSF white cell count')
await userEvent.type(input, '12')
await userEvent.click(within(csfSection).getByRole('button', { name: 'Link card' }))
await userEvent.click(within(csfSection).getByRole('button', { name: 'Attach card' }))
await waitFor(() => expect(api.put).toHaveBeenCalledWith('/study-tools/lab-values/2/cards/12'))
await userEvent.click(screen.getByRole('button', { name: /Unlink card 9 from CSF white cell count/ }))
await waitFor(() => expect(api.delete).toHaveBeenCalledWith('/study-tools/lab-values/2/cards/9'))

View file

@ -11,9 +11,15 @@ import { useEffect, useRef, useState } from 'react'
* short page never loses it, and a movement under `threshold` is treated as
* noise trackpads and momentum scrolling emit a lot of one-pixel jitter.
*/
export default function useHidingBar({ offset = 90, threshold = 6 } = {}) {
export default function useHidingBar({ offset = 90, threshold = 6, settle = 350 } = {}) {
const [hidden, setHidden] = useState(false)
const lastY = useRef(0)
// When the bar collapses, the page gets 46px shorter and the browser adjusts
// the scroll position to compensate. That adjustment is itself a scroll event,
// in the opposite direction, which shows the bar again — which makes the page
// taller, which scrolls again. The result is a bar that vibrates. Nothing may
// change state until the layout has settled.
const lockedUntil = useRef(0)
useEffect(() => {
if (typeof window === 'undefined') return
@ -25,10 +31,19 @@ export default function useHidingBar({ offset = 90, threshold = 6 } = {}) {
frame = window.requestAnimationFrame(() => {
frame = 0
const y = window.scrollY
if (performance.now() < lockedUntil.current) {
// Still the echo of our own resize: follow the page, decide nothing.
lastY.current = y
return
}
const delta = y - lastY.current
if (Math.abs(delta) < threshold) return
lastY.current = y
setHidden(y > offset && delta > 0)
const next = y > offset && delta > 0
setHidden(prev => {
if (prev !== next) lockedUntil.current = performance.now() + settle
return next
})
})
}
@ -37,7 +52,7 @@ export default function useHidingBar({ offset = 90, threshold = 6 } = {}) {
window.removeEventListener('scroll', onScroll)
if (frame) window.cancelAnimationFrame(frame)
}
}, [offset, threshold])
}, [offset, threshold, settle])
return hidden
}

View file

@ -911,13 +911,9 @@ const timerStarted = timeLeft !== null
{i + 1}
{marked && <span className="quiz-rail-mark" aria-label="Marked"></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>
)}
{/* One line each. The number already says which question it is, and a
five-line excerpt makes a rail of twenty into a page of its own. */}
{seen && <span className="quiz-rail-text">{excerpt}</span>}
</button>
)
}

View file

@ -70,10 +70,12 @@ describe('quiz player', () => {
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()
// One row, one line: the number, then the stem truncated by CSS. A repeated
// "Question N" label and a stacked difficulty pill turned a rail of twenty
// into a page of its own.
expect(within(items[0]).getByText('1')).toBeInTheDocument()
expect(within(items[0]).getByText('Full first clinical question.')).toBeInTheDocument()
expect(within(items[0]).getByText('hard')).toBeInTheDocument()
expect(within(items[0]).queryByText('Question 1')).not.toBeInTheDocument()
expect(items[0]).toHaveAttribute('aria-current', 'true')
// The unopened one is a bare number previewing it would spoil the case.

View file

@ -26,10 +26,10 @@
.quiz-rail-item.is-done .quiz-rail-num { background: #dff0e8; color: #327b64; }
.quiz-rail-item.is-active .quiz-rail-num { background: #496fa5; color: #fff; }
.quiz-rail-mark { position: absolute; top: -4px; right: -4px; font-size: .6rem; color: #d99a2b; }
.quiz-rail-body { min-width: 0; display: flex; flex-direction: column; gap: 2px; }
.quiz-rail-label { font-size: .68rem; font-weight: 700; letter-spacing: .04em; text-transform: uppercase; color: #98a0aa; }
.quiz-rail-text { font-size: .78rem; line-height: 1.4; overflow-wrap: anywhere; }
.quiz-rail-diff { align-self: flex-start; font-size: .62rem; font-weight: 700; text-transform: uppercase; letter-spacing: .04em; border-radius: 10px; padding: 1px 7px; margin-top: 2px; }
/* One row, one line: truncated rather than wrapped, so twenty questions
stay a list you can scan instead of a page you have to scroll. */
.quiz-rail-text { flex: 1; min-width: 0; font-size: .78rem; line-height: 1.4;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.quiz-rail-diff.is-easy { background: #eef7f3; color: #327b64; }
.quiz-rail-diff.is-medium { background: #fdf6e8; color: #8a6417; }
.quiz-rail-diff.is-hard { background: #fbecf0; color: #a13c51; }

View file

@ -23,100 +23,6 @@ const dayLabel = (iso) => {
return d.toLocaleDateString(undefined, { month: 'short', day: '2-digit', year: 'numeric' })
}
function PastAttemptsSection() {
const [open, setOpen] = useState(false)
const [history, setHistory] = useState(null)
const [loading, setLoading] = useState(false)
const [deletingId, setDeletingId] = useState(null)
const load = async () => {
if (history !== null) { setOpen(v => !v); return }
setLoading(true)
try {
const res = await api.get('/attempts/history')
setHistory(res.data)
} catch { setHistory([]) }
finally { setLoading(false); setOpen(true) }
}
const deleteAttempt = async (attemptId) => {
setDeletingId(attemptId)
try {
await api.delete(`/attempts/${attemptId}`)
setHistory(prev =>
prev
.map(q => ({ ...q, attempts: q.attempts.filter(a => a.attempt_id !== attemptId) }))
.filter(q => q.attempts.length > 0)
)
} catch { }
finally { setDeletingId(null) }
}
const totalCompleted = history?.reduce((s, q) => s + q.attempts.length, 0) ?? 0
return (
<div className="card" style={{ marginTop: 16 }}>
<button onClick={load} style={{
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
width: '100%', background: 'none', border: 'none', cursor: 'pointer',
padding: 0, textAlign: 'left',
}}>
<h2 style={{ fontSize: '1rem', color: 'var(--text)', margin: 0 }}>
All past attempts {history !== null && `(${totalCompleted})`}
</h2>
<span style={{ color: 'var(--text-muted)', fontSize: '0.8rem' }}>
{loading ? 'Loading...' : open ? '▲ Hide' : '▼ Show'}
</span>
</button>
{open && history !== null && (
<div style={{ marginTop: 12 }}>
{history.length === 0 ? (
<div style={{ color: 'var(--text-muted)', fontSize: '0.875rem' }}>No completed attempts yet.</div>
) : history.map(quiz => (
<div key={quiz.quiz_id} style={{ marginBottom: 14 }}>
<div style={{ fontWeight: 600, fontSize: '0.88rem', marginBottom: 6 }}>{quiz.title}</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 5 }}>
{quiz.attempts.map((a) => (
<div key={a.attempt_id} style={{
display: 'flex', alignItems: 'center',
padding: '7px 12px', background: 'var(--bg)', borderRadius: 8,
fontSize: '0.82rem', gap: 8, flexWrap: 'wrap',
}}>
<span style={{ color: 'var(--text-muted)', flex: 1 }}>
{new Date(a.date).toLocaleDateString()} {a.score}/{a.total}
</span>
<span style={{
fontWeight: 700, fontSize: '0.85rem',
color: a.percentage >= 80 ? 'var(--correct-fg)' : a.percentage >= 50 ? '#d97706' : 'var(--wrong-fg)',
}}>{a.percentage}%</span>
<Link to={`/results/${a.attempt_id}`} style={{ color: 'var(--primary)', fontSize: '0.78rem', textDecoration: 'none', flexShrink: 0 }}>
Review
</Link>
<button
onClick={() => deleteAttempt(a.attempt_id)}
disabled={deletingId === a.attempt_id}
title="Delete this attempt"
style={{
background: 'none', border: 'none', cursor: 'pointer',
color: 'var(--wrong-fg)', fontSize: '0.78rem',
padding: '2px 4px', borderRadius: 4, flexShrink: 0,
opacity: deletingId === a.attempt_id ? 0.4 : 0.6,
}}
>
{deletingId === a.attempt_id ? '…' : '✕'}
</button>
</div>
))}
</div>
</div>
))}
</div>
)}
</div>
)
}
function HighlightText({ text, query }) {
if (!query || !text) return <span>{text}</span>
const idx = text.toLowerCase().indexOf(query.toLowerCase())
@ -383,7 +289,6 @@ export default function QuizzesPage() {
const [tab, setTab] = useState('sessions')
const [stateFilter, setStateFilter] = useState('all')
// The full history lives on the analysis page; this is a launcher.
const [showAllSessions, setShowAllSessions] = useState(false)
const SESSION_PREVIEW = 6
const [searchQuery, setSearchQuery] = useState('')
const [searchMode, setSearchMode] = useState('all')
@ -440,8 +345,8 @@ export default function QuizzesPage() {
)
const previewRows = useMemo(
() => (showAllSessions ? visibleRows : visibleRows.slice(0, SESSION_PREVIEW)),
[visibleRows, showAllSessions],
() => visibleRows.slice(0, SESSION_PREVIEW),
[visibleRows],
)
const dayGroups = useMemo(() => {
@ -616,24 +521,14 @@ export default function QuizzesPage() {
{visibleRows.length > SESSION_PREVIEW && (
<div className="qz-seemore">
{showAllSessions ? (
<button className="btn btn-secondary btn-sm" onClick={() => setShowAllSessions(false)}>
Show fewer
</button>
) : (
<>
<button className="btn btn-secondary btn-sm" onClick={() => setShowAllSessions(true)}>
Show all {visibleRows.length}
</button>
<Link className="btn btn-secondary btn-sm" to="/analysis">
Full session history
</Link>
</>
)}
{/* Expanding a list in place answers a smaller question than
the one being asked. The history page has the filters. */}
<Link className="btn btn-secondary btn-sm" to="/sessions">
Full session history ({visibleRows.length})
</Link>
</div>
)}
<PastAttemptsSection />
</>
)
)}

View file

@ -0,0 +1,77 @@
/* Full session history: the rail narrows, the list never truncates. */
.sx-page { max-width: 1080px; margin: 0 auto; padding-bottom: 48px; }
.sx-header { display: flex; justify-content: space-between; align-items: flex-end; gap: 12px; flex-wrap: wrap; margin-bottom: 16px; }
.sx-header h1 { margin: 0 0 4px; font-size: 1.35rem; }
.sx-header p { margin: 0; color: var(--text-muted); font-size: 0.87rem; }
.sx-body { display: grid; grid-template-columns: 210px 1fr; gap: 20px; align-items: start; }
.sx-rail {
position: sticky; top: 76px; max-height: calc(100vh - 100px); overflow-y: auto;
background: var(--card-bg); border: 1px solid var(--border);
border-radius: 12px; padding: 12px;
}
.sx-search {
width: 100%; min-height: 38px; padding: 8px 11px; margin-bottom: 12px;
border: 1px solid var(--border); border-radius: 8px;
background: var(--input-bg); color: var(--text); font-size: 0.85rem;
}
.sx-rail h2 {
margin: 12px 0 6px; font-size: 0.69rem; font-weight: 700;
letter-spacing: 0.07em; text-transform: uppercase; color: var(--text-subtle);
}
.sx-rail ul { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 2px; }
.sx-filter {
display: flex; align-items: center; gap: 8px; width: 100%; min-height: 36px;
padding: 7px 10px; border: 0; border-radius: 7px; background: none;
font: inherit; font-size: 0.84rem; color: var(--text); text-align: left; cursor: pointer;
}
.sx-filter span:first-child { flex: 1; min-width: 0; }
.sx-filter:hover { background: var(--bg); }
.sx-filter.is-active { background: var(--option-sel-bg); color: var(--primary); font-weight: 650; }
.sx-count { font-size: 0.74rem; color: var(--text-subtle); font-variant-numeric: tabular-nums; }
.sx-summary {
margin-top: 14px; padding-top: 12px; border-top: 1px solid var(--border);
display: flex; flex-direction: column; gap: 2px;
}
.sx-summary strong { font-size: 1.3rem; font-variant-numeric: tabular-nums; }
.sx-summary span { font-size: 0.74rem; color: var(--text-muted); }
.sx-main { min-width: 0; }
.sx-error { color: var(--wrong-fg); font-size: 0.85rem; }
.sx-count-line { margin: 0 0 10px; font-size: 0.8rem; color: var(--text-muted); }
.sx-empty { background: var(--card-bg); border: 1px solid var(--border); border-radius: 12px; padding: 32px; text-align: center; color: var(--text-muted); }
.sx-list { list-style: none; margin: 0; padding: 0; background: var(--card-bg); border: 1px solid var(--border); border-radius: 12px; overflow: hidden; }
.sx-row { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; padding: 12px 14px; border-bottom: 1px solid var(--border); }
.sx-row:last-child { border-bottom: 0; }
.sx-row:hover { background: var(--bg); }
/* An unfinished test is the one thing on this page you can act on right now. */
.sx-row.is-in_progress { box-shadow: inset 3px 0 0 var(--primary); }
.sx-row-main { flex: 1; min-width: 180px; display: flex; flex-direction: column; gap: 2px; }
.sx-title { font-size: 0.92rem; font-weight: 650; color: var(--text); text-decoration: none; overflow-wrap: anywhere; }
.sx-title:hover { color: var(--primary); }
.sx-meta { font-size: 0.76rem; color: var(--text-subtle); }
.sx-score { flex-shrink: 0; min-width: 76px; font-weight: 700; font-size: 0.92rem; font-variant-numeric: tabular-nums; display: flex; flex-direction: column; }
.sx-score em { font-style: normal; font-size: 0.68rem; font-weight: 600; color: var(--text-subtle); }
.sx-score.is-good { color: var(--correct-fg); }
.sx-score.is-fair { color: #b45309; }
.sx-score.is-poor { color: var(--wrong-fg); }
.sx-score.is-none { color: var(--text-subtle); font-weight: 500; font-size: 0.78rem; }
.sx-progress { flex-shrink: 0; min-width: 100px; font-size: 0.8rem; font-weight: 600; color: var(--primary); }
.sx-when { flex-shrink: 0; min-width: 92px; font-size: 0.78rem; color: var(--text-muted); }
.sx-actions { flex-shrink: 0; display: flex; gap: 6px; }
@media (max-width: 820px) {
.sx-body { grid-template-columns: 1fr; }
.sx-rail { position: static; max-height: none; }
.sx-rail ul { flex-direction: row; flex-wrap: wrap; }
.sx-filter { width: auto; }
.sx-when, .sx-score { min-width: 0; }
.sx-actions { width: 100%; }
.sx-actions .btn { flex: 1; }
}

View file

@ -0,0 +1,218 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { Link } from 'react-router-dom'
import api from '../api/client'
import './SessionsPage.css'
const pct = (value) => (value == null ? null : Math.round(value))
const band = (value) => (value == null ? '' : value >= 80 ? ' is-good' : value >= 50 ? ' is-fair' : ' is-poor')
const when = (value) => {
if (!value) return '—'
const date = new Date(value)
const days = Math.round((Date.now() - date) / 86400000)
if (days === 0) return 'Today'
if (days === 1) return 'Yesterday'
if (days < 7) return `${days} days ago`
return date.toLocaleDateString(undefined, { day: '2-digit', month: 'short', year: 'numeric' })
}
const STATES = [
{ key: 'all', label: 'Everything' },
{ key: 'in_progress', label: 'In progress' },
{ key: 'completed', label: 'Completed' },
{ key: 'not_started', label: 'Not started' },
]
const SORTS = [
{ key: 'recent', label: 'Most recent' },
{ key: 'worst', label: 'Weakest score' },
{ key: 'best', label: 'Best score' },
{ key: 'title', label: 'Name' },
]
/**
* Every session, in full, with the sidebar doing the narrowing.
*
* The quizzes page shows a handful and stops. That is right for a landing
* point and wrong for the question "what have I actually done" which is what
* this page answers, so nothing here is truncated and nothing needs a
* "show all".
*/
export default function SessionsPage() {
const [rows, setRows] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [state, setState] = useState('all')
const [mode, setMode] = useState('all')
const [sort, setSort] = useState('recent')
const [query, setQuery] = useState('')
const load = useCallback(() => {
setLoading(true)
api.get('/quizzes/sessions')
.then(res => setRows(res.data || []))
.catch(() => setError('Could not load your sessions'))
.finally(() => setLoading(false))
}, [])
useEffect(() => { load() }, [load])
const modes = useMemo(
() => [...new Set(rows.map(r => r.mode).filter(Boolean))].sort(), [rows])
const shown = useMemo(() => {
const needle = query.trim().toLowerCase()
const filtered = rows.filter(row => (
(state === 'all' || row.state === state)
&& (mode === 'all' || row.mode === mode)
&& (!needle || (row.title || '').toLowerCase().includes(needle))
))
const by = {
recent: (a, b) => (b.last_activity || '').localeCompare(a.last_activity || ''),
// Nulls last in both directions: a session never attempted is not the
// weakest score, it is the absence of one.
worst: (a, b) => (a.last_percentage ?? 1e9) - (b.last_percentage ?? 1e9),
best: (a, b) => (b.last_percentage ?? -1) - (a.last_percentage ?? -1),
title: (a, b) => (a.title || '').localeCompare(b.title || ''),
}
return [...filtered].sort(by[sort])
}, [rows, state, mode, sort, query])
const counts = useMemo(() => {
const tally = { all: rows.length }
for (const row of rows) tally[row.state] = (tally[row.state] || 0) + 1
return tally
}, [rows])
const attempted = rows.filter(r => r.last_percentage != null)
const average = attempted.length
? Math.round(attempted.reduce((sum, r) => sum + r.last_percentage, 0) / attempted.length)
: null
return (
<div className="sx-page">
<div className="sx-header">
<div>
<h1>Session history</h1>
<p>Every test you have started or finished.</p>
</div>
<Link className="btn btn-secondary" to="/quizzes">Quizzes</Link>
</div>
<div className="sx-body">
<aside className="sx-rail">
<input className="sx-search" value={query} onChange={e => setQuery(e.target.value)}
placeholder="Search by name" aria-label="Search sessions" />
<h2>Status</h2>
<ul>
{STATES.map(option => (
<li key={option.key}>
<button className={`sx-filter${state === option.key ? ' is-active' : ''}`}
onClick={() => setState(option.key)}>
<span>{option.label}</span>
<span className="sx-count">{counts[option.key] || 0}</span>
</button>
</li>
))}
</ul>
{modes.length > 1 && (
<>
<h2>Mode</h2>
<ul>
<li>
<button className={`sx-filter${mode === 'all' ? ' is-active' : ''}`}
onClick={() => setMode('all')}><span>Any mode</span></button>
</li>
{modes.map(name => (
<li key={name}>
<button className={`sx-filter${mode === name ? ' is-active' : ''}`}
onClick={() => setMode(name)}>
<span>{name === 'learning' ? 'Study' : name === 'timed' ? 'Exam' : name}</span>
</button>
</li>
))}
</ul>
</>
)}
<h2>Sort</h2>
<ul>
{SORTS.map(option => (
<li key={option.key}>
<button className={`sx-filter${sort === option.key ? ' is-active' : ''}`}
onClick={() => setSort(option.key)}><span>{option.label}</span></button>
</li>
))}
</ul>
{average != null && (
<div className="sx-summary">
<strong>{average}%</strong>
<span>average across {attempted.length} attempted</span>
</div>
)}
</aside>
<div className="sx-main">
{error && <p className="sx-error" role="alert">{error}</p>}
{loading ? <div className="loading"><div className="spinner" /></div>
: shown.length === 0 ? (
<div className="sx-empty">
{rows.length === 0 ? 'No sessions yet.' : 'Nothing matches those filters.'}
</div>
) : (
<>
<p className="sx-count-line" role="status">
{shown.length} of {rows.length} session{rows.length === 1 ? '' : 's'}
</p>
<ul className="sx-list">
{shown.map(row => (
<li key={row.quiz_id} className={`sx-row is-${row.state}`}>
<div className="sx-row-main">
<Link className="sx-title" to={`/quizzes/${row.quiz_id}`}>{row.title}</Link>
<span className="sx-meta">
{row.category_name && <>{row.category_name} · </>}
{row.mode === 'learning' ? 'Study' : row.mode === 'timed' ? 'Exam' : row.mode}
{' · '}{row.questions_per_attempt || row.questions_count} questions
{row.attempts_count > 1 && <> · {row.attempts_count} attempts</>}
</span>
</div>
{row.state === 'in_progress' ? (
<span className="sx-progress">
{row.answered}/{row.total} answered
</span>
) : row.last_percentage != null ? (
<span className={`sx-score${band(row.last_percentage)}`}>
{pct(row.last_percentage)}%
{row.best_percentage != null && row.best_percentage !== row.last_percentage && (
<em>best {pct(row.best_percentage)}%</em>
)}
</span>
) : <span className="sx-score is-none">Not attempted</span>}
<span className="sx-when">{when(row.last_activity)}</span>
<span className="sx-actions">
{row.state === 'in_progress' && (
<Link className="btn btn-primary btn-sm" to={`/quizzes/${row.quiz_id}`}>Resume</Link>
)}
{row.last_attempt_id && (
<Link className="btn btn-secondary btn-sm" to={`/results/${row.last_attempt_id}`}>Review</Link>
)}
{row.state === 'not_started' && (
<Link className="btn btn-secondary btn-sm" to={`/quizzes/${row.quiz_id}`}>Start</Link>
)}
</span>
</li>
))}
</ul>
</>
)}
</div>
</div>
</div>
)
}