feat: sessions and analysis are one page, with the rail as the list

From the recording: sessions and analysis are the same subject, and
there should be no separate sessions page. The sidebar lists every
session, the top entry is the overall analysis, and each row opens that
session's own performance and recommendations.

- /sessions          overall analysis (was /analysis)
- /sessions/:attempt one session (was /analysis/session/:id)
- /sessions/q/:quiz  a session not yet sat — says so and offers Start
- /study/:id         taking a test (was /quizzes/:id then /sessions/:id)
- /study/new         building a custom test
- old /quizzes, /analysis and /analysis/session paths redirect

A session you have not answered no longer launches when clicked. It
opens its overview, which says nothing has been answered and that the
analysis fills in once it is sat through. Clicking a name in a list
used to drop the learner into a 240-question exam with the clock on.

SessionRail is shared by both analysis views; SessionsPage is gone and
so are the duplicate "History" and "Analysis" menu entries.

In the player, the counter that unfolded a grid of question numbers is
gone wherever the session rail is on screen — the rail already lists
every question and fills in as you go. Below 1150px, where the rail is
hidden, the dropdown remains as the only navigator.

Backend 208/208, frontend 252/252.

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 04:18:04 +02:00
parent ebb9e701ee
commit 5a37c3d3f0
31 changed files with 372 additions and 427 deletions

View file

