feat: sessions and analysis are one page, with the rail as the list
From the recording: sessions and analysis are the same subject, and there should be no separate sessions page. The sidebar lists every session, the top entry is the overall analysis, and each row opens that session's own performance and recommendations. - /sessions overall analysis (was /analysis) - /sessions/:attempt one session (was /analysis/session/:id) - /sessions/q/:quiz a session not yet sat — says so and offers Start - /study/:id taking a test (was /quizzes/:id then /sessions/:id) - /study/new building a custom test - old /quizzes, /analysis and /analysis/session paths redirect A session you have not answered no longer launches when clicked. It opens its overview, which says nothing has been answered and that the analysis fills in once it is sat through. Clicking a name in a list used to drop the learner into a 240-question exam with the clock on. SessionRail is shared by both analysis views; SessionsPage is gone and so are the duplicate "History" and "Analysis" menu entries. In the player, the counter that unfolded a grid of question numbers is gone wherever the session rail is on screen — the rail already lists every question and fills in as you go. Below 1150px, where the rail is hidden, the dropdown remains as the only navigator. Backend 208/208, frontend 252/252. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
This commit is contained in:
parent
ebb9e701ee
commit
5a37c3d3f0
31 changed files with 372 additions and 427 deletions
|
|
@ -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 <Navigate to={`/sessions${rest}${search}${hash}`} replace />
|
||||
const rest = pathname.slice(from.length)
|
||||
return <Navigate to={`${to}${rest}${search}${hash}`} replace />
|
||||
}
|
||||
|
||||
/** /analysis/session/:attemptId is now simply /sessions/:attemptId. */
|
||||
function LegacySessionRedirect() {
|
||||
const { attemptId } = useParams()
|
||||
const { search, hash } = useLocation()
|
||||
return <Navigate to={`/sessions/${attemptId}${search}${hash}`} replace />
|
||||
}
|
||||
|
||||
function AppRoutes() {
|
||||
|
|
@ -110,16 +116,24 @@ function AppRoutes() {
|
|||
<Route element={<RequireAuth />}>
|
||||
<Route element={<AppLayout />}>
|
||||
<Route path="/" element={<DashboardPage />} />
|
||||
<Route path="/sessions" element={<SessionsPage />} />
|
||||
<Route path="/sessions/create" element={<CustomQuizPage />} />
|
||||
<Route path="/sessions/:id" element={<QuizPage />} />
|
||||
{/* 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. */}
|
||||
<Route path="/sessions" element={<AnalysisPage />} />
|
||||
<Route path="/sessions/:attemptId" element={<AnalysisSessionPage />} />
|
||||
{/* A session with nothing sat yet has no attempt to analyse, so it
|
||||
is addressed by quiz instead and says what is missing. */}
|
||||
<Route path="/sessions/q/:quizId" element={<AnalysisSessionPage />} />
|
||||
|
||||
{/* Doing the work, as opposed to reviewing it. */}
|
||||
<Route path="/study/new" element={<CustomQuizPage />} />
|
||||
<Route path="/study/:id" element={<QuizPage />} />
|
||||
<Route path="/results/:id" element={<ResultsPage />} />
|
||||
<Route path="/documents/:id" element={<DocumentDetailPage />} />
|
||||
<Route path="/account" element={<AccountPage />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
<Route path="/question-bank" element={<QuestionBankPage />} />
|
||||
<Route path="/analysis" element={<AnalysisPage />} />
|
||||
<Route path="/analysis/session/:attemptId" element={<AnalysisSessionPage />} />
|
||||
<Route path="/questions/manage" element={<QuestionManagerPage />} />
|
||||
<Route path="/flashcards" element={<FlashcardsPage />} />
|
||||
<Route path="/search" element={<SearchPage />} />
|
||||
|
|
@ -144,7 +158,7 @@ function AppRoutes() {
|
|||
<Route element={<RequireAuth moderator />}>
|
||||
<Route element={<AppLayout />}>
|
||||
<Route path="/upload" element={<UploadPage />} />
|
||||
<Route path="/sessions/:id/edit" element={<QuizEditPage />} />
|
||||
<Route path="/study/:id/edit" element={<QuizEditPage />} />
|
||||
<Route path="/jobs" element={<JobsPage />} />
|
||||
<Route path="/trash" element={<TrashPage />} />
|
||||
<Route path="/categories" element={<CategoriesPage />} />
|
||||
|
|
@ -157,7 +171,11 @@ function AppRoutes() {
|
|||
{/* The word "quiz" is gone from the interface, but links to it are in
|
||||
bookmarks, in shared messages, and in anything already open in a
|
||||
second tab. These keep working rather than landing on Not Found. */}
|
||||
<Route path="/quizzes/*" element={<LegacyQuizRedirect />} />
|
||||
<Route path="/quizzes/*" element={<LegacyRedirect from="/quizzes" to="/study" />} />
|
||||
{/* /analysis was the overall picture and /analysis/session/:id one
|
||||
session's; both now live under /sessions. */}
|
||||
<Route path="/analysis" element={<Navigate to="/sessions" replace />} />
|
||||
<Route path="/analysis/session/:attemptId" element={<LegacySessionRedirect />} />
|
||||
|
||||
{/* Catch-all */}
|
||||
<Route path="*" element={user ? <NotFoundPage /> : <Navigate to="/home" replace />} />
|
||||
|
|
|
|||
|
|
@ -56,10 +56,10 @@ export default function CategoryPerformance() {
|
|||
</select>
|
||||
</label>
|
||||
<button type="button" className="btn btn-primary btn-sm"
|
||||
onClick={() => navigate(`/sessions/create?adaptive=1&count=${adaptiveCount}`)}>Start adaptive session</button>
|
||||
onClick={() => navigate(`/study/new?adaptive=1&count=${adaptiveCount}`)}>Start adaptive session</button>
|
||||
{main.length > 0 && (
|
||||
<button type="button" className="btn btn-secondary btn-sm"
|
||||
onClick={() => navigate(`/sessions/create?adaptive=1&count=${adaptiveCount}&${main.slice(0, 3).map(row => `category=${row.category_id}`).join('&')}`)}>
|
||||
onClick={() => navigate(`/study/new?adaptive=1&count=${adaptiveCount}&${main.slice(0, 3).map(row => `category=${row.category_id}`).join('&')}`)}>
|
||||
On my weakest topics
|
||||
</button>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -63,7 +63,7 @@ export default function ContinueStudy() {
|
|||
</span>
|
||||
<span className="cs-count">{answered}/{total}</span>
|
||||
<Link className="btn btn-secondary btn-sm"
|
||||
to={done ? `/analysis/session/${row.last_attempt_id}` : `/sessions/${row.quiz_id}`}>
|
||||
to={done ? `/study/${row.last_attempt_id}` : `/study/${row.quiz_id}`}>
|
||||
{done ? 'Review' : 'Resume'}
|
||||
</Link>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ function JobsBadge({ jobs }) {
|
|||
</div>
|
||||
<div style={{ color: 'var(--text-muted)', fontSize: '0.78rem' }}>{job.last_step || 'Waiting…'}</div>
|
||||
{job.status === 'completed' && job.quiz_id && (
|
||||
<Link to={`/sessions/${job.quiz_id}`} style={{ fontSize: '0.75rem', color: 'var(--primary)', textDecoration: 'none', display: 'block', marginTop: 4 }}
|
||||
<Link to={`/study/${job.quiz_id}`} style={{ fontSize: '0.75rem', color: 'var(--primary)', textDecoration: 'none', display: 'block', marginTop: 4 }}
|
||||
onClick={() => setOpen(false)}>Open Quiz →</Link>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -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' },
|
||||
|
|
|
|||
|
|
@ -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.')
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ const mount = (props = {}) => render(
|
|||
<Routes>
|
||||
<Route path="/articles/7" element={
|
||||
<PractiseTopic article={ARTICLE} canEdit={false} questions={QUESTIONS} onUnlink={vi.fn()} {...props} />} />
|
||||
<Route path="/sessions/:id" element={<h1>Test ready</h1>} />
|
||||
<Route path="/study/:id" element={<h1>Test ready</h1>} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
)
|
||||
|
|
|
|||
36
frontend/src/components/SessionRail.css
Normal file
36
frontend/src/components/SessionRail.css
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
/* Additions to the rail shared by the overall analysis and a single session.
|
||||
The frame itself (.an-rail, .an-rail-head, .an-rail-list) is defined in
|
||||
AnalysisPage.css, which both views already load. */
|
||||
|
||||
/* The overall picture, pinned above the individual sessions and separated from
|
||||
them — it is the parent of the list, not the first row of it. */
|
||||
.an-rail-all {
|
||||
display: flex; flex-direction: column; gap: 3px;
|
||||
padding: 12px 14px; text-decoration: none; color: var(--text);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.an-rail-all:hover { background: var(--bg); }
|
||||
.an-rail-all strong { font-size: .85rem; font-weight: 650; }
|
||||
.an-rail-all span { font-size: .75rem; color: var(--text-muted); }
|
||||
|
||||
/* Which session you are reading. Without this the rail gives no clue, and on a
|
||||
list of fifteen that matters more than the hover state does. */
|
||||
.an-rail-all.active,
|
||||
.an-rail-list a.active {
|
||||
background: var(--option-sel-bg);
|
||||
box-shadow: inset 3px 0 0 var(--primary);
|
||||
}
|
||||
.an-rail-all.active strong,
|
||||
.an-rail-list a.active .an-rail-title { color: var(--primary); }
|
||||
|
||||
.an-rail-search {
|
||||
width: calc(100% - 20px); margin: 10px; padding: 7px 10px;
|
||||
/* 16px on touch: iOS zooms the whole page in on any smaller font when a
|
||||
field takes focus, and never zooms back out. */
|
||||
font-size: .82rem; font-family: inherit;
|
||||
border: 1px solid var(--border); border-radius: 7px;
|
||||
background: var(--input-bg); color: var(--text);
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
.an-rail-search { font-size: 16px; }
|
||||
}
|
||||
78
frontend/src/components/SessionRail.jsx
Normal file
78
frontend/src/components/SessionRail.jsx
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
import { useMemo, useState } from 'react'
|
||||
import { NavLink } from 'react-router-dom'
|
||||
import SessionProgress from './SessionProgress'
|
||||
import './SessionRail.css'
|
||||
|
||||
/**
|
||||
* The list of sessions, alongside whatever session you are reading.
|
||||
*
|
||||
* Sessions and analysis are the same subject, so this is the navigation for
|
||||
* both: the overall picture sits at the top, every session follows, and the
|
||||
* one you are looking at is marked. It replaced a separate sessions page,
|
||||
* which listed the same rows a second time under a different heading.
|
||||
*
|
||||
* Nothing is truncated. A learner asking "what have I done" wants the whole
|
||||
* answer, and the search box is what narrows it.
|
||||
*/
|
||||
export default function SessionRail({ sessions, open, onToggle, loading }) {
|
||||
const [query, setQuery] = useState('')
|
||||
|
||||
const shown = useMemo(() => {
|
||||
const needle = query.trim().toLowerCase()
|
||||
if (!needle) return sessions
|
||||
return sessions.filter(row => (row.title || '').toLowerCase().includes(needle))
|
||||
}, [sessions, query])
|
||||
|
||||
return (
|
||||
<aside className="an-rail">
|
||||
<div className="an-rail-head">
|
||||
<h2>Sessions</h2>
|
||||
<button type="button" aria-label={open ? 'Hide sessions' : 'Show sessions'}
|
||||
aria-expanded={open} onClick={onToggle}>{open ? '‹' : '›'}</button>
|
||||
</div>
|
||||
|
||||
{open && (
|
||||
<>
|
||||
{/* `end` so this only lights up on /sessions itself, not on every
|
||||
session underneath it. */}
|
||||
<NavLink to="/sessions" end className="an-rail-all">
|
||||
<strong>Your overall analysis</strong>
|
||||
<span>Everything you have practised</span>
|
||||
</NavLink>
|
||||
|
||||
{sessions.length > 6 && (
|
||||
<input className="an-rail-search" type="search" value={query}
|
||||
onChange={e => setQuery(e.target.value)}
|
||||
placeholder="Search sessions" aria-label="Search sessions" />
|
||||
)}
|
||||
|
||||
{loading ? <p className="an-rail-empty">Loading…</p>
|
||||
: shown.length === 0 ? (
|
||||
<p className="an-rail-empty">
|
||||
{sessions.length === 0 ? 'No sessions yet.' : 'Nothing matches that.'}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="an-rail-list">
|
||||
{shown.map(row => (
|
||||
<li key={row.quiz_id}>
|
||||
{/* Never launches the test. A session you have not sat
|
||||
opens its own overview, which says so and offers to
|
||||
start it — clicking a name used to drop you straight
|
||||
into a 240-question exam. */}
|
||||
<NavLink to={row.last_attempt_id
|
||||
? `/sessions/${row.last_attempt_id}` : `/sessions/q/${row.quiz_id}`}>
|
||||
<span className="an-rail-title">
|
||||
<strong>{row.mode === 'learning' ? 'Study mode:' : 'Exam mode:'}</strong> {row.title}
|
||||
</span>
|
||||
<SessionProgress answered={row.answered} total={row.total}
|
||||
correct={row.state === 'completed' ? row.last_score : null} />
|
||||
</NavLink>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
69
frontend/src/components/SessionRail.test.jsx
Normal file
69
frontend/src/components/SessionRail.test.jsx
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { MemoryRouter } from 'react-router-dom'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import SessionRail from './SessionRail'
|
||||
|
||||
const SESSIONS = [
|
||||
{ quiz_id: 1, title: 'Board Review I', mode: 'timed', state: 'completed',
|
||||
answered: 268, total: 268, last_score: 200, last_attempt_id: 91 },
|
||||
{ quiz_id: 2, title: 'Neonatal jaundice', mode: 'learning', state: 'in_progress',
|
||||
answered: 4, total: 20, last_attempt_id: 92 },
|
||||
{ quiz_id: 3, title: 'Board Review IX', mode: 'timed', state: 'not_started',
|
||||
answered: 0, total: 243, last_attempt_id: null },
|
||||
]
|
||||
|
||||
const mount = (props = {}) => render(
|
||||
<MemoryRouter>
|
||||
<SessionRail sessions={SESSIONS} open onToggle={() => {}} loading={false} {...props} />
|
||||
</MemoryRouter>,
|
||||
)
|
||||
|
||||
describe('the session rail', () => {
|
||||
it('puts the overall analysis above the sessions themselves', () => {
|
||||
mount()
|
||||
expect(screen.getByRole('link', { name: /Your overall analysis/ }))
|
||||
.toHaveAttribute('href', '/sessions')
|
||||
})
|
||||
|
||||
it('never launches a test — an unsat session opens its overview instead', () => {
|
||||
mount()
|
||||
// The two with an attempt read as that attempt's analysis...
|
||||
expect(screen.getByRole('link', { name: /Board Review I\b/ })).toHaveAttribute('href', '/sessions/91')
|
||||
expect(screen.getByRole('link', { name: /Neonatal jaundice/ })).toHaveAttribute('href', '/sessions/92')
|
||||
// ...and the one never sat goes to its overview, not to /study, which
|
||||
// would drop the learner into a 243-question exam with the clock running.
|
||||
const unsat = screen.getByRole('link', { name: /Board Review IX/ })
|
||||
expect(unsat).toHaveAttribute('href', '/sessions/q/3')
|
||||
expect(unsat.getAttribute('href')).not.toMatch(/^\/study\//)
|
||||
})
|
||||
|
||||
it('says which mode each session was sat in', () => {
|
||||
mount()
|
||||
expect(screen.getByText('Study mode:')).toBeInTheDocument()
|
||||
expect(screen.getAllByText('Exam mode:')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('filters by name once the list is long enough to need it', async () => {
|
||||
const many = Array.from({ length: 8 }, (_, i) => ({
|
||||
quiz_id: i + 10, title: `Session ${i}`, mode: 'timed', state: 'completed',
|
||||
answered: 5, total: 5, last_score: 5, last_attempt_id: 100 + i,
|
||||
}))
|
||||
mount({ sessions: many })
|
||||
await userEvent.type(screen.getByLabelText('Search sessions'), 'Session 3')
|
||||
expect(screen.getByRole('link', { name: /Session 3/ })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('link', { name: /Session 4/ })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('offers no search box for a list short enough to read', () => {
|
||||
mount()
|
||||
expect(screen.queryByLabelText('Search sessions')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('collapses to just its header', () => {
|
||||
const onToggle = vi.fn()
|
||||
mount({ open: false, onToggle })
|
||||
expect(screen.queryByRole('link', { name: /Board Review I/ })).not.toBeInTheDocument()
|
||||
expect(screen.getByLabelText('Show sessions')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
|
@ -31,7 +31,6 @@ const COLUMNS = [
|
|||
links: [
|
||||
{ to: '/search', label: 'Search' },
|
||||
{ to: '/ai', label: 'AI Mode' },
|
||||
{ to: '/analysis', label: 'Analysis' },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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']
|
||||
|
||||
|
|
|
|||
30
frontend/src/hooks/useMediaQuery.js
Normal file
30
frontend/src/hooks/useMediaQuery.js
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
|
||||
/**
|
||||
* Tracks a CSS media query from JavaScript.
|
||||
*
|
||||
* For cases where the two layouts are not the same markup styled differently
|
||||
* but genuinely different controls — a navigator that is a permanent column on
|
||||
* a wide screen and a dropdown on a narrow one. Hiding one of them with CSS
|
||||
* leaves it in the accessibility tree and in the tab order, so the choice has
|
||||
* to happen before the render, not after it.
|
||||
*/
|
||||
export default function useMediaQuery(query) {
|
||||
const [matches, setMatches] = useState(
|
||||
// Guarded for the server and for test environments without matchMedia.
|
||||
() => (typeof window !== 'undefined' && window.matchMedia
|
||||
? window.matchMedia(query).matches
|
||||
: false),
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined' || !window.matchMedia) return undefined
|
||||
const list = window.matchMedia(query)
|
||||
const onChange = event => setMatches(event.matches)
|
||||
setMatches(list.matches)
|
||||
list.addEventListener('change', onChange)
|
||||
return () => list.removeEventListener('change', onChange)
|
||||
}, [query])
|
||||
|
||||
return matches
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<div className={`an-layout${railOpen ? '' : ' rail-closed'}`}>
|
||||
<aside className="an-rail">
|
||||
<div className="an-rail-head">
|
||||
<h2>Latest sessions</h2>
|
||||
<button type="button" aria-label={railOpen ? 'Hide sessions' : 'Show sessions'}
|
||||
aria-expanded={railOpen} onClick={() => setRailOpen(v => !v)}>{railOpen ? '‹' : '›'}</button>
|
||||
</div>
|
||||
{railOpen && (
|
||||
sessions.length === 0
|
||||
? <p className="an-rail-empty">No sessions yet.</p>
|
||||
: <ul className="an-rail-list">
|
||||
{sessions.map(row => (
|
||||
<li key={row.quiz_id}>
|
||||
{/* The session's own analysis, not the raw answer list —
|
||||
that is what this rail is for. */}
|
||||
<Link to={row.last_attempt_id
|
||||
? `/analysis/session/${row.last_attempt_id}` : `/sessions/${row.quiz_id}`}>
|
||||
<span className="an-rail-title">
|
||||
<strong>{row.mode === 'learning' ? 'Study mode:' : 'Exam mode:'}</strong> {row.title}
|
||||
</span>
|
||||
<SessionProgress answered={row.answered} total={row.total}
|
||||
correct={row.state === 'completed' ? row.last_score : null} />
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</aside>
|
||||
<SessionRail sessions={sessions} open={railOpen} loading={sessionsLoading}
|
||||
onToggle={() => setRailOpen(v => !v)} />
|
||||
|
||||
<div className="an-page">
|
||||
<div className="an-header">
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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 <div className="loading"><div className="spinner" /></div>
|
||||
|
||||
// Addressed by quiz rather than attempt: nothing has been sat, so there is
|
||||
// nothing to analyse. Opening a session used to launch it — clicking a name
|
||||
// in a list dropped you into a 240-question exam with the clock running.
|
||||
// This says what the session is and lets you decide.
|
||||
if (!data && quizId) {
|
||||
const row = sessions.find(item => String(item.quiz_id) === String(quizId))
|
||||
return (
|
||||
<div className={`an-page${railOpen ? '' : ' is-narrow'}`}>
|
||||
<SessionRail sessions={sessions} open={railOpen} loading={sessionsLoading}
|
||||
onToggle={() => setRailOpen(v => !v)} />
|
||||
{!railOpen && (
|
||||
<button type="button" className="an-rail-show" onClick={() => setRailOpen(true)}>
|
||||
› Sessions
|
||||
</button>
|
||||
)}
|
||||
<main className="an-main">
|
||||
<div className="an-head">
|
||||
<h1>Your performance for <span>{row?.title || 'this session'}</span></h1>
|
||||
</div>
|
||||
<div className="an-notyet">
|
||||
<p className="an-notyet-lead">You have not answered any of this yet.</p>
|
||||
<p>
|
||||
{row ? <>{row.questions_per_attempt || row.questions_count} questions
|
||||
{' · '}{row.mode === 'learning' ? 'Study mode' : 'Exam mode'}</> : null}
|
||||
</p>
|
||||
<p className="an-notyet-note">
|
||||
Sit it through to the end and this page fills in: how you scored,
|
||||
where the time went, which topics to go back to and what to read.
|
||||
</p>
|
||||
<Link className="btn btn-primary" to={`/study/${quizId}`}>Start this session</Link>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!data) return <div className="an-empty">{error || 'Session not found.'}</div>
|
||||
|
||||
const correct = data.score
|
||||
|
|
@ -121,32 +164,8 @@ export default function AnalysisSessionPage() {
|
|||
|
||||
return (
|
||||
<div className={`an-page${railOpen ? '' : ' is-narrow'}`}>
|
||||
<aside className="an-rail">
|
||||
<div className="an-rail-head">
|
||||
<h2>Latest sessions</h2>
|
||||
<button type="button" onClick={() => setRailOpen(false)} aria-label="Hide sessions">‹</button>
|
||||
</div>
|
||||
<ul>
|
||||
{sessions.map(session => (
|
||||
<li key={session.quiz_id}>
|
||||
<Link className={`an-rail-item${session.last_attempt_id === Number(attemptId) ? ' is-active' : ''}`}
|
||||
to={session.last_attempt_id ? `/analysis/session/${session.last_attempt_id}` : `/sessions/${session.quiz_id}`}>
|
||||
<span className="an-rail-mode">
|
||||
{session.mode === 'learning' ? 'Study mode' : 'Exam mode'}:
|
||||
</span>
|
||||
<span className="an-rail-title">{session.title}</span>
|
||||
<span className="an-rail-count">
|
||||
{session.answered}/{session.total} questions
|
||||
</span>
|
||||
<span className="an-rail-bar">
|
||||
<span className="is-right" style={{ width: `${(session.last_percentage || 0)}%` }} />
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
{sessions.length === 0 && <li className="an-rail-empty">No sessions yet.</li>}
|
||||
</ul>
|
||||
</aside>
|
||||
<SessionRail sessions={sessions} open={railOpen} loading={sessionsLoading}
|
||||
onToggle={() => setRailOpen(v => !v)} />
|
||||
|
||||
{!railOpen && (
|
||||
<button type="button" className="an-rail-show" onClick={() => setRailOpen(true)}>
|
||||
|
|
@ -160,7 +179,7 @@ export default function AnalysisSessionPage() {
|
|||
<div className="an-head-actions">
|
||||
<Link className="btn btn-secondary btn-sm" to={`/results/${attemptId}`}>Review answers</Link>
|
||||
{data.quiz_id && (
|
||||
<Link className="btn btn-secondary btn-sm" to={`/sessions/${data.quiz_id}?restart=1`}>Retake</Link>
|
||||
<Link className="btn btn-secondary btn-sm" to={`/study/${data.quiz_id}?restart=1`}>Retake</Link>
|
||||
)}
|
||||
{/* Deleting is destructive and irreversible, so it asks first —
|
||||
inline, because a browser confirm() is not something this
|
||||
|
|
|
|||
|
|
@ -467,7 +467,7 @@ export default function CourseDetailPage() {
|
|||
)}
|
||||
</div>
|
||||
{canAttempt && (
|
||||
<button className="btn btn-primary" onClick={() => navigate(`/sessions/${activeLesson.quiz_id}?return_to=/courses/${courseId}`)}>
|
||||
<button className="btn btn-primary" onClick={() => navigate(`/study/${activeLesson.quiz_id}?return_to=/courses/${courseId}`)}>
|
||||
{attempts.length > 0 ? 'Retake Quiz' : 'Start Quiz'}
|
||||
</button>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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.')
|
||||
|
|
|
|||
|
|
@ -21,9 +21,9 @@ function setupCount(count = 30) {
|
|||
})
|
||||
}
|
||||
function renderBuilder() {
|
||||
render(<MemoryRouter initialEntries={['/sessions/create']}><Routes>
|
||||
<Route path="/sessions/create" element={<CustomQuizPage />} />
|
||||
<Route path="/sessions/:id" element={<h1>Saved test</h1>} />
|
||||
render(<MemoryRouter initialEntries={['/study/new']}><Routes>
|
||||
<Route path="/study/new" element={<CustomQuizPage />} />
|
||||
<Route path="/study/:id" element={<h1>Saved test</h1>} />
|
||||
</Routes></MemoryRouter>)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
<ExtractionProgress
|
||||
jobId={activeJob.jobId}
|
||||
label={activeJob.type === 'flashcard' ? 'Generating Cards' : 'Extracting Questions'}
|
||||
onDone={(quizId) => { setActiveJob(null); if (activeJob.type === 'flashcard') navigate('/flashcards'); else navigate(`/sessions/${quizId}`) }}
|
||||
onDone={(quizId) => { setActiveJob(null); if (activeJob.type === 'flashcard') navigate('/flashcards'); else navigate(`/study/${quizId}`) }}
|
||||
onClose={() => setActiveJob(null)}
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -65,7 +65,7 @@ function JobDetail({ job }) {
|
|||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, flexShrink: 0 }}>
|
||||
{job.quiz_id && (
|
||||
<Link to={`/sessions/${job.quiz_id}`} className="btn btn-primary btn-sm">Open Quiz</Link>
|
||||
<Link to={`/study/${job.quiz_id}`} className="btn btn-primary btn-sm">Open Quiz</Link>
|
||||
)}
|
||||
{job.status === 'running' && (
|
||||
<button className="btn btn-danger btn-sm" onClick={async () => {
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ export default function PublicQuizPage() {
|
|||
</p>
|
||||
)}
|
||||
{user ? (
|
||||
<button className="btn btn-primary" onClick={() => navigate(`/sessions/${quiz.quiz_id}`)}>
|
||||
<button className="btn btn-primary" onClick={() => navigate(`/study/${quiz.quiz_id}`)}>
|
||||
Take this quiz
|
||||
</button>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
|
|
|
|||
|
|
@ -268,7 +268,7 @@ export default function QuizEditPage() {
|
|||
)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<Link to={`/sessions/${id}`} className="btn btn-secondary btn-sm">← Back to Quiz</Link>
|
||||
<Link to={`/study/${id}`} className="btn btn-secondary btn-sm">← Back to Quiz</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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
|
|||
<div>
|
||||
{isModerator && (
|
||||
<div style={{ textAlign: 'right', marginBottom: 8, display: 'flex', gap: 8, justifyContent: 'flex-end', flexWrap: 'wrap' }}>
|
||||
<Link to={`/sessions/${id}/edit`} className="btn btn-secondary btn-sm">✏️ Edit Questions</Link>
|
||||
<Link to={`/study/${id}/edit`} className="btn btn-secondary btn-sm">✏️ Edit Questions</Link>
|
||||
</div>
|
||||
)}
|
||||
{starting ? (
|
||||
|
|
@ -1017,10 +1022,14 @@ const timerStarted = timeLeft !== null
|
|||
onClick={() => safeNavigate(Math.max(0, currentIdx - 1))}
|
||||
disabled={currentIdx === 0}>← Prev</button>
|
||||
|
||||
<button className="quiz-nav-toggle btn btn-secondary btn-sm"
|
||||
onClick={() => setNavOpen(v => !v)}>
|
||||
{currentIdx + 1} / {totalCount} {navOpen ? '▼' : '▲'}
|
||||
</button>
|
||||
{/* 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 && (
|
||||
<button className="quiz-nav-toggle btn btn-secondary btn-sm"
|
||||
onClick={() => setNavOpen(v => !v)}>
|
||||
{currentIdx + 1} / {totalCount} {navOpen ? '▼' : '▲'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{isLast ? (
|
||||
<button className="btn btn-primary" onClick={() => setShowReview(true)} disabled={submitting}>
|
||||
|
|
@ -1208,7 +1217,7 @@ const timerStarted = timeLeft !== null
|
|||
) : (
|
||||
<button className="btn btn-secondary btn-sm" onClick={() => setRestartConfirm(true)} title="Start this quiz over from the beginning">↺ Restart</button>
|
||||
)}
|
||||
{isModerator && <Link to={`/sessions/${id}/edit`} className="btn btn-secondary btn-sm">✏️ Edit</Link>}
|
||||
{isModerator && <Link to={`/study/${id}/edit`} className="btn btn-secondary btn-sm">✏️ Edit</Link>}
|
||||
</div>
|
||||
</div>
|
||||
{voices.length > 1 && (
|
||||
|
|
@ -1232,7 +1241,11 @@ const timerStarted = timeLeft !== null
|
|||
{/* Main content */}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div className="quiz-topbar">
|
||||
<button type="button" className="quiz-question-select" aria-expanded={navOpen} onClick={() => setNavOpen(value => !value)}><small>Question</small><strong>{currentIdx + 1}</strong> of {totalCount} ▾</button>
|
||||
{hasRail ? (
|
||||
<p className="quiz-question-select is-static"><small>Question</small><strong>{currentIdx + 1}</strong> of {totalCount}</p>
|
||||
) : (
|
||||
<button type="button" className="quiz-question-select" aria-expanded={navOpen} onClick={() => setNavOpen(value => !value)}><small>Question</small><strong>{currentIdx + 1}</strong> of {totalCount} ▾</button>
|
||||
)}
|
||||
<div className="quiz-top-actions">
|
||||
<button type="button" title="Keyboard shortcuts" aria-label="Keyboard shortcuts" onClick={() => setTool('shortcuts')}>⌨ <span>Shortcuts</span></button>
|
||||
<button type="button" title="Calculator" aria-label="Calculator" onClick={() => setTool('calculator')}>▦ <span>Calculator</span></button>
|
||||
|
|
@ -1242,7 +1255,7 @@ const timerStarted = timeLeft !== null
|
|||
<button type="button" aria-label="Next question" disabled={isLast} onClick={() => safeNavigate(currentIdx + 1)}>Next ›</button>
|
||||
</div>
|
||||
</div>
|
||||
{navOpen && <div className="quiz-nav-mobile-grid">{questions.map((q, i) => <QuestionDot key={q.id} q={q} i={i} />)}</div>}
|
||||
{!hasRail && navOpen && <div className="quiz-nav-mobile-grid">{questions.map((q, i) => <QuestionDot key={q.id} q={q} i={i} />)}</div>}
|
||||
|
||||
{current && (
|
||||
<div className="question-card" style={{
|
||||
|
|
@ -1259,7 +1272,7 @@ const timerStarted = timeLeft !== null
|
|||
{current.category_breadcrumbs.map((category, index) => (
|
||||
<span key={category.id}>
|
||||
{index > 0 && <span className="quiz-breadcrumb-sep" aria-hidden="true">›</span>}
|
||||
<Link to={`/sessions/create?category=${category.id}`} target="_blank" rel="noopener noreferrer">{category.name}</Link>
|
||||
<Link to={`/study/new?category=${category.id}`} target="_blank" rel="noopener noreferrer">{category.name}</Link>
|
||||
</span>
|
||||
))}
|
||||
</nav>
|
||||
|
|
|
|||
|
|
@ -47,12 +47,12 @@ beforeEach(() => {
|
|||
api.delete.mockResolvedValue({})
|
||||
})
|
||||
|
||||
function mount(entry = '/sessions/10') {
|
||||
function mount(entry = '/study/10') {
|
||||
render(<MemoryRouter initialEntries={[entry]}><Routes>
|
||||
<Route path="/sessions/:id" element={<QuizPage />} />
|
||||
<Route path="/study/:id" element={<QuizPage />} />
|
||||
{/* Submitting a general session ends on its analysis; a course quiz, which
|
||||
has no analysis of its own, still ends on the answer review. */}
|
||||
<Route path="/analysis/session/:attemptId" element={<div>Submitted results</div>} />
|
||||
<Route path="/sessions/:attemptId" element={<div>Submitted results</div>} />
|
||||
<Route path="/results/:id" element={<div>Course results</div>} />
|
||||
</Routes></MemoryRouter>)
|
||||
}
|
||||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -71,7 +71,7 @@ export default function ResultsPage() {
|
|||
</div>
|
||||
) : (
|
||||
<div className="results-crumb">
|
||||
<Link to={`/analysis/session/${result.id}`}>← Session analysis</Link>
|
||||
<Link to={`/study/${result.id}`}>← Session analysis</Link>
|
||||
<h1>Answer review</h1>
|
||||
<span className="results-crumb-score">{correct} of {total} correct · {pct}%</span>
|
||||
</div>
|
||||
|
|
@ -106,7 +106,7 @@ export default function ResultsPage() {
|
|||
const cardClass = ans.is_correct ? 'correct-card' : ans.user_answer ? 'wrong-card' : 'skipped-card'
|
||||
return (
|
||||
<div className={`review-card ${cardClass}`} key={ans.question_id}>
|
||||
<nav className="quiz-breadcrumbs" aria-label="Question categories">{ans.category_breadcrumbs?.length ? ans.category_breadcrumbs.map((category, index) => <span key={category.id}>{index > 0 && ' › '}<Link to={`/sessions/create?category=${category.id}`} target="_blank" rel="noopener noreferrer">{category.name}</Link></span>) : <span>Uncategorized</span>}</nav>
|
||||
<nav className="quiz-breadcrumbs" aria-label="Question categories">{ans.category_breadcrumbs?.length ? ans.category_breadcrumbs.map((category, index) => <span key={category.id}>{index > 0 && ' › '}<Link to={`/study/new?category=${category.id}`} target="_blank" rel="noopener noreferrer">{category.name}</Link></span>) : <span>Uncategorized</span>}</nav>
|
||||
{/* Question header */}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 16, gap: 12 }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
@ -1,229 +0,0 @@
|
|||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import api from '../api/client'
|
||||
import './SessionsPage.css'
|
||||
|
||||
const pct = (value) => (value == null ? null : Math.round(value))
|
||||
const band = (value) => (value == null ? '' : value >= 80 ? ' is-good' : value >= 50 ? ' is-fair' : ' is-poor')
|
||||
|
||||
const when = (value) => {
|
||||
if (!value) return '—'
|
||||
const date = new Date(value)
|
||||
const days = Math.round((Date.now() - date) / 86400000)
|
||||
if (days === 0) return 'Today'
|
||||
if (days === 1) return 'Yesterday'
|
||||
if (days < 7) return `${days} days ago`
|
||||
return date.toLocaleDateString(undefined, { day: '2-digit', month: 'short', year: 'numeric' })
|
||||
}
|
||||
|
||||
const STATES = [
|
||||
{ key: 'all', label: 'Everything' },
|
||||
{ key: 'in_progress', label: 'In progress' },
|
||||
{ key: 'completed', label: 'Completed' },
|
||||
{ key: 'not_started', label: 'Not started' },
|
||||
]
|
||||
|
||||
const SORTS = [
|
||||
{ key: 'recent', label: 'Most recent' },
|
||||
{ key: 'worst', label: 'Weakest score' },
|
||||
{ key: 'best', label: 'Best score' },
|
||||
{ key: 'title', label: 'Name' },
|
||||
]
|
||||
|
||||
/**
|
||||
* Every session, in full, with the sidebar doing the narrowing.
|
||||
*
|
||||
* This is the only list of sessions there is. It replaced a page that showed
|
||||
* the same rows twice — once as "Sessions" and again as "Library" — which gave
|
||||
* two names to one thing and no way to tell them apart. Material a learner has
|
||||
* not started is not a session: it is a study plan, and it is listed there.
|
||||
*/
|
||||
export default function SessionsPage() {
|
||||
const [rows, setRows] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [state, setState] = useState('all')
|
||||
const [mode, setMode] = useState('all')
|
||||
const [sort, setSort] = useState('recent')
|
||||
const [query, setQuery] = useState('')
|
||||
|
||||
const load = useCallback(() => {
|
||||
setLoading(true)
|
||||
api.get('/quizzes/sessions')
|
||||
.then(res => setRows(res.data || []))
|
||||
.catch(() => setError('Could not load your sessions'))
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
const modes = useMemo(
|
||||
() => [...new Set(rows.map(r => r.mode).filter(Boolean))].sort(), [rows])
|
||||
|
||||
const shown = useMemo(() => {
|
||||
const needle = query.trim().toLowerCase()
|
||||
const filtered = rows.filter(row => (
|
||||
(state === 'all' || row.state === state)
|
||||
&& (mode === 'all' || row.mode === mode)
|
||||
&& (!needle || (row.title || '').toLowerCase().includes(needle))
|
||||
))
|
||||
const by = {
|
||||
recent: (a, b) => (b.last_activity || '').localeCompare(a.last_activity || ''),
|
||||
// Nulls last in both directions: a session never attempted is not the
|
||||
// weakest score, it is the absence of one.
|
||||
worst: (a, b) => (a.last_percentage ?? 1e9) - (b.last_percentage ?? 1e9),
|
||||
best: (a, b) => (b.last_percentage ?? -1) - (a.last_percentage ?? -1),
|
||||
title: (a, b) => (a.title || '').localeCompare(b.title || ''),
|
||||
}
|
||||
return [...filtered].sort(by[sort])
|
||||
}, [rows, state, mode, sort, query])
|
||||
|
||||
const counts = useMemo(() => {
|
||||
const tally = { all: rows.length }
|
||||
for (const row of rows) tally[row.state] = (tally[row.state] || 0) + 1
|
||||
return tally
|
||||
}, [rows])
|
||||
|
||||
const attempted = rows.filter(r => r.last_percentage != null)
|
||||
const average = attempted.length
|
||||
? Math.round(attempted.reduce((sum, r) => sum + r.last_percentage, 0) / attempted.length)
|
||||
: null
|
||||
|
||||
return (
|
||||
<div className="sx-page">
|
||||
<div className="sx-header">
|
||||
<div>
|
||||
<h1>Sessions</h1>
|
||||
<p>Every test you have started or finished.</p>
|
||||
</div>
|
||||
{/* Where a new one comes from. The course material itself lives in the
|
||||
study plans — this page is the record of working through it, not a
|
||||
second copy of the catalogue. */}
|
||||
<div className="sx-header-actions">
|
||||
<Link className="btn btn-secondary" to="/study-plans">Study plans</Link>
|
||||
<Link className="btn btn-primary" to="/sessions/create">Create custom test</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sx-body">
|
||||
<aside className="sx-rail">
|
||||
<input className="sx-search" value={query} onChange={e => setQuery(e.target.value)}
|
||||
placeholder="Search by name" aria-label="Search sessions" />
|
||||
|
||||
<h2>Status</h2>
|
||||
<ul>
|
||||
{STATES.map(option => (
|
||||
<li key={option.key}>
|
||||
<button className={`sx-filter${state === option.key ? ' is-active' : ''}`}
|
||||
onClick={() => setState(option.key)}>
|
||||
<span>{option.label}</span>
|
||||
<span className="sx-count">{counts[option.key] || 0}</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{modes.length > 1 && (
|
||||
<>
|
||||
<h2>Mode</h2>
|
||||
<ul>
|
||||
<li>
|
||||
<button className={`sx-filter${mode === 'all' ? ' is-active' : ''}`}
|
||||
onClick={() => setMode('all')}><span>Any mode</span></button>
|
||||
</li>
|
||||
{modes.map(name => (
|
||||
<li key={name}>
|
||||
<button className={`sx-filter${mode === name ? ' is-active' : ''}`}
|
||||
onClick={() => setMode(name)}>
|
||||
<span>{name === 'learning' ? 'Study' : name === 'timed' ? 'Exam' : name}</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
|
||||
<h2>Sort</h2>
|
||||
<ul>
|
||||
{SORTS.map(option => (
|
||||
<li key={option.key}>
|
||||
<button className={`sx-filter${sort === option.key ? ' is-active' : ''}`}
|
||||
onClick={() => setSort(option.key)}><span>{option.label}</span></button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{average != null && (
|
||||
<div className="sx-summary">
|
||||
<strong>{average}%</strong>
|
||||
<span>average across {attempted.length} attempted</span>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
|
||||
<div className="sx-main">
|
||||
{error && <p className="sx-error" role="alert">{error}</p>}
|
||||
{loading ? <div className="loading"><div className="spinner" /></div>
|
||||
: shown.length === 0 ? (
|
||||
<div className="sx-empty">
|
||||
{rows.length === 0 ? 'No sessions yet.' : 'Nothing matches those filters.'}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<p className="sx-count-line" role="status">
|
||||
{shown.length} of {rows.length} session{rows.length === 1 ? '' : 's'}
|
||||
</p>
|
||||
<ul className="sx-list">
|
||||
{shown.map(row => (
|
||||
<li key={row.quiz_id} className={`sx-row is-${row.state}`}>
|
||||
<div className="sx-row-main">
|
||||
{/* The title opens what there is to see: the analysis
|
||||
once it has been sat, the session itself otherwise.
|
||||
It used to launch the test on click. */}
|
||||
<Link className="sx-title" to={row.last_attempt_id
|
||||
? `/analysis/session/${row.last_attempt_id}` : `/sessions/${row.quiz_id}`}>
|
||||
{row.title}
|
||||
</Link>
|
||||
<span className="sx-meta">
|
||||
{row.mode === 'learning' ? 'Study' : row.mode === 'timed' ? 'Exam' : row.mode}
|
||||
{' · '}{row.questions_per_attempt || row.questions_count} questions
|
||||
{row.attempts_count > 1 && <> · {row.attempts_count} attempts</>}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{row.state === 'in_progress' ? (
|
||||
<span className="sx-progress">
|
||||
{row.answered}/{row.total} answered
|
||||
</span>
|
||||
) : row.last_percentage != null ? (
|
||||
<span className={`sx-score${band(row.last_percentage)}`}>
|
||||
{pct(row.last_percentage)}%
|
||||
{row.best_percentage != null && row.best_percentage !== row.last_percentage && (
|
||||
<em>best {pct(row.best_percentage)}%</em>
|
||||
)}
|
||||
</span>
|
||||
) : <span className="sx-score is-none">Not attempted</span>}
|
||||
|
||||
<span className="sx-when">{when(row.last_activity)}</span>
|
||||
|
||||
<span className="sx-actions">
|
||||
{row.state === 'in_progress' && (
|
||||
<Link className="btn btn-primary btn-sm" to={`/sessions/${row.quiz_id}`}>Resume</Link>
|
||||
)}
|
||||
{row.last_attempt_id && (
|
||||
<Link className="btn btn-secondary btn-sm" to={`/analysis/session/${row.last_attempt_id}`}>Review</Link>
|
||||
)}
|
||||
{row.state === 'not_started' && (
|
||||
<Link className="btn btn-secondary btn-sm" to={`/sessions/${row.quiz_id}`}>Start</Link>
|
||||
)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -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() {
|
|||
<div className="block-sessions">
|
||||
<h3>Sessions</h3>
|
||||
{block.quiz_id ? (
|
||||
<Link className="btn btn-primary btn-sm" to={`/sessions/${block.quiz_id}`}>
|
||||
<Link className="btn btn-primary btn-sm" to={`/study/${block.quiz_id}`}>
|
||||
{block.completed ? 'Review this block' : 'Continue this block'}
|
||||
</Link>
|
||||
) : block.question_count === 0 ? (
|
||||
|
|
|
|||
|
|
@ -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 } })
|
||||
|
|
|
|||
Loading…
Reference in a new issue