From 5a37c3d3f017422ee84ec681f1ca9fca39d4dfaf Mon Sep 17 00:00:00 2001 From: Daniel Date: Fri, 11 Sep 2026 04:18:04 +0200 Subject: [PATCH] feat: sessions and analysis are one page, with the rail as the list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN --- frontend/src/App.jsx | 48 ++-- .../src/components/CategoryPerformance.jsx | 4 +- frontend/src/components/ContinueStudy.jsx | 2 +- frontend/src/components/Navbar.jsx | 7 +- frontend/src/components/PractiseTopic.jsx | 2 +- .../src/components/PractiseTopic.test.jsx | 2 +- frontend/src/components/SessionRail.css | 36 +++ frontend/src/components/SessionRail.jsx | 78 ++++++ frontend/src/components/SessionRail.test.jsx | 69 ++++++ frontend/src/components/SiteFooter.jsx | 1 - frontend/src/components/SiteFooter.test.jsx | 2 +- frontend/src/hooks/useMediaQuery.js | 30 +++ frontend/src/pages/AnalysisPage.jsx | 40 +-- frontend/src/pages/AnalysisSessionPage.css | 10 + frontend/src/pages/AnalysisSessionPage.jsx | 77 +++--- frontend/src/pages/CourseDetailPage.jsx | 2 +- frontend/src/pages/CustomQuizPage.jsx | 2 +- frontend/src/pages/CustomQuizPage.test.jsx | 6 +- frontend/src/pages/DocumentDetailPage.jsx | 6 +- frontend/src/pages/JobsPage.jsx | 2 +- frontend/src/pages/PublicQuizPage.jsx | 2 +- frontend/src/pages/QuestionBankPage.jsx | 4 +- frontend/src/pages/QuizEditPage.jsx | 2 +- frontend/src/pages/QuizPage.jsx | 33 ++- frontend/src/pages/QuizPage.test.jsx | 10 +- frontend/src/pages/QuizPlayer.css | 4 + frontend/src/pages/ResultsPage.jsx | 4 +- frontend/src/pages/SessionsPage.css | 79 ------ frontend/src/pages/SessionsPage.jsx | 229 ------------------ frontend/src/pages/StudyPlanPage.jsx | 4 +- frontend/src/pages/StudyPlanPage.test.jsx | 2 +- 31 files changed, 372 insertions(+), 427 deletions(-) create mode 100644 frontend/src/components/SessionRail.css create mode 100644 frontend/src/components/SessionRail.jsx create mode 100644 frontend/src/components/SessionRail.test.jsx create mode 100644 frontend/src/hooks/useMediaQuery.js delete mode 100644 frontend/src/pages/SessionsPage.css delete mode 100644 frontend/src/pages/SessionsPage.jsx 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 ( +
+ setRailOpen(v => !v)} /> + {!railOpen && ( + + )} +
+
+

Your performance for {row?.title || 'this session'}

+
+
+

You have not answered any of this yet.

+

+ {row ? <>{row.questions_per_attempt || row.questions_count} questions + {' · '}{row.mode === 'learning' ? 'Study mode' : 'Exam mode'} : null} +

+

+ 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. +