@ -1,5 +1,5 @@
import { lazy, Suspense } from 'react' import { lazy, Suspense } from 'react'
import { BrowserRouter, Routes, Route, Navigate, Outlet, useLocation } from 'react-router-dom' import { BrowserRouter, Routes, Route, Navigate, Outlet, useLocation, useParams } from 'react-router-dom'
import { AuthProvider, useAuth } from './context/AuthContext' import { AuthProvider, useAuth } from './context/AuthContext'
import { ThemeProvider } from './context/ThemeContext' import { ThemeProvider } from './context/ThemeContext'
import Navbar from './components/Navbar' import Navbar from './components/Navbar'
@ -33,7 +33,6 @@ const LandingPage = lazy(() => import('./pages/LandingPage'))
const FlashcardsPage = lazy(() => import('./pages/FlashcardsPage')) const FlashcardsPage = lazy(() => import('./pages/FlashcardsPage'))
const ArticlesPage = lazy(() => import('./pages/ArticlesPage')) const ArticlesPage = lazy(() => import('./pages/ArticlesPage'))
const SearchPage = lazy(() => import('./pages/SearchPage')) const SearchPage = lazy(() => import('./pages/SearchPage'))
const SessionsPage = lazy(() => import('./pages/SessionsPage'))
const AnalysisSessionPage = lazy(() => import('./pages/AnalysisSessionPage')) const AnalysisSessionPage = lazy(() => import('./pages/AnalysisSessionPage'))
const AiModePage = lazy(() => import('./pages/AiModePage')) const AiModePage = lazy(() => import('./pages/AiModePage'))
const MediaPage = lazy(() => import('./pages/MediaPage')) const MediaPage = lazy(() => import('./pages/MediaPage'))
@ -79,14 +78,21 @@ function RequireAuth({ moderator = false }) {
} }
/** /**
* Sends an old /quizzes URL to its /sessions equivalent, keeping whatever * Rewrites the leading segment of an old URL, keeping the rest of the path and
* follows and any query string. `replace` so the browser's Back button skips * any query string. `replace` so the browser's Back button skips the dead
* the dead address rather than bouncing the learner straight back to it. * address rather than bouncing the learner straight back to it.
*/ */
function LegacyQuizRedirect() { function LegacyRedirect({ from, to }) {
const { pathname, search, hash } = useLocation() const { pathname, search, hash } = useLocation()
const rest = pathname.replace(/^\/quizzes/, '') const rest = pathname.slice(from.length)
return <Navigate to={`/sessions${rest}${search}${hash}`} replace /> return <Navigate to={`${to}${rest}${search}${hash}`} replace />
}
/** /analysis/session/:attemptId is now simply /sessions/:attemptId. */
function LegacySessionRedirect() {
const { attemptId } = useParams()
const { search, hash } = useLocation()
return <Navigate to={`/sessions/${attemptId}${search}${hash}`} replace />
} }
function AppRoutes() { function AppRoutes() {
@ -110,16 +116,24 @@ function AppRoutes() {
<Route element={<RequireAuth />}> <Route element={<RequireAuth />}>
<Route element={<AppLayout />}> <Route element={<AppLayout />}>
<Route path="/" element={<DashboardPage />} /> <Route path="/" element={<DashboardPage />} />
<Route path="/sessions" element={<SessionsPage />} /> {/* Sessions and analysis are one thing: the sidebar lists every
<Route path="/sessions/create" element={<CustomQuizPage />} /> session, the top entry is the overall picture, and each row
<Route path="/sessions/:id" element={<QuizPage />} /> opens that session's own performance. There is no separate
list page that was the same rows under a second name. */}
<Route path="/sessions" element={<AnalysisPage />} />
<Route path="/sessions/:attemptId" element={<AnalysisSessionPage />} />
{/* A session with nothing sat yet has no attempt to analyse, so it
is addressed by quiz instead and says what is missing. */}
<Route path="/sessions/q/:quizId" element={<AnalysisSessionPage />} />
{/* Doing the work, as opposed to reviewing it. */}
<Route path="/study/new" element={<CustomQuizPage />} />
<Route path="/study/:id" element={<QuizPage />} />
<Route path="/results/:id" element={<ResultsPage />} /> <Route path="/results/:id" element={<ResultsPage />} />
<Route path="/documents/:id" element={<DocumentDetailPage />} /> <Route path="/documents/:id" element={<DocumentDetailPage />} />
<Route path="/account" element={<AccountPage />} /> <Route path="/account" element={<AccountPage />} />
<Route path="/settings" element={<SettingsPage />} /> <Route path="/settings" element={<SettingsPage />} />
<Route path="/question-bank" element={<QuestionBankPage />} /> <Route path="/question-bank" element={<QuestionBankPage />} />
<Route path="/analysis" element={<AnalysisPage />} />
<Route path="/analysis/session/:attemptId" element={<AnalysisSessionPage />} />
<Route path="/questions/manage" element={<QuestionManagerPage />} /> <Route path="/questions/manage" element={<QuestionManagerPage />} />
<Route path="/flashcards" element={<FlashcardsPage />} /> <Route path="/flashcards" element={<FlashcardsPage />} />
<Route path="/search" element={<SearchPage />} /> <Route path="/search" element={<SearchPage />} />
@ -144,7 +158,7 @@ function AppRoutes() {
<Route element={<RequireAuth moderator />}> <Route element={<RequireAuth moderator />}>
<Route element={<AppLayout />}> <Route element={<AppLayout />}>
<Route path="/upload" element={<UploadPage />} /> <Route path="/upload" element={<UploadPage />} />
<Route path="/sessions/:id/edit" element={<QuizEditPage />} /> <Route path="/study/:id/edit" element={<QuizEditPage />} />
<Route path="/jobs" element={<JobsPage />} /> <Route path="/jobs" element={<JobsPage />} />
<Route path="/trash" element={<TrashPage />} /> <Route path="/trash" element={<TrashPage />} />
<Route path="/categories" element={<CategoriesPage />} /> <Route path="/categories" element={<CategoriesPage />} />
@ -157,7 +171,11 @@ function AppRoutes() {
{/* The word "quiz" is gone from the interface, but links to it are in {/* The word "quiz" is gone from the interface, but links to it are in
bookmarks, in shared messages, and in anything already open in a bookmarks, in shared messages, and in anything already open in a
second tab. These keep working rather than landing on Not Found. */} second tab. These keep working rather than landing on Not Found. */}
<Route path="/quizzes/*" element={<LegacyQuizRedirect />} /> <Route path="/quizzes/*" element={<LegacyRedirect from="/quizzes" to="/study" />} />
{/* /analysis was the overall picture and /analysis/session/:id one
session's; both now live under /sessions. */}
<Route path="/analysis" element={<Navigate to="/sessions" replace />} />
<Route path="/analysis/session/:attemptId" element={<LegacySessionRedirect />} />
{/* Catch-all */} {/* Catch-all */}
<Route path="*" element={user ? <NotFoundPage /> : <Navigate to="/home" replace />} /> <Route path="*" element={user ? <NotFoundPage /> : <Navigate to="/home" replace />} />

View file

@ -56,10 +56,10 @@ export default function CategoryPerformance() {
</select> </select>
</label> </label>
<button type="button" className="btn btn-primary btn-sm" <button type="button" className="btn btn-primary btn-sm"
onClick={() => navigate(`/sessions/create?adaptive=1&count=${adaptiveCount}`)}>Start adaptive session</button> onClick={() => navigate(`/study/new?adaptive=1&count=${adaptiveCount}`)}>Start adaptive session</button>
{main.length > 0 && ( {main.length > 0 && (
<button type="button" className="btn btn-secondary btn-sm" <button type="button" className="btn btn-secondary btn-sm"
onClick={() => navigate(`/sessions/create?adaptive=1&count=${adaptiveCount}&${main.slice(0, 3).map(row => `category=${row.category_id}`).join('&')}`)}> onClick={() => navigate(`/study/new?adaptive=1&count=${adaptiveCount}&${main.slice(0, 3).map(row => `category=${row.category_id}`).join('&')}`)}>
On my weakest topics On my weakest topics
</button> </button>
)} )}

View file

@ -63,7 +63,7 @@ export default function ContinueStudy() {
</span> </span>
<span className="cs-count">{answered}/{total}</span> <span className="cs-count">{answered}/{total}</span>
<Link className="btn btn-secondary btn-sm" <Link className="btn btn-secondary btn-sm"
to={done ? `/analysis/session/${row.last_attempt_id}` : `/sessions/${row.quiz_id}`}> to={done ? `/study/${row.last_attempt_id}` : `/study/${row.quiz_id}`}>
{done ? 'Review' : 'Resume'} {done ? 'Review' : 'Resume'}
</Link> </Link>
</div> </div>

View file

@ -48,7 +48,7 @@ function JobsBadge({ jobs }) {
</div> </div>
<div style={{ color: 'var(--text-muted)', fontSize: '0.78rem' }}>{job.last_step || 'Waiting…'}</div> <div style={{ color: 'var(--text-muted)', fontSize: '0.78rem' }}>{job.last_step || 'Waiting…'}</div>
{job.status === 'completed' && job.quiz_id && ( {job.status === 'completed' && job.quiz_id && (
<Link to={`/sessions/${job.quiz_id}`} style={{ fontSize: '0.75rem', color: 'var(--primary)', textDecoration: 'none', display: 'block', marginTop: 4 }} <Link to={`/study/${job.quiz_id}`} style={{ fontSize: '0.75rem', color: 'var(--primary)', textDecoration: 'none', display: 'block', marginTop: 4 }}
onClick={() => setOpen(false)}>Open Quiz </Link> onClick={() => setOpen(false)}>Open Quiz </Link>
)} )}
</div> </div>
@ -107,10 +107,9 @@ export default function Navbar({ onSignIn, onRegister }) {
{ to: '/home', label: 'Home' }, { to: '/home', label: 'Home' },
{ to: '/', label: 'Dashboard' }, { to: '/', label: 'Dashboard' },
{ to: '/ai', label: 'AI Mode' }, { to: '/ai', label: 'AI Mode' },
// One entry, because there is one page. "Sessions" and "History" were two // One entry. Sessions and analysis are the same subject the list of what
// names for the same list, which is what made it unreadable. // you have sat and the reading of how it went so they are one page.
{ to: '/sessions', label: 'Sessions' }, { to: '/sessions', label: 'Sessions' },
{ to: '/analysis', label: 'Analysis' },
{ to: '/question-bank', label: 'Question Bank' }, { to: '/question-bank', label: 'Question Bank' },
...(canManageQuestions ? [{ to: '/questions/manage', label: 'Manage Qs' }, ...(canManageQuestions ? [{ to: '/questions/manage', label: 'Manage Qs' },
{ to: '/media', label: 'Images' }, { to: '/media', label: 'Images' },

View file

@ -40,7 +40,7 @@ export default function PractiseTopic({ article, canEdit, questions, onUnlink })
difficulty: null, algorithm: 'random', difficulty: null, algorithm: 'random',
article_ids: [article.id], tag_ids: [], explicit_ids: [], article_ids: [article.id], tag_ids: [], explicit_ids: [],
}) })
navigate(`/sessions/${res.data.id}`) navigate(`/study/${res.data.id}`)
} catch (err) { } catch (err) {
const detail = err.response?.data?.detail const detail = err.response?.data?.detail
setError(typeof detail === 'string' ? detail : 'Could not create a test from this topic.') setError(typeof detail === 'string' ? detail : 'Could not create a test from this topic.')

View file

@ -18,7 +18,7 @@ const mount = (props = {}) => render(
<Routes> <Routes>
<Route path="/articles/7" element={ <Route path="/articles/7" element={
<PractiseTopic article={ARTICLE} canEdit={false} questions={QUESTIONS} onUnlink={vi.fn()} {...props} />} /> <PractiseTopic article={ARTICLE} canEdit={false} questions={QUESTIONS} onUnlink={vi.fn()} {...props} />} />
<Route path="/sessions/:id" element={<h1>Test ready</h1>} /> <Route path="/study/:id" element={<h1>Test ready</h1>} />
</Routes> </Routes>
</MemoryRouter> </MemoryRouter>
) )

View file

@ -0,0 +1,36 @@
/* Additions to the rail shared by the overall analysis and a single session.
The frame itself (.an-rail, .an-rail-head, .an-rail-list) is defined in
AnalysisPage.css, which both views already load. */
/* The overall picture, pinned above the individual sessions and separated from
them it is the parent of the list, not the first row of it. */
.an-rail-all {
display: flex; flex-direction: column; gap: 3px;
padding: 12px 14px; text-decoration: none; color: var(--text);
border-bottom: 1px solid var(--border);
}
.an-rail-all:hover { background: var(--bg); }
.an-rail-all strong { font-size: .85rem; font-weight: 650; }
.an-rail-all span { font-size: .75rem; color: var(--text-muted); }
/* Which session you are reading. Without this the rail gives no clue, and on a
list of fifteen that matters more than the hover state does. */
.an-rail-all.active,
.an-rail-list a.active {
background: var(--option-sel-bg);
box-shadow: inset 3px 0 0 var(--primary);
}
.an-rail-all.active strong,
.an-rail-list a.active .an-rail-title { color: var(--primary); }
.an-rail-search {
width: calc(100% - 20px); margin: 10px; padding: 7px 10px;
/* 16px on touch: iOS zooms the whole page in on any smaller font when a
field takes focus, and never zooms back out. */
font-size: .82rem; font-family: inherit;
border: 1px solid var(--border); border-radius: 7px;
background: var(--input-bg); color: var(--text);
}
@media (max-width: 720px) {
.an-rail-search { font-size: 16px; }
}

View file

@ -0,0 +1,78 @@
import { useMemo, useState } from 'react'
import { NavLink } from 'react-router-dom'
import SessionProgress from './SessionProgress'
import './SessionRail.css'
/**
* The list of sessions, alongside whatever session you are reading.
*
* Sessions and analysis are the same subject, so this is the navigation for
* both: the overall picture sits at the top, every session follows, and the
* one you are looking at is marked. It replaced a separate sessions page,
* which listed the same rows a second time under a different heading.
*
* Nothing is truncated. A learner asking "what have I done" wants the whole
* answer, and the search box is what narrows it.
*/
export default function SessionRail({ sessions, open, onToggle, loading }) {
const [query, setQuery] = useState('')
const shown = useMemo(() => {
const needle = query.trim().toLowerCase()
if (!needle) return sessions
return sessions.filter(row => (row.title || '').toLowerCase().includes(needle))
}, [sessions, query])
return (
<aside className="an-rail">
<div className="an-rail-head">
<h2>Sessions</h2>
<button type="button" aria-label={open ? 'Hide sessions' : 'Show sessions'}
aria-expanded={open} onClick={onToggle}>{open ? '' : ''}</button>
</div>
{open && (
<>
{/* `end` so this only lights up on /sessions itself, not on every
session underneath it. */}
<NavLink to="/sessions" end className="an-rail-all">
<strong>Your overall analysis</strong>
<span>Everything you have practised</span>
</NavLink>
{sessions.length > 6 && (
<input className="an-rail-search" type="search" value={query}
onChange={e => setQuery(e.target.value)}
placeholder="Search sessions" aria-label="Search sessions" />
)}
{loading ? <p className="an-rail-empty">Loading</p>
: shown.length === 0 ? (
<p className="an-rail-empty">
{sessions.length === 0 ? 'No sessions yet.' : 'Nothing matches that.'}
</p>
) : (
<ul className="an-rail-list">
{shown.map(row => (
<li key={row.quiz_id}>
{/* Never launches the test. A session you have not sat
opens its own overview, which says so and offers to
start it clicking a name used to drop you straight
into a 240-question exam. */}
<NavLink to={row.last_attempt_id
? `/sessions/${row.last_attempt_id}` : `/sessions/q/${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} />
</NavLink>
</li>
))}
</ul>
)}
</>
)}
</aside>
)
}

View file

@ -0,0 +1,69 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { MemoryRouter } from 'react-router-dom'
import { describe, expect, it, vi } from 'vitest'
import SessionRail from './SessionRail'
const SESSIONS = [
{ quiz_id: 1, title: 'Board Review I', mode: 'timed', state: 'completed',
answered: 268, total: 268, last_score: 200, last_attempt_id: 91 },
{ quiz_id: 2, title: 'Neonatal jaundice', mode: 'learning', state: 'in_progress',
answered: 4, total: 20, last_attempt_id: 92 },
{ quiz_id: 3, title: 'Board Review IX', mode: 'timed', state: 'not_started',
answered: 0, total: 243, last_attempt_id: null },
]
const mount = (props = {}) => render(
<MemoryRouter>
<SessionRail sessions={SESSIONS} open onToggle={() => {}} loading={false} {...props} />
</MemoryRouter>,
)
describe('the session rail', () => {
it('puts the overall analysis above the sessions themselves', () => {
mount()
expect(screen.getByRole('link', { name: /Your overall analysis/ }))
.toHaveAttribute('href', '/sessions')
})
it('never launches a test — an unsat session opens its overview instead', () => {
mount()
// The two with an attempt read as that attempt's analysis...
expect(screen.getByRole('link', { name: /Board Review I\b/ })).toHaveAttribute('href', '/sessions/91')
expect(screen.getByRole('link', { name: /Neonatal jaundice/ })).toHaveAttribute('href', '/sessions/92')
// ...and the one never sat goes to its overview, not to /study, which
// would drop the learner into a 243-question exam with the clock running.
const unsat = screen.getByRole('link', { name: /Board Review IX/ })
expect(unsat).toHaveAttribute('href', '/sessions/q/3')
expect(unsat.getAttribute('href')).not.toMatch(/^\/study\//)
})
it('says which mode each session was sat in', () => {
mount()
expect(screen.getByText('Study mode:')).toBeInTheDocument()
expect(screen.getAllByText('Exam mode:')).toHaveLength(2)
})
it('filters by name once the list is long enough to need it', async () => {
const many = Array.from({ length: 8 }, (_, i) => ({
quiz_id: i + 10, title: `Session ${i}`, mode: 'timed', state: 'completed',
answered: 5, total: 5, last_score: 5, last_attempt_id: 100 + i,
}))
mount({ sessions: many })
await userEvent.type(screen.getByLabelText('Search sessions'), 'Session 3')
expect(screen.getByRole('link', { name: /Session 3/ })).toBeInTheDocument()
expect(screen.queryByRole('link', { name: /Session 4/ })).not.toBeInTheDocument()
})
it('offers no search box for a list short enough to read', () => {
mount()
expect(screen.queryByLabelText('Search sessions')).not.toBeInTheDocument()
})
it('collapses to just its header', () => {
const onToggle = vi.fn()
mount({ open: false, onToggle })
expect(screen.queryByRole('link', { name: /Board Review I/ })).not.toBeInTheDocument()
expect(screen.getByLabelText('Show sessions')).toBeInTheDocument()
})
})

View file

@ -31,7 +31,6 @@ const COLUMNS = [
links: [ links: [
{ to: '/search', label: 'Search' }, { to: '/search', label: 'Search' },
{ to: '/ai', label: 'AI Mode' }, { to: '/ai', label: 'AI Mode' },
{ to: '/analysis', label: 'Analysis' },
], ],
}, },
{ {

View file

@ -3,7 +3,7 @@ import { render, screen, within } from '@testing-library/react'
import { MemoryRouter } from 'react-router-dom' import { MemoryRouter } from 'react-router-dom'
import SiteFooter from './SiteFooter' import SiteFooter from './SiteFooter'
const ROUTES = ['/home', '/login', '/register', '/', '/sessions', '/question-bank', '/analysis', const ROUTES = ['/home', '/login', '/register', '/', '/sessions', '/question-bank',
'/questions/manage', '/flashcards', '/search', '/ai', '/media', '/study-plans', '/articles', '/questions/manage', '/flashcards', '/search', '/ai', '/media', '/study-plans', '/articles',
'/courses', '/account', '/settings', '/categories', '/editorial'] '/courses', '/account', '/settings', '/categories', '/editorial']

View file

@ -0,0 +1,30 @@
import { useEffect, useState } from 'react'
/**
* Tracks a CSS media query from JavaScript.
*
* For cases where the two layouts are not the same markup styled differently
* but genuinely different controls a navigator that is a permanent column on
* a wide screen and a dropdown on a narrow one. Hiding one of them with CSS
* leaves it in the accessibility tree and in the tab order, so the choice has
* to happen before the render, not after it.
*/
export default function useMediaQuery(query) {
const [matches, setMatches] = useState(
// Guarded for the server and for test environments without matchMedia.
() => (typeof window !== 'undefined' && window.matchMedia
? window.matchMedia(query).matches
: false),
)
useEffect(() => {
if (typeof window === 'undefined' || !window.matchMedia) return undefined
const list = window.matchMedia(query)
const onChange = event => setMatches(event.matches)
setMatches(list.matches)
list.addEventListener('change', onChange)
return () => list.removeEventListener('change', onChange)
}, [query])
return matches
}

View file

@ -2,7 +2,7 @@ import { useState, useEffect, useCallback } from 'react'
import { Link, useNavigate } from 'react-router-dom' import { Link, useNavigate } from 'react-router-dom'
import api from '../api/client' import api from '../api/client'
import CategoryPerformance from '../components/CategoryPerformance' import CategoryPerformance from '../components/CategoryPerformance'
import SessionProgress from '../components/SessionProgress' import SessionRail from '../components/SessionRail'
import './AnalysisPage.css' import './AnalysisPage.css'
const STATUS_LABEL = { focus: 'Focus area', proficient: 'Proficient', no_data: 'No data yet' } const STATUS_LABEL = { focus: 'Focus area', proficient: 'Proficient', no_data: 'No data yet' }
@ -70,6 +70,7 @@ export default function AnalysisPage() {
const [error, setError] = useState('') const [error, setError] = useState('')
const [count, setCount] = useState(10) const [count, setCount] = useState(10)
const [sessions, setSessions] = useState([]) const [sessions, setSessions] = useState([])
const [sessionsLoading, setSessionsLoading] = useState(true)
const [railOpen, setRailOpen] = useState(true) const [railOpen, setRailOpen] = useState(true)
const navigate = useNavigate() const navigate = useNavigate()
@ -84,44 +85,21 @@ export default function AnalysisPage() {
useEffect(() => { load() }, [load]) useEffect(() => { load() }, [load])
// Every session, not a recent handful: this rail is the whole list now.
useEffect(() => { useEffect(() => {
api.get('/quizzes/sessions') api.get('/quizzes/sessions')
.then(res => setSessions((Array.isArray(res.data) ? res.data : []).slice(0, 12))) .then(res => setSessions(Array.isArray(res.data) ? res.data : []))
.catch(() => setSessions([])) .catch(() => setSessions([]))
.finally(() => setSessionsLoading(false))
}, []) }, [])
const startAdaptive = () => navigate(`/sessions/create?adaptive=1&count=${count}`) const startAdaptive = () => navigate(`/study/new?adaptive=1&count=${count}`)
const startCategory = (categoryId) => navigate(`/sessions/create?category=${categoryId}&count=${count}`) const startCategory = (categoryId) => navigate(`/study/new?category=${categoryId}&count=${count}`)
return ( return (
<div className={`an-layout${railOpen ? '' : ' rail-closed'}`}> <div className={`an-layout${railOpen ? '' : ' rail-closed'}`}>
<aside className="an-rail"> <SessionRail sessions={sessions} open={railOpen} loading={sessionsLoading}
<div className="an-rail-head"> onToggle={() => setRailOpen(v => !v)} />
<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}>
{/* The session's own analysis, not the raw answer list
that is what this rail is for. */}
<Link to={row.last_attempt_id
? `/analysis/session/${row.last_attempt_id}` : `/sessions/${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-page">
<div className="an-header"> <div className="an-header">

View file

@ -101,3 +101,13 @@
color: var(--wrong-fg); background: var(--wrong-bg); color: var(--wrong-fg); background: var(--wrong-bg);
border: 1px solid var(--wrong-bd); border-radius: 8px; border: 1px solid var(--wrong-bd); border-radius: 8px;
} }
/* A session with nothing sat yet. States the absence rather than showing a
grid of zeroes, which reads as a score of nought. */
.an-notyet {
max-width: 560px; padding: 22px 24px; text-align: center;
background: var(--card-bg); border: 1px solid var(--border); border-radius: 12px;
}
.an-notyet-lead { margin: 0 0 6px; font-size: 1.02rem; font-weight: 650; }
.an-notyet p { margin: 0 0 10px; font-size: .88rem; color: var(--text-muted); }
.an-notyet-note { line-height: 1.6; }

View file

@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from 'react' import { useCallback, useEffect, useMemo, useState } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom' import { Link, useNavigate, useParams } from 'react-router-dom'
import SessionRail from '../components/SessionRail'
import api from '../api/client' import api from '../api/client'
import './AnalysisSessionPage.css' import './AnalysisSessionPage.css'
@ -67,7 +68,9 @@ const SORTS = {
* reading carefully or stalling. * reading carefully or stalling.
*/ */
export default function AnalysisSessionPage() { export default function AnalysisSessionPage() {
const { attemptId } = useParams() // Two ways in: an attempt to analyse, or when nothing has been sat yet
// the quiz itself, so the page can say what is missing instead of 404ing.
const { attemptId, quizId } = useParams()
const [data, setData] = useState(null) const [data, setData] = useState(null)
const [sessions, setSessions] = useState([]) const [sessions, setSessions] = useState([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
@ -76,6 +79,7 @@ export default function AnalysisSessionPage() {
// Ten at a time: a session of forty is a table nobody reads to the end of. // Ten at a time: a session of forty is a table nobody reads to the end of.
const [page, setPage] = useState(0) const [page, setPage] = useState(0)
const [railOpen, setRailOpen] = useState(true) const [railOpen, setRailOpen] = useState(true)
const [sessionsLoading, setSessionsLoading] = useState(true)
const [confirmDelete, setConfirmDelete] = useState(false) const [confirmDelete, setConfirmDelete] = useState(false)
const [deleting, setDeleting] = useState(false) const [deleting, setDeleting] = useState(false)
const navigate = useNavigate() const navigate = useNavigate()
@ -92,6 +96,7 @@ export default function AnalysisSessionPage() {
} }
const load = useCallback(() => { const load = useCallback(() => {
if (!attemptId) { setLoading(false); return }
setLoading(true) setLoading(true)
api.get(`/attempts/${attemptId}/analysis`) api.get(`/attempts/${attemptId}/analysis`)
.then(res => setData(res.data)) .then(res => setData(res.data))
@ -101,8 +106,9 @@ export default function AnalysisSessionPage() {
useEffect(() => { load() }, [load]) useEffect(() => { load() }, [load])
useEffect(() => { useEffect(() => {
api.get('/quizzes/sessions').then(res => setSessions((res.data || []).slice(0, 12))) api.get('/quizzes/sessions').then(res => setSessions(res.data || []))
.catch(() => setSessions([])) .catch(() => setSessions([]))
.finally(() => setSessionsLoading(false))
}, []) }, [])
const rows = useMemo( const rows = useMemo(
@ -113,6 +119,43 @@ export default function AnalysisSessionPage() {
useEffect(() => { setPage(0) }, [sort]) useEffect(() => { setPage(0) }, [sort])
if (loading) return <div className="loading"><div className="spinner" /></div> if (loading) return <div className="loading"><div className="spinner" /></div>
// Addressed by quiz rather than attempt: nothing has been sat, so there is
// nothing to analyse. Opening a session used to launch it clicking a name
// in a list dropped you into a 240-question exam with the clock running.
// This says what the session is and lets you decide.
if (!data && quizId) {
const row = sessions.find(item => String(item.quiz_id) === String(quizId))
return (
<div className={`an-page${railOpen ? '' : ' is-narrow'}`}>
<SessionRail sessions={sessions} open={railOpen} loading={sessionsLoading}
onToggle={() => setRailOpen(v => !v)} />
{!railOpen && (
<button type="button" className="an-rail-show" onClick={() => setRailOpen(true)}>
Sessions
</button>
)}
<main className="an-main">
<div className="an-head">
<h1>Your performance for <span>{row?.title || 'this session'}</span></h1>
</div>
<div className="an-notyet">
<p className="an-notyet-lead">You have not answered any of this yet.</p>
<p>
{row ? <>{row.questions_per_attempt || row.questions_count} questions
{' · '}{row.mode === 'learning' ? 'Study mode' : 'Exam mode'}</> : null}
</p>
<p className="an-notyet-note">
Sit it through to the end and this page fills in: how you scored,
where the time went, which topics to go back to and what to read.
</p>
<Link className="btn btn-primary" to={`/study/${quizId}`}>Start this session</Link>
</div>
</main>
</div>
)
}
if (!data) return <div className="an-empty">{error || 'Session not found.'}</div> if (!data) return <div className="an-empty">{error || 'Session not found.'}</div>
const correct = data.score const correct = data.score
@ -121,32 +164,8 @@ export default function AnalysisSessionPage() {
return ( return (
<div className={`an-page${railOpen ? '' : ' is-narrow'}`}> <div className={`an-page${railOpen ? '' : ' is-narrow'}`}>
<aside className="an-rail"> <SessionRail sessions={sessions} open={railOpen} loading={sessionsLoading}
<div className="an-rail-head"> onToggle={() => setRailOpen(v => !v)} />
<h2>Latest sessions</h2>
<button type="button" onClick={() => setRailOpen(false)} aria-label="Hide sessions"></button>
</div>
<ul>
{sessions.map(session => (
<li key={session.quiz_id}>
<Link className={`an-rail-item${session.last_attempt_id === Number(attemptId) ? ' is-active' : ''}`}
to={session.last_attempt_id ? `/analysis/session/${session.last_attempt_id}` : `/sessions/${session.quiz_id}`}>
<span className="an-rail-mode">
{session.mode === 'learning' ? 'Study mode' : 'Exam mode'}:
</span>
<span className="an-rail-title">{session.title}</span>
<span className="an-rail-count">
{session.answered}/{session.total} questions
</span>
<span className="an-rail-bar">
<span className="is-right" style={{ width: `${(session.last_percentage || 0)}%` }} />
</span>
</Link>
</li>
))}
{sessions.length === 0 && <li className="an-rail-empty">No sessions yet.</li>}
</ul>
</aside>
{!railOpen && ( {!railOpen && (
<button type="button" className="an-rail-show" onClick={() => setRailOpen(true)}> <button type="button" className="an-rail-show" onClick={() => setRailOpen(true)}>
@ -160,7 +179,7 @@ export default function AnalysisSessionPage() {
<div className="an-head-actions"> <div className="an-head-actions">
<Link className="btn btn-secondary btn-sm" to={`/results/${attemptId}`}>Review answers</Link> <Link className="btn btn-secondary btn-sm" to={`/results/${attemptId}`}>Review answers</Link>
{data.quiz_id && ( {data.quiz_id && (
<Link className="btn btn-secondary btn-sm" to={`/sessions/${data.quiz_id}?restart=1`}>Retake</Link> <Link className="btn btn-secondary btn-sm" to={`/study/${data.quiz_id}?restart=1`}>Retake</Link>
)} )}
{/* Deleting is destructive and irreversible, so it asks first {/* Deleting is destructive and irreversible, so it asks first
inline, because a browser confirm() is not something this inline, because a browser confirm() is not something this

View file

@ -467,7 +467,7 @@ export default function CourseDetailPage() {
)} )}
</div> </div>
{canAttempt && ( {canAttempt && (
<button className="btn btn-primary" onClick={() => navigate(`/sessions/${activeLesson.quiz_id}?return_to=/courses/${courseId}`)}> <button className="btn btn-primary" onClick={() => navigate(`/study/${activeLesson.quiz_id}?return_to=/courses/${courseId}`)}>
{attempts.length > 0 ? 'Retake Quiz' : 'Start Quiz'} {attempts.length > 0 ? 'Retake Quiz' : 'Start Quiz'}
</button> </button>
)} )}

View file

@ -107,7 +107,7 @@ export default function CustomQuizPage() {
is_shared: shared, difficulty: difficulty || null, algorithm: adaptive ? 'adaptive' : 'random', is_shared: shared, difficulty: difficulty || null, algorithm: adaptive ? 'adaptive' : 'random',
article_ids: articleIds, tag_ids: tagIds, explicit_ids: explicitIds, article_ids: articleIds, tag_ids: tagIds, explicit_ids: explicitIds,
}) })
navigate(`/sessions/${result.data.id}`) navigate(`/study/${result.data.id}`)
} catch (err) { } catch (err) {
const detail = err.response?.data?.detail const detail = err.response?.data?.detail
setError(typeof detail === 'string' ? detail : 'Could not create test. Check your settings and try again.') setError(typeof detail === 'string' ? detail : 'Could not create test. Check your settings and try again.')

View file

@ -21,9 +21,9 @@ function setupCount(count = 30) {
}) })
} }
function renderBuilder() { function renderBuilder() {
render(<MemoryRouter initialEntries={['/sessions/create']}><Routes> render(<MemoryRouter initialEntries={['/study/new']}><Routes>
<Route path="/sessions/create" element={<CustomQuizPage />} /> <Route path="/study/new" element={<CustomQuizPage />} />
<Route path="/sessions/:id" element={<h1>Saved test</h1>} /> <Route path="/study/:id" element={<h1>Saved test</h1>} />
</Routes></MemoryRouter>) </Routes></MemoryRouter>)
} }

View file

@ -227,10 +227,10 @@ export default function DocumentDetailPage() {
setActiveJob({ jobId: res.data.job_id, sectionName }) setActiveJob({ jobId: res.data.job_id, sectionName })
// If already completed (sync fallback), navigate directly // If already completed (sync fallback), navigate directly
if (res.data.status === 'completed' && res.data.quiz_id) { if (res.data.status === 'completed' && res.data.quiz_id) {
navigate(`/sessions/${res.data.quiz_id}`) navigate(`/study/${res.data.quiz_id}`)
} }
} else if (res.data.id) { } else if (res.data.id) {
navigate(`/sessions/${res.data.id}`) navigate(`/study/${res.data.id}`)
} }
} catch (err) { } catch (err) {
setError(err.response?.data?.detail || 'Failed to start extraction. Check AI model config.') setError(err.response?.data?.detail || 'Failed to start extraction. Check AI model config.')
@ -285,7 +285,7 @@ export default function DocumentDetailPage() {
<ExtractionProgress <ExtractionProgress
jobId={activeJob.jobId} jobId={activeJob.jobId}
label={activeJob.type === 'flashcard' ? 'Generating Cards' : 'Extracting Questions'} label={activeJob.type === 'flashcard' ? 'Generating Cards' : 'Extracting Questions'}
onDone={(quizId) => { setActiveJob(null); if (activeJob.type === 'flashcard') navigate('/flashcards'); else navigate(`/sessions/${quizId}`) }} onDone={(quizId) => { setActiveJob(null); if (activeJob.type === 'flashcard') navigate('/flashcards'); else navigate(`/study/${quizId}`) }}
onClose={() => setActiveJob(null)} onClose={() => setActiveJob(null)}
/> />
)} )}

View file

@ -65,7 +65,7 @@ function JobDetail({ job }) {
</div> </div>
<div style={{ display: 'flex', gap: 8, flexShrink: 0 }}> <div style={{ display: 'flex', gap: 8, flexShrink: 0 }}>
{job.quiz_id && ( {job.quiz_id && (
<Link to={`/sessions/${job.quiz_id}`} className="btn btn-primary btn-sm">Open Quiz</Link> <Link to={`/study/${job.quiz_id}`} className="btn btn-primary btn-sm">Open Quiz</Link>
)} )}
{job.status === 'running' && ( {job.status === 'running' && (
<button className="btn btn-danger btn-sm" onClick={async () => { <button className="btn btn-danger btn-sm" onClick={async () => {

View file

@ -46,7 +46,7 @@ export default function PublicQuizPage() {
</p> </p>
)} )}
{user ? ( {user ? (
<button className="btn btn-primary" onClick={() => navigate(`/sessions/${quiz.quiz_id}`)}> <button className="btn btn-primary" onClick={() => navigate(`/study/${quiz.quiz_id}`)}>
Take this quiz Take this quiz
</button> </button>
) : ( ) : (

View file

@ -179,7 +179,7 @@ function CreateQuizModal({ selectedIds, categories, onClose, onCreated }) {
time_limit_minutes: form.time_limit_minutes ? parseInt(form.time_limit_minutes) : null, time_limit_minutes: form.time_limit_minutes ? parseInt(form.time_limit_minutes) : null,
} }
}) })
navigate(`/sessions/${res.data.id}`) navigate(`/study/${res.data.id}`)
} else { } else {
const res = await api.post('/questions/from-bank', { const res = await api.post('/questions/from-bank', {
title: form.title, title: form.title,
@ -187,7 +187,7 @@ function CreateQuizModal({ selectedIds, categories, onClose, onCreated }) {
mode: form.mode, mode: form.mode,
time_limit_minutes: form.time_limit_minutes ? parseInt(form.time_limit_minutes) : null, time_limit_minutes: form.time_limit_minutes ? parseInt(form.time_limit_minutes) : null,
}) })
navigate(`/sessions/${res.data.id}`) navigate(`/study/${res.data.id}`)
} }
} catch (err) { setError(apiError(err, 'Could not create quiz')) } } catch (err) { setError(apiError(err, 'Could not create quiz')) }
finally { setLoading(false) } finally { setLoading(false) }

View file

@ -268,7 +268,7 @@ export default function QuizEditPage() {
)} )}
</div> </div>
<div style={{ display: 'flex', gap: 8 }}> <div style={{ display: 'flex', gap: 8 }}>
<Link to={`/sessions/${id}`} className="btn btn-secondary btn-sm"> Back to Quiz</Link> <Link to={`/study/${id}`} className="btn btn-secondary btn-sm"> Back to Quiz</Link>
</div> </div>
</div> </div>
</div> </div>

View file

@ -7,6 +7,7 @@ import RichText from '../components/RichText'
import { mergeTextRanges } from '../utils/highlightOffsets' import { mergeTextRanges } from '../utils/highlightOffsets'
import { useAuth } from '../context/AuthContext' import { useAuth } from '../context/AuthContext'
import api from '../api/client' import api from '../api/client'
import useMediaQuery from '../hooks/useMediaQuery'
import MyNote from '../components/MyNote' import MyNote from '../components/MyNote'
import QuizTools, { QuizDialog } from '../components/QuizTools' import QuizTools, { QuizDialog } from '../components/QuizTools'
import './QuizPlayer.css' import './QuizPlayer.css'
@ -393,6 +394,10 @@ export default function QuizPage() {
const [totalTime, setTotalTime] = useState(null) const [totalTime, setTotalTime] = useState(null)
const [toast, setToast] = useState('') const [toast, setToast] = useState('')
const [navOpen, setNavOpen] = useState(false) const [navOpen, setNavOpen] = useState(false)
// The session rail is the navigator whenever there is room for it; the
// dropdown only exists for screens too narrow to show it. Matches the
// 1150px breakpoint in QuizPlayer.css that hides the rail.
const hasRail = useMediaQuery('(min-width: 1151px)')
const [expandedImagePath, setExpandedImagePath] = useState('') const [expandedImagePath, setExpandedImagePath] = useState('')
const [imageZoom, setImageZoom] = useState(1) const [imageZoom, setImageZoom] = useState(1)
const [startedAt, setStartedAt] = useState(null) const [startedAt, setStartedAt] = useState(null)
@ -926,7 +931,7 @@ const timerStarted = timeLeft !== null
} else { } else {
// Everywhere else the session ends on its analysis: score, timing and // Everywhere else the session ends on its analysis: score, timing and
// what to do next. The answer-by-answer review is one link from there. // what to do next. The answer-by-answer review is one link from there.
navigate(`/analysis/session/${attemptId}`, { state: { result: res.data } }) navigate(`/sessions/${attemptId}`, { state: { result: res.data } })
} }
} catch (err) { } catch (err) {
const detail = err.response?.data?.detail const detail = err.response?.data?.detail
@ -965,7 +970,7 @@ const timerStarted = timeLeft !== null
<div> <div>
{isModerator && ( {isModerator && (
<div style={{ textAlign: 'right', marginBottom: 8, display: 'flex', gap: 8, justifyContent: 'flex-end', flexWrap: 'wrap' }}> <div style={{ textAlign: 'right', marginBottom: 8, display: 'flex', gap: 8, justifyContent: 'flex-end', flexWrap: 'wrap' }}>
<Link to={`/sessions/${id}/edit`} className="btn btn-secondary btn-sm"> Edit Questions</Link> <Link to={`/study/${id}/edit`} className="btn btn-secondary btn-sm"> Edit Questions</Link>
</div> </div>
)} )}
{starting ? ( {starting ? (
@ -1017,10 +1022,14 @@ const timerStarted = timeLeft !== null
onClick={() => safeNavigate(Math.max(0, currentIdx - 1))} onClick={() => safeNavigate(Math.max(0, currentIdx - 1))}
disabled={currentIdx === 0}> Prev</button> disabled={currentIdx === 0}> Prev</button>
<button className="quiz-nav-toggle btn btn-secondary btn-sm" {/* Only where the rail is not on screen. Beside a permanent list of
onClick={() => setNavOpen(v => !v)}> every question, a button that unfolds the same list is noise. */}
{currentIdx + 1} / {totalCount} {navOpen ? '▼' : '▲'} {!hasRail && (
</button> <button className="quiz-nav-toggle btn btn-secondary btn-sm"
onClick={() => setNavOpen(v => !v)}>
{currentIdx + 1} / {totalCount} {navOpen ? '▼' : '▲'}
</button>
)}
{isLast ? ( {isLast ? (
<button className="btn btn-primary" onClick={() => setShowReview(true)} disabled={submitting}> <button className="btn btn-primary" onClick={() => setShowReview(true)} disabled={submitting}>
@ -1208,7 +1217,7 @@ const timerStarted = timeLeft !== null
) : ( ) : (
<button className="btn btn-secondary btn-sm" onClick={() => setRestartConfirm(true)} title="Start this quiz over from the beginning"> Restart</button> <button className="btn btn-secondary btn-sm" onClick={() => setRestartConfirm(true)} title="Start this quiz over from the beginning"> Restart</button>
)} )}
{isModerator && <Link to={`/sessions/${id}/edit`} className="btn btn-secondary btn-sm"> Edit</Link>} {isModerator && <Link to={`/study/${id}/edit`} className="btn btn-secondary btn-sm"> Edit</Link>}
</div> </div>
</div> </div>
{voices.length > 1 && ( {voices.length > 1 && (
@ -1232,7 +1241,11 @@ const timerStarted = timeLeft !== null
{/* Main content */} {/* Main content */}
<div style={{ flex: 1, minWidth: 0 }}> <div style={{ flex: 1, minWidth: 0 }}>
<div className="quiz-topbar"> <div className="quiz-topbar">
<button type="button" className="quiz-question-select" aria-expanded={navOpen} onClick={() => setNavOpen(value => !value)}><small>Question</small><strong>{currentIdx + 1}</strong> of {totalCount} </button> {hasRail ? (
<p className="quiz-question-select is-static"><small>Question</small><strong>{currentIdx + 1}</strong> of {totalCount}</p>
) : (
<button type="button" className="quiz-question-select" aria-expanded={navOpen} onClick={() => setNavOpen(value => !value)}><small>Question</small><strong>{currentIdx + 1}</strong> of {totalCount} </button>
)}
<div className="quiz-top-actions"> <div className="quiz-top-actions">
<button type="button" title="Keyboard shortcuts" aria-label="Keyboard shortcuts" onClick={() => setTool('shortcuts')}> <span>Shortcuts</span></button> <button type="button" title="Keyboard shortcuts" aria-label="Keyboard shortcuts" onClick={() => setTool('shortcuts')}> <span>Shortcuts</span></button>
<button type="button" title="Calculator" aria-label="Calculator" onClick={() => setTool('calculator')}> <span>Calculator</span></button> <button type="button" title="Calculator" aria-label="Calculator" onClick={() => setTool('calculator')}> <span>Calculator</span></button>
@ -1242,7 +1255,7 @@ const timerStarted = timeLeft !== null
<button type="button" aria-label="Next question" disabled={isLast} onClick={() => safeNavigate(currentIdx + 1)}>Next </button> <button type="button" aria-label="Next question" disabled={isLast} onClick={() => safeNavigate(currentIdx + 1)}>Next </button>
</div> </div>
</div> </div>
{navOpen && <div className="quiz-nav-mobile-grid">{questions.map((q, i) => <QuestionDot key={q.id} q={q} i={i} />)}</div>} {!hasRail && navOpen && <div className="quiz-nav-mobile-grid">{questions.map((q, i) => <QuestionDot key={q.id} q={q} i={i} />)}</div>}
{current && ( {current && (
<div className="question-card" style={{ <div className="question-card" style={{
@ -1259,7 +1272,7 @@ const timerStarted = timeLeft !== null
{current.category_breadcrumbs.map((category, index) => ( {current.category_breadcrumbs.map((category, index) => (
<span key={category.id}> <span key={category.id}>
{index > 0 && <span className="quiz-breadcrumb-sep" aria-hidden="true"></span>} {index > 0 && <span className="quiz-breadcrumb-sep" aria-hidden="true"></span>}
<Link to={`/sessions/create?category=${category.id}`} target="_blank" rel="noopener noreferrer">{category.name}</Link> <Link to={`/study/new?category=${category.id}`} target="_blank" rel="noopener noreferrer">{category.name}</Link>
</span> </span>
))} ))}
</nav> </nav>

View file

@ -47,12 +47,12 @@ beforeEach(() => {
api.delete.mockResolvedValue({}) api.delete.mockResolvedValue({})
}) })
function mount(entry = '/sessions/10') { function mount(entry = '/study/10') {
render(<MemoryRouter initialEntries={[entry]}><Routes> render(<MemoryRouter initialEntries={[entry]}><Routes>
<Route path="/sessions/:id" element={<QuizPage />} /> <Route path="/study/:id" element={<QuizPage />} />
{/* Submitting a general session ends on its analysis; a course quiz, which {/* Submitting a general session ends on its analysis; a course quiz, which
has no analysis of its own, still ends on the answer review. */} has no analysis of its own, still ends on the answer review. */}
<Route path="/analysis/session/:attemptId" element={<div>Submitted results</div>} /> <Route path="/sessions/:attemptId" element={<div>Submitted results</div>} />
<Route path="/results/:id" element={<div>Course results</div>} /> <Route path="/results/:id" element={<div>Course results</div>} />
</Routes></MemoryRouter>) </Routes></MemoryRouter>)
} }
@ -151,7 +151,7 @@ describe('quiz player', () => {
await userEvent.click(screen.getByRole('button', { name: 'Retry resume' })) await userEvent.click(screen.getByRole('button', { name: 'Retry resume' }))
expect(await screen.findByText(/Full explanation, preserved without shortening/)).toBeInTheDocument() expect(await screen.findByText(/Full explanation, preserved without shortening/)).toBeInTheDocument()
// Once the answer is in, the trail is a way to more of the same topic. // Once the answer is in, the trail is a way to more of the same topic.
expect(screen.getByRole('link', { name: 'Neonatology' })).toHaveAttribute('href', '/sessions/create?category=11') expect(screen.getByRole('link', { name: 'Neonatology' })).toHaveAttribute('href', '/study/new?category=11')
fireEvent(window, new Event('pagehide')) fireEvent(window, new Event('pagehide'))
await waitFor(() => expect(api.post).toHaveBeenCalledWith('/attempts/progress', expect.objectContaining({ answers: { 1: 'First answer' } }), expect.any(Object))) await waitFor(() => expect(api.post).toHaveBeenCalledWith('/attempts/progress', expect.objectContaining({ answers: { 1: 'First answer' } }), expect.any(Object)))
expect(api.post.mock.calls.some(([url]) => url.startsWith('/attempts/start'))).toBe(false) expect(api.post.mock.calls.some(([url]) => url.startsWith('/attempts/start'))).toBe(false)
@ -195,7 +195,7 @@ describe('quiz player', () => {
const response = await originalGet(url, ...args) const response = await originalGet(url, ...args)
return url.startsWith('/quizzes/10') ? { data: { ...response.data, mode: 'learning', allow_review: null, course_id: 1 } } : response return url.startsWith('/quizzes/10') ? { data: { ...response.data, mode: 'learning', allow_review: null, course_id: 1 } } : response
}) })
mount('/sessions/10?return_to=%2Fcourses%2F1') mount('/study/10?return_to=%2Fcourses%2F1')
await userEvent.click(await screen.findByRole('button', { name: 'Begin Quiz' })) await userEvent.click(await screen.findByRole('button', { name: 'Begin Quiz' }))
await findStem('Full first clinical question.') await findStem('Full first clinical question.')
expect(api.post).toHaveBeenCalledWith('/attempts/start?quiz_id=10&mode=exam') expect(api.post).toHaveBeenCalledWith('/attempts/start?quiz_id=10&mode=exam')

View file

@ -234,3 +234,7 @@
.results-crumb > a:hover { text-decoration: underline; } .results-crumb > a:hover { text-decoration: underline; }
.results-crumb h1 { margin: 6px 0 2px; font-size: 1.35rem; } .results-crumb h1 { margin: 6px 0 2px; font-size: 1.35rem; }
.results-crumb-score { font-size: 0.85rem; color: var(--text-muted); } .results-crumb-score { font-size: 0.85rem; color: var(--text-muted); }
/* Where the rail is on screen the counter is a label, not a control there is
nothing left for it to open. */
.quiz-topbar .quiz-question-select.is-static { margin: 0; border: 0; cursor: default; }

View file

@ -71,7 +71,7 @@ export default function ResultsPage() {
</div> </div>
) : ( ) : (
<div className="results-crumb"> <div className="results-crumb">
<Link to={`/analysis/session/${result.id}`}>&larr; Session analysis</Link> <Link to={`/study/${result.id}`}>&larr; Session analysis</Link>
<h1>Answer review</h1> <h1>Answer review</h1>
<span className="results-crumb-score">{correct} of {total} correct &middot; {pct}%</span> <span className="results-crumb-score">{correct} of {total} correct &middot; {pct}%</span>
</div> </div>
@ -106,7 +106,7 @@ export default function ResultsPage() {
const cardClass = ans.is_correct ? 'correct-card' : ans.user_answer ? 'wrong-card' : 'skipped-card' const cardClass = ans.is_correct ? 'correct-card' : ans.user_answer ? 'wrong-card' : 'skipped-card'
return ( return (
<div className={`review-card ${cardClass}`} key={ans.question_id}> <div className={`review-card ${cardClass}`} key={ans.question_id}>
<nav className="quiz-breadcrumbs" aria-label="Question categories">{ans.category_breadcrumbs?.length ? ans.category_breadcrumbs.map((category, index) => <span key={category.id}>{index > 0 && ' '}<Link to={`/sessions/create?category=${category.id}`} target="_blank" rel="noopener noreferrer">{category.name}</Link></span>) : <span>Uncategorized</span>}</nav> <nav className="quiz-breadcrumbs" aria-label="Question categories">{ans.category_breadcrumbs?.length ? ans.category_breadcrumbs.map((category, index) => <span key={category.id}>{index > 0 && ' '}<Link to={`/study/new?category=${category.id}`} target="_blank" rel="noopener noreferrer">{category.name}</Link></span>) : <span>Uncategorized</span>}</nav>
{/* Question header */} {/* Question header */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 16, gap: 12 }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 16, gap: 12 }}>
<div style={{ flex: 1 }}> <div style={{ flex: 1 }}>

View file

@ -1,79 +0,0 @@
/* 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; }
}
.sx-header-actions { display: flex; gap: 8px; flex-wrap: wrap; }

View file

@ -1,229 +0,0 @@
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.
*
* This is the only list of sessions there is. It replaced a page that showed
* the same rows twice once as "Sessions" and again as "Library" which gave
* two names to one thing and no way to tell them apart. Material a learner has
* not started is not a session: it is a study plan, and it is listed there.
*/
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>Sessions</h1>
<p>Every test you have started or finished.</p>
</div>
{/* Where a new one comes from. The course material itself lives in the
study plans this page is the record of working through it, not a
second copy of the catalogue. */}
<div className="sx-header-actions">
<Link className="btn btn-secondary" to="/study-plans">Study plans</Link>
<Link className="btn btn-primary" to="/sessions/create">Create custom test</Link>
</div>
</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">
{/* The title opens what there is to see: the analysis
once it has been sat, the session itself otherwise.
It used to launch the test on click. */}
<Link className="sx-title" to={row.last_attempt_id
? `/analysis/session/${row.last_attempt_id}` : `/sessions/${row.quiz_id}`}>
{row.title}
</Link>
<span className="sx-meta">
{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={`/sessions/${row.quiz_id}`}>Resume</Link>
)}
{row.last_attempt_id && (
<Link className="btn btn-secondary btn-sm" to={`/analysis/session/${row.last_attempt_id}`}>Review</Link>
)}
{row.state === 'not_started' && (
<Link className="btn btn-secondary btn-sm" to={`/sessions/${row.quiz_id}`}>Start</Link>
)}
</span>
</li>
))}
</ul>
</>
)}
</div>
</div>
</div>
)
}

View file

@ -56,7 +56,7 @@ export default function StudyPlanPage() {
setBusy(true); setError('') setBusy(true); setError('')
try { try {
const res = await api.post(`/study-plans/blocks/${block.id}/start`, null, { params: { mode } }) const res = await api.post(`/study-plans/blocks/${block.id}/start`, null, { params: { mode } })
navigate(`/sessions/${res.data.id}`) navigate(`/study/${res.data.id}`)
} catch (err) { setError(apiError(err, 'Could not start this block')) } } catch (err) { setError(apiError(err, 'Could not start this block')) }
finally { setBusy(false) } finally { setBusy(false) }
} }
@ -300,7 +300,7 @@ export default function StudyPlanPage() {
<div className="block-sessions"> <div className="block-sessions">
<h3>Sessions</h3> <h3>Sessions</h3>
{block.quiz_id ? ( {block.quiz_id ? (
<Link className="btn btn-primary btn-sm" to={`/sessions/${block.quiz_id}`}> <Link className="btn btn-primary btn-sm" to={`/study/${block.quiz_id}`}>
{block.completed ? 'Review this block' : 'Continue this block'} {block.completed ? 'Review this block' : 'Continue this block'}
</Link> </Link>
) : block.question_count === 0 ? ( ) : block.question_count === 0 ? (

View file

@ -86,7 +86,7 @@ describe('study plans', () => {
it('offers both modes on a fresh block and continues one already started', async () => { it('offers both modes on a fresh block and continues one already started', async () => {
mountPlan() mountPlan()
const started = (await screen.findByText('Block 1')).closest('.block') const started = (await screen.findByText('Block 1')).closest('.block')
expect(within(started).getByRole('link', { name: 'Review this block' })).toHaveAttribute('href', '/sessions/77') expect(within(started).getByRole('link', { name: 'Review this block' })).toHaveAttribute('href', '/study/77')
const fresh = screen.getByText('Block 2').closest('.block') const fresh = screen.getByText('Block 2').closest('.block')
api.post.mockResolvedValue({ data: { id: 91 } }) api.post.mockResolvedValue({ data: { id: 91 } })