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