+ Start this session +
+
+
+ ) + } + if (!data) return
{error || 'Session not found.'}
const correct = data.score @@ -121,32 +164,8 @@ export default function AnalysisSessionPage() { return (
- + setRailOpen(v => !v)} /> {!railOpen && ( )} diff --git a/frontend/src/pages/CustomQuizPage.jsx b/frontend/src/pages/CustomQuizPage.jsx index 2aa1160..6c59d65 100644 --- a/frontend/src/pages/CustomQuizPage.jsx +++ b/frontend/src/pages/CustomQuizPage.jsx @@ -107,7 +107,7 @@ export default function CustomQuizPage() { is_shared: shared, difficulty: difficulty || null, algorithm: adaptive ? 'adaptive' : 'random', article_ids: articleIds, tag_ids: tagIds, explicit_ids: explicitIds, }) - navigate(`/sessions/${result.data.id}`) + navigate(`/study/${result.data.id}`) } catch (err) { const detail = err.response?.data?.detail setError(typeof detail === 'string' ? detail : 'Could not create test. Check your settings and try again.') diff --git a/frontend/src/pages/CustomQuizPage.test.jsx b/frontend/src/pages/CustomQuizPage.test.jsx index 78ba54f..b0742aa 100644 --- a/frontend/src/pages/CustomQuizPage.test.jsx +++ b/frontend/src/pages/CustomQuizPage.test.jsx @@ -21,9 +21,9 @@ function setupCount(count = 30) { }) } function renderBuilder() { - render( - } /> - Saved test} /> + render( + } /> + Saved test} /> ) } diff --git a/frontend/src/pages/DocumentDetailPage.jsx b/frontend/src/pages/DocumentDetailPage.jsx index f849eca..2814a6f 100644 --- a/frontend/src/pages/DocumentDetailPage.jsx +++ b/frontend/src/pages/DocumentDetailPage.jsx @@ -227,10 +227,10 @@ export default function DocumentDetailPage() { setActiveJob({ jobId: res.data.job_id, sectionName }) // If already completed (sync fallback), navigate directly 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) { - navigate(`/sessions/${res.data.id}`) + navigate(`/study/${res.data.id}`) } } catch (err) { setError(err.response?.data?.detail || 'Failed to start extraction. Check AI model config.') @@ -285,7 +285,7 @@ export default function DocumentDetailPage() { { 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)} /> )} diff --git a/frontend/src/pages/JobsPage.jsx b/frontend/src/pages/JobsPage.jsx index e396e41..1388988 100644 --- a/frontend/src/pages/JobsPage.jsx +++ b/frontend/src/pages/JobsPage.jsx @@ -65,7 +65,7 @@ function JobDetail({ job }) {
{job.quiz_id && ( - Open Quiz + Open Quiz )} {job.status === 'running' && ( ) : ( diff --git a/frontend/src/pages/QuestionBankPage.jsx b/frontend/src/pages/QuestionBankPage.jsx index 9801b1a..f1964a0 100644 --- a/frontend/src/pages/QuestionBankPage.jsx +++ b/frontend/src/pages/QuestionBankPage.jsx @@ -179,7 +179,7 @@ function CreateQuizModal({ selectedIds, categories, onClose, onCreated }) { time_limit_minutes: form.time_limit_minutes ? parseInt(form.time_limit_minutes) : null, } }) - navigate(`/sessions/${res.data.id}`) + navigate(`/study/${res.data.id}`) } else { const res = await api.post('/questions/from-bank', { title: form.title, @@ -187,7 +187,7 @@ function CreateQuizModal({ selectedIds, categories, onClose, onCreated }) { mode: form.mode, 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')) } finally { setLoading(false) } diff --git a/frontend/src/pages/QuizEditPage.jsx b/frontend/src/pages/QuizEditPage.jsx index bdc792c..e66e0ca 100644 --- a/frontend/src/pages/QuizEditPage.jsx +++ b/frontend/src/pages/QuizEditPage.jsx @@ -268,7 +268,7 @@ export default function QuizEditPage() { )}
- ← Back to Quiz + ← Back to Quiz
diff --git a/frontend/src/pages/QuizPage.jsx b/frontend/src/pages/QuizPage.jsx index 822ecc3..d85ad65 100644 --- a/frontend/src/pages/QuizPage.jsx +++ b/frontend/src/pages/QuizPage.jsx @@ -7,6 +7,7 @@ import RichText from '../components/RichText' import { mergeTextRanges } from '../utils/highlightOffsets' import { useAuth } from '../context/AuthContext' import api from '../api/client' +import useMediaQuery from '../hooks/useMediaQuery' import MyNote from '../components/MyNote' import QuizTools, { QuizDialog } from '../components/QuizTools' import './QuizPlayer.css' @@ -393,6 +394,10 @@ export default function QuizPage() { const [totalTime, setTotalTime] = useState(null) const [toast, setToast] = useState('') 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 [imageZoom, setImageZoom] = useState(1) const [startedAt, setStartedAt] = useState(null) @@ -926,7 +931,7 @@ const timerStarted = timeLeft !== null } else { // 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. - navigate(`/analysis/session/${attemptId}`, { state: { result: res.data } }) + navigate(`/sessions/${attemptId}`, { state: { result: res.data } }) } } catch (err) { const detail = err.response?.data?.detail @@ -965,7 +970,7 @@ const timerStarted = timeLeft !== null
{isModerator && (
- ✏️ Edit Questions + ✏️ Edit Questions
)} {starting ? ( @@ -1017,10 +1022,14 @@ const timerStarted = timeLeft !== null onClick={() => safeNavigate(Math.max(0, currentIdx - 1))} disabled={currentIdx === 0}>← Prev - + {/* Only where the rail is not on screen. Beside a permanent list of + every question, a button that unfolds the same list is noise. */} + {!hasRail && ( + + )} {isLast ? ( )} - {isModerator && ✏️ Edit} + {isModerator && ✏️ Edit}
{voices.length > 1 && ( @@ -1232,7 +1241,11 @@ const timerStarted = timeLeft !== null {/* Main content */}
- + {hasRail ? ( +

Question{currentIdx + 1} of {totalCount}

+ ) : ( + + )}
@@ -1242,7 +1255,7 @@ const timerStarted = timeLeft !== null
- {navOpen &&
{questions.map((q, i) => )}
} + {!hasRail && navOpen &&
{questions.map((q, i) => )}
} {current && (
( {index > 0 && } - {category.name} + {category.name} ))} diff --git a/frontend/src/pages/QuizPage.test.jsx b/frontend/src/pages/QuizPage.test.jsx index 0aeb07f..0a3b1b7 100644 --- a/frontend/src/pages/QuizPage.test.jsx +++ b/frontend/src/pages/QuizPage.test.jsx @@ -47,12 +47,12 @@ beforeEach(() => { api.delete.mockResolvedValue({}) }) -function mount(entry = '/sessions/10') { +function mount(entry = '/study/10') { render( - } /> + } /> {/* Submitting a general session ends on its analysis; a course quiz, which has no analysis of its own, still ends on the answer review. */} - Submitted results
} /> + Submitted results
} /> Course results
} /> ) } @@ -151,7 +151,7 @@ describe('quiz player', () => { await userEvent.click(screen.getByRole('button', { name: 'Retry resume' })) 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. - 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')) 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) @@ -195,7 +195,7 @@ describe('quiz player', () => { const response = await originalGet(url, ...args) 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 findStem('Full first clinical question.') expect(api.post).toHaveBeenCalledWith('/attempts/start?quiz_id=10&mode=exam') diff --git a/frontend/src/pages/QuizPlayer.css b/frontend/src/pages/QuizPlayer.css index 579539c..5474768 100644 --- a/frontend/src/pages/QuizPlayer.css +++ b/frontend/src/pages/QuizPlayer.css @@ -234,3 +234,7 @@ .results-crumb > a:hover { text-decoration: underline; } .results-crumb h1 { margin: 6px 0 2px; font-size: 1.35rem; } .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; } diff --git a/frontend/src/pages/ResultsPage.jsx b/frontend/src/pages/ResultsPage.jsx index 568f150..9f99d39 100644 --- a/frontend/src/pages/ResultsPage.jsx +++ b/frontend/src/pages/ResultsPage.jsx @@ -71,7 +71,7 @@ export default function ResultsPage() { ) : (
- ← Session analysis + ← Session analysis

Answer review

{correct} of {total} correct · {pct}%
@@ -106,7 +106,7 @@ export default function ResultsPage() { const cardClass = ans.is_correct ? 'correct-card' : ans.user_answer ? 'wrong-card' : 'skipped-card' return (
- + {/* Question header */}
diff --git a/frontend/src/pages/SessionsPage.css b/frontend/src/pages/SessionsPage.css deleted file mode 100644 index 50df917..0000000 --- a/frontend/src/pages/SessionsPage.css +++ /dev/null @@ -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; } diff --git a/frontend/src/pages/SessionsPage.jsx b/frontend/src/pages/SessionsPage.jsx deleted file mode 100644 index e92e475..0000000 --- a/frontend/src/pages/SessionsPage.jsx +++ /dev/null @@ -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 ( -
-
-
-

Sessions

-

Every test you have started or finished.

-
- {/* 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. */} -
- Study plans - Create custom test -
-
- -
- - -
- {error &&

{error}

} - {loading ?
- : shown.length === 0 ? ( -
- {rows.length === 0 ? 'No sessions yet.' : 'Nothing matches those filters.'} -
- ) : ( - <> -

- {shown.length} of {rows.length} session{rows.length === 1 ? '' : 's'} -

-
    - {shown.map(row => ( -
  • -
    - {/* 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} - -
    - - {row.state === 'in_progress' ? ( - - {row.answered}/{row.total} answered - - ) : row.last_percentage != null ? ( - - {pct(row.last_percentage)}% - {row.best_percentage != null && row.best_percentage !== row.last_percentage && ( - best {pct(row.best_percentage)}% - )} - - ) : Not attempted} - - {when(row.last_activity)} - - - {row.state === 'in_progress' && ( - Resume - )} - {row.last_attempt_id && ( - Review - )} - {row.state === 'not_started' && ( - Start - )} - -
  • - ))} -
- - )} -
-
-
- ) -} diff --git a/frontend/src/pages/StudyPlanPage.jsx b/frontend/src/pages/StudyPlanPage.jsx index 8e66ad5..6311c47 100644 --- a/frontend/src/pages/StudyPlanPage.jsx +++ b/frontend/src/pages/StudyPlanPage.jsx @@ -56,7 +56,7 @@ export default function StudyPlanPage() { setBusy(true); setError('') try { 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')) } finally { setBusy(false) } } @@ -300,7 +300,7 @@ export default function StudyPlanPage() {

Sessions

{block.quiz_id ? ( - + {block.completed ? 'Review this block' : 'Continue this block'} ) : block.question_count === 0 ? ( diff --git a/frontend/src/pages/StudyPlanPage.test.jsx b/frontend/src/pages/StudyPlanPage.test.jsx index 41d31f4..f5296c1 100644 --- a/frontend/src/pages/StudyPlanPage.test.jsx +++ b/frontend/src/pages/StudyPlanPage.test.jsx @@ -86,7 +86,7 @@ describe('study plans', () => { it('offers both modes on a fresh block and continues one already started', async () => { mountPlan() 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') api.post.mockResolvedValue({ data: { id: 91 } })