diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index 8cb2fed..26cef11 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -1,5 +1,5 @@
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 { ThemeProvider } from './context/ThemeContext'
import Navbar from './components/Navbar'
@@ -33,7 +33,6 @@ 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 AnalysisSessionPage = lazy(() => import('./pages/AnalysisSessionPage'))
const AiModePage = lazy(() => import('./pages/AiModePage'))
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
- * follows and any query string. `replace` so the browser's Back button skips
- * the dead address rather than bouncing the learner straight back to it.
+ * Rewrites the leading segment of an old URL, keeping the rest of the path and
+ * any query string. `replace` so the browser's Back button skips the dead
+ * address rather than bouncing the learner straight back to it.
*/
-function LegacyQuizRedirect() {
+function LegacyRedirect({ from, to }) {
const { pathname, search, hash } = useLocation()
- const rest = pathname.replace(/^\/quizzes/, '')
- return
+ const rest = pathname.slice(from.length)
+ return
+}
+
+/** /analysis/session/:attemptId is now simply /sessions/:attemptId. */
+function LegacySessionRedirect() {
+ const { attemptId } = useParams()
+ const { search, hash } = useLocation()
+ return
}
function AppRoutes() {
@@ -110,16 +116,24 @@ function AppRoutes() {
}>
}>
} />
- } />
- } />
- } />
+ {/* Sessions and analysis are one thing: the sidebar lists every
+ session, the top entry is the overall picture, and each row
+ opens that session's own performance. There is no separate
+ list page — that was the same rows under a second name. */}
+ } />
+ } />
+ {/* A session with nothing sat yet has no attempt to analyse, so it
+ is addressed by quiz instead and says what is missing. */}
+ } />
+
+ {/* Doing the work, as opposed to reviewing it. */}
+ } />
+ } />
} />
} />
} />
} />
} />
- } />
- } />
} />
} />
} />
@@ -144,7 +158,7 @@ function AppRoutes() {
}>
}>
} />
- } />
+ } />
} />
} />
} />
@@ -157,7 +171,11 @@ function AppRoutes() {
{/* 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
second tab. These keep working rather than landing on Not Found. */}
- } />
+ } />
+ {/* /analysis was the overall picture and /analysis/session/:id one
+ session's; both now live under /sessions. */}
+ } />
+ } />
{/* Catch-all */}
: } />
diff --git a/frontend/src/components/CategoryPerformance.jsx b/frontend/src/components/CategoryPerformance.jsx
index d964531..44da653 100644
--- a/frontend/src/components/CategoryPerformance.jsx
+++ b/frontend/src/components/CategoryPerformance.jsx
@@ -56,10 +56,10 @@ export default function CategoryPerformance() {
+ onClick={() => navigate(`/study/new?adaptive=1&count=${adaptiveCount}`)}>Start adaptive session
{main.length > 0 && (
)}
diff --git a/frontend/src/components/ContinueStudy.jsx b/frontend/src/components/ContinueStudy.jsx
index 2a832b8..55cb590 100644
--- a/frontend/src/components/ContinueStudy.jsx
+++ b/frontend/src/components/ContinueStudy.jsx
@@ -63,7 +63,7 @@ export default function ContinueStudy() {
{answered}/{total}
+ to={done ? `/study/${row.last_attempt_id}` : `/study/${row.quiz_id}`}>
{done ? 'Review' : 'Resume'}
diff --git a/frontend/src/components/Navbar.jsx b/frontend/src/components/Navbar.jsx
index a4d9195..f617ee0 100644
--- a/frontend/src/components/Navbar.jsx
+++ b/frontend/src/components/Navbar.jsx
@@ -48,7 +48,7 @@ function JobsBadge({ jobs }) {
{job.last_step || 'Waiting…'}
{job.status === 'completed' && job.quiz_id && (
- setOpen(false)}>Open Quiz →
)}
@@ -107,10 +107,9 @@ export default function Navbar({ onSignIn, onRegister }) {
{ to: '/home', label: 'Home' },
{ to: '/', label: 'Dashboard' },
{ to: '/ai', label: 'AI Mode' },
- // One entry, because there is one page. "Sessions" and "History" were two
- // names for the same list, which is what made it unreadable.
+ // One entry. Sessions and analysis are the same subject — the list of what
+ // you have sat and the reading of how it went — so they are one page.
{ to: '/sessions', label: 'Sessions' },
- { to: '/analysis', label: 'Analysis' },
{ to: '/question-bank', label: 'Question Bank' },
...(canManageQuestions ? [{ to: '/questions/manage', label: 'Manage Qs' },
{ to: '/media', label: 'Images' },
diff --git a/frontend/src/components/PractiseTopic.jsx b/frontend/src/components/PractiseTopic.jsx
index f67a149..d0332b2 100644
--- a/frontend/src/components/PractiseTopic.jsx
+++ b/frontend/src/components/PractiseTopic.jsx
@@ -40,7 +40,7 @@ export default function PractiseTopic({ article, canEdit, questions, onUnlink })
difficulty: null, algorithm: 'random',
article_ids: [article.id], tag_ids: [], explicit_ids: [],
})
- navigate(`/sessions/${res.data.id}`)
+ navigate(`/study/${res.data.id}`)
} catch (err) {
const detail = err.response?.data?.detail
setError(typeof detail === 'string' ? detail : 'Could not create a test from this topic.')
diff --git a/frontend/src/components/PractiseTopic.test.jsx b/frontend/src/components/PractiseTopic.test.jsx
index 9eab6cb..c5acdcf 100644
--- a/frontend/src/components/PractiseTopic.test.jsx
+++ b/frontend/src/components/PractiseTopic.test.jsx
@@ -18,7 +18,7 @@ const mount = (props = {}) => render(
} />
- Test ready} />
+ Test ready} />
)
diff --git a/frontend/src/components/SessionRail.css b/frontend/src/components/SessionRail.css
new file mode 100644
index 0000000..6a16ff1
--- /dev/null
+++ b/frontend/src/components/SessionRail.css
@@ -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; }
+}
diff --git a/frontend/src/components/SessionRail.jsx b/frontend/src/components/SessionRail.jsx
new file mode 100644
index 0000000..15de512
--- /dev/null
+++ b/frontend/src/components/SessionRail.jsx
@@ -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 (
+
+ )
+}
diff --git a/frontend/src/components/SessionRail.test.jsx b/frontend/src/components/SessionRail.test.jsx
new file mode 100644
index 0000000..d77fb6f
--- /dev/null
+++ b/frontend/src/components/SessionRail.test.jsx
@@ -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(
+
+ {}} loading={false} {...props} />
+ ,
+)
+
+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()
+ })
+})
diff --git a/frontend/src/components/SiteFooter.jsx b/frontend/src/components/SiteFooter.jsx
index d3de9a1..2e858b5 100644
--- a/frontend/src/components/SiteFooter.jsx
+++ b/frontend/src/components/SiteFooter.jsx
@@ -31,7 +31,6 @@ const COLUMNS = [
links: [
{ to: '/search', label: 'Search' },
{ to: '/ai', label: 'AI Mode' },
- { to: '/analysis', label: 'Analysis' },
],
},
{
diff --git a/frontend/src/components/SiteFooter.test.jsx b/frontend/src/components/SiteFooter.test.jsx
index fd88849..67f1e67 100644
--- a/frontend/src/components/SiteFooter.test.jsx
+++ b/frontend/src/components/SiteFooter.test.jsx
@@ -3,7 +3,7 @@ import { render, screen, within } from '@testing-library/react'
import { MemoryRouter } from 'react-router-dom'
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',
'/courses', '/account', '/settings', '/categories', '/editorial']
diff --git a/frontend/src/hooks/useMediaQuery.js b/frontend/src/hooks/useMediaQuery.js
new file mode 100644
index 0000000..507f568
--- /dev/null
+++ b/frontend/src/hooks/useMediaQuery.js
@@ -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
+}
diff --git a/frontend/src/pages/AnalysisPage.jsx b/frontend/src/pages/AnalysisPage.jsx
index 4d238cf..fd64be1 100644
--- a/frontend/src/pages/AnalysisPage.jsx
+++ b/frontend/src/pages/AnalysisPage.jsx
@@ -2,7 +2,7 @@ import { useState, useEffect, useCallback } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import api from '../api/client'
import CategoryPerformance from '../components/CategoryPerformance'
-import SessionProgress from '../components/SessionProgress'
+import SessionRail from '../components/SessionRail'
import './AnalysisPage.css'
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 [count, setCount] = useState(10)
const [sessions, setSessions] = useState([])
+ const [sessionsLoading, setSessionsLoading] = useState(true)
const [railOpen, setRailOpen] = useState(true)
const navigate = useNavigate()
@@ -84,44 +85,21 @@ export default function AnalysisPage() {
useEffect(() => { load() }, [load])
+ // Every session, not a recent handful: this rail is the whole list now.
useEffect(() => {
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([]))
+ .finally(() => setSessionsLoading(false))
}, [])
- const startAdaptive = () => navigate(`/sessions/create?adaptive=1&count=${count}`)
- const startCategory = (categoryId) => navigate(`/sessions/create?category=${categoryId}&count=${count}`)
+ const startAdaptive = () => navigate(`/study/new?adaptive=1&count=${count}`)
+ const startCategory = (categoryId) => navigate(`/study/new?category=${categoryId}&count=${count}`)
return (
-
+ setRailOpen(v => !v)} />
diff --git a/frontend/src/pages/AnalysisSessionPage.css b/frontend/src/pages/AnalysisSessionPage.css
index d9ce333..78e067d 100644
--- a/frontend/src/pages/AnalysisSessionPage.css
+++ b/frontend/src/pages/AnalysisSessionPage.css
@@ -101,3 +101,13 @@
color: var(--wrong-fg); background: var(--wrong-bg);
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; }
diff --git a/frontend/src/pages/AnalysisSessionPage.jsx b/frontend/src/pages/AnalysisSessionPage.jsx
index 46805b8..d92d33f 100644
--- a/frontend/src/pages/AnalysisSessionPage.jsx
+++ b/frontend/src/pages/AnalysisSessionPage.jsx
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom'
+import SessionRail from '../components/SessionRail'
import api from '../api/client'
import './AnalysisSessionPage.css'
@@ -67,7 +68,9 @@ const SORTS = {
* reading carefully or stalling.
*/
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 [sessions, setSessions] = useState([])
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.
const [page, setPage] = useState(0)
const [railOpen, setRailOpen] = useState(true)
+ const [sessionsLoading, setSessionsLoading] = useState(true)
const [confirmDelete, setConfirmDelete] = useState(false)
const [deleting, setDeleting] = useState(false)
const navigate = useNavigate()
@@ -92,6 +96,7 @@ export default function AnalysisSessionPage() {
}
const load = useCallback(() => {
+ if (!attemptId) { setLoading(false); return }
setLoading(true)
api.get(`/attempts/${attemptId}/analysis`)
.then(res => setData(res.data))
@@ -101,8 +106,9 @@ export default function AnalysisSessionPage() {
useEffect(() => { load() }, [load])
useEffect(() => {
- api.get('/quizzes/sessions').then(res => setSessions((res.data || []).slice(0, 12)))
+ api.get('/quizzes/sessions').then(res => setSessions(res.data || []))
.catch(() => setSessions([]))
+ .finally(() => setSessionsLoading(false))
}, [])
const rows = useMemo(
@@ -113,6 +119,43 @@ export default function AnalysisSessionPage() {
useEffect(() => { setPage(0) }, [sort])
if (loading) return
+
+ // 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 (
+
- {/* 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. */}
-
- {/* 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. */}
-
- {row.title}
-
-
- {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>}
-
-