diff --git a/backend/alembic/versions/b2c3d4e5f6a7_plan_origin.py b/backend/alembic/versions/b2c3d4e5f6a7_plan_origin.py
new file mode 100644
index 0000000..1468446
--- /dev/null
+++ b/backend/alembic/versions/b2c3d4e5f6a7_plan_origin.py
@@ -0,0 +1,47 @@
+"""Mark study-plan source quizzes with origin='plan'
+
+Board Review I–XII exist twice over: once as a study plan the learner works
+through block by block, and once as the bulk quiz the plan was built from. The
+session list showed both, so the same twelve titles appeared under "Sessions"
+and again under "Library" with nothing to tell them apart.
+
+Tagging the bulk quizzes keeps them intact — the questions still hang off them
+via `source_quiz_id` — while taking them out of the list of things a learner
+picks from. A quiz already sat stays in the history regardless; that filtering
+lives in the endpoint, not here.
+
+Revision ID: b2c3d4e5f6a7
+Revises: a1b2c3d4e5f6
+"""
+import sqlalchemy as sa
+from alembic import op
+
+revision = "b2c3d4e5f6a7"
+down_revision = "a1b2c3d4e5f6"
+branch_labels = None
+depends_on = None
+
+
+def _has(table: str) -> bool:
+ return table in sa.inspect(op.get_bind()).get_table_names()
+
+
+def upgrade() -> None:
+ # Guarded because create_all() may have built these tables on a fresh
+ # deploy before Alembic ran.
+ if not (_has("quizzes") and _has("study_plans")):
+ return
+ # Matched on the trimmed title: one of the imports carries a trailing
+ # space ("Board Review VII ").
+ op.execute(sa.text("""
+ UPDATE quizzes SET origin = 'plan'
+ WHERE course_id IS NULL
+ AND origin <> 'plan'
+ AND btrim(title) IN (SELECT btrim(name) FROM study_plans)
+ """))
+
+
+def downgrade() -> None:
+ if not _has("quizzes"):
+ return
+ op.execute(sa.text("UPDATE quizzes SET origin = 'bank' WHERE origin = 'plan'"))
diff --git a/backend/app/models/quiz.py b/backend/app/models/quiz.py
index d191c0c..d027bf8 100644
--- a/backend/app/models/quiz.py
+++ b/backend/app/models/quiz.py
@@ -7,6 +7,11 @@ from app.database import Base
from app.models.quiz_category import QuizCategory # noqa — ensures mapper resolves "QuizCategory"
from app.models.quiz_question_link import QuizQuestionLink # noqa
+#: `Quiz.origin` for a quiz that backs a study-plan block rather than standing
+#: on its own. Kept here because both the study-plan builder that sets it and
+#: the session list that filters on it need the same spelling.
+PLAN_ORIGIN = "plan"
+
class Quiz(Base):
__tablename__ = "quizzes"
@@ -29,7 +34,10 @@ class Quiz(Base):
questions_per_attempt = Column(Integer, nullable=True) # null = all; set = random subset from pool
allow_review = Column(Integer, default=1) # 1 = students can review answers after submit, 0 = no review
share_token = Column(String(64), unique=True, nullable=True) # public /share/{token} link when set
- origin = Column(String(20), default="bank") # bank | upload | ai
+ # bank | upload | ai | sample | plan.
+ # "plan" marks a quiz that exists to back a study-plan block. It is still a
+ # real quiz — it just is not something the learner picks off a list.
+ origin = Column(String(20), default="bank")
section = relationship("Section", back_populates="quizzes")
user = relationship("User", back_populates="quizzes")
diff --git a/backend/app/routers/quizzes.py b/backend/app/routers/quizzes.py
index 602c96d..eae2cd1 100644
--- a/backend/app/routers/quizzes.py
+++ b/backend/app/routers/quizzes.py
@@ -8,7 +8,7 @@ from sqlalchemy.orm import Session
from app.utils.upload_access import validate_image_attachments
from app.utils.quiz_questions import validate_option_explanations
from app.database import get_db
-from app.models.quiz import Quiz
+from app.models.quiz import PLAN_ORIGIN, Quiz
from app.models.quiz_category import QuizCategory
from app.models.question import Question as QuestionModel
from app.models.section import Section
@@ -333,6 +333,12 @@ def list_quiz_sessions(
for quiz in quizzes:
live = active.get(quiz.id)
done = finished.get(quiz.id, [])
+ # Material belonging to a study plan is not a session of its own. It is
+ # listed, and started, on the plan — showing it here as well put the
+ # same twelve titles under two headings with no way to tell them apart.
+ # Once the learner has actually sat it, it is history and belongs here.
+ if quiz.origin == PLAN_ORIGIN and not live and not done:
+ continue
last = done[-1] if done else None
per_attempt = quiz.questions_per_attempt or quiz.questions_count or 0
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index 128ddd7..8cb2fed 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -13,7 +13,6 @@ const UploadPage = lazy(() => import('./pages/UploadPage'))
const DocumentDetailPage = lazy(() => import('./pages/DocumentDetailPage'))
const QuizPage = lazy(() => import('./pages/QuizPage'))
const CustomQuizPage = lazy(() => import('./pages/CustomQuizPage'))
-const QuizzesPage = lazy(() => import('./pages/QuizzesPage'))
const ResultsPage = lazy(() => import('./pages/ResultsPage'))
const AdminPage = lazy(() => import('./pages/AdminPage'))
const AccountPage = lazy(() => import('./pages/AccountPage'))
@@ -79,6 +78,17 @@ function RequireAuth({ moderator = false }) {
return
}
+/**
+ * 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.
+ */
+function LegacyQuizRedirect() {
+ const { pathname, search, hash } = useLocation()
+ const rest = pathname.replace(/^\/quizzes/, '')
+ return
+}
+
function AppRoutes() {
const { user, loading } = useAuth()
if (loading) return
@@ -100,10 +110,9 @@ function AppRoutes() {
}>
}>
} />
- } />
} />
- } />
- } />
+ } />
+ } />
} />
} />
} />
@@ -135,7 +144,7 @@ function AppRoutes() {
}>
}>
} />
- } />
+ } />
} />
} />
} />
@@ -145,6 +154,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. */}
+ } />
+
{/* Catch-all */}
: } />
diff --git a/frontend/src/components/CategoryPerformance.jsx b/frontend/src/components/CategoryPerformance.jsx
index 1155320..d964531 100644
--- a/frontend/src/components/CategoryPerformance.jsx
+++ b/frontend/src/components/CategoryPerformance.jsx
@@ -56,10 +56,10 @@ export default function CategoryPerformance() {
navigate(`/quizzes/create?adaptive=1&count=${adaptiveCount}`)}>Start adaptive session
+ onClick={() => navigate(`/sessions/create?adaptive=1&count=${adaptiveCount}`)}>Start adaptive session
{main.length > 0 && (
navigate(`/quizzes/create?adaptive=1&count=${adaptiveCount}&${main.slice(0, 3).map(row => `category=${row.category_id}`).join('&')}`)}>
+ onClick={() => navigate(`/sessions/create?adaptive=1&count=${adaptiveCount}&${main.slice(0, 3).map(row => `category=${row.category_id}`).join('&')}`)}>
On my weakest topics
)}
diff --git a/frontend/src/components/ContinueStudy.jsx b/frontend/src/components/ContinueStudy.jsx
index a92625d..2a832b8 100644
--- a/frontend/src/components/ContinueStudy.jsx
+++ b/frontend/src/components/ContinueStudy.jsx
@@ -44,7 +44,7 @@ export default function ContinueStudy() {
Latest question sessions
- See all
+ See all
{sessions.map(row => {
@@ -63,7 +63,7 @@ export default function ContinueStudy() {
{answered}/{total}
+ to={done ? `/analysis/session/${row.last_attempt_id}` : `/sessions/${row.quiz_id}`}>
{done ? 'Review' : 'Resume'}
diff --git a/frontend/src/components/Navbar.jsx b/frontend/src/components/Navbar.jsx
index 082b84a..a4d9195 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,11 +107,9 @@ export default function Navbar({ onSignIn, onRegister }) {
{ to: '/home', label: 'Home' },
{ to: '/', label: 'Dashboard' },
{ to: '/ai', label: 'AI Mode' },
- // "Quiz" describes the packaging; a learner sits a session. Both entries
- // are here because they answer different questions: what can I sit, and
- // what have I sat.
- { to: '/quizzes', label: 'Sessions' },
- { to: '/sessions', label: 'History' },
+ // One entry, because there is one page. "Sessions" and "History" were two
+ // names for the same list, which is what made it unreadable.
+ { to: '/sessions', label: 'Sessions' },
{ to: '/analysis', label: 'Analysis' },
{ to: '/question-bank', label: 'Question Bank' },
...(canManageQuestions ? [{ to: '/questions/manage', label: 'Manage Qs' },
diff --git a/frontend/src/components/PractiseTopic.jsx b/frontend/src/components/PractiseTopic.jsx
index 21221f8..f67a149 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(`/quizzes/${res.data.id}`)
+ navigate(`/sessions/${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 ea2c6b0..9eab6cb 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/SiteFooter.jsx b/frontend/src/components/SiteFooter.jsx
index 936db7b..d3de9a1 100644
--- a/frontend/src/components/SiteFooter.jsx
+++ b/frontend/src/components/SiteFooter.jsx
@@ -14,7 +14,7 @@ const COLUMNS = [
{ to: '/', label: 'Dashboard' },
{ to: '/question-bank', label: 'Question bank' },
{ to: '/study-plans', label: 'Study plans' },
- { to: '/quizzes', label: 'Quizzes' },
+ { to: '/sessions', label: 'Sessions' },
],
},
{
diff --git a/frontend/src/components/SiteFooter.test.jsx b/frontend/src/components/SiteFooter.test.jsx
index 58e0855..fd88849 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', '/', '/quizzes', '/question-bank', '/analysis',
+const ROUTES = ['/home', '/login', '/register', '/', '/sessions', '/question-bank', '/analysis',
'/questions/manage', '/flashcards', '/search', '/ai', '/media', '/study-plans', '/articles',
'/courses', '/account', '/settings', '/categories', '/editorial']
diff --git a/frontend/src/pages/AnalysisPage.jsx b/frontend/src/pages/AnalysisPage.jsx
index 4e2ab36..4d238cf 100644
--- a/frontend/src/pages/AnalysisPage.jsx
+++ b/frontend/src/pages/AnalysisPage.jsx
@@ -90,8 +90,8 @@ export default function AnalysisPage() {
.catch(() => setSessions([]))
}, [])
- const startAdaptive = () => navigate(`/quizzes/create?adaptive=1&count=${count}`)
- const startCategory = (categoryId) => navigate(`/quizzes/create?category=${categoryId}&count=${count}`)
+ const startAdaptive = () => navigate(`/sessions/create?adaptive=1&count=${count}`)
+ const startCategory = (categoryId) => navigate(`/sessions/create?category=${categoryId}&count=${count}`)
return (
@@ -110,7 +110,7 @@ export default function AnalysisPage() {
{/* The session's own analysis, not the raw answer list —
that is what this rail is for. */}
+ ? `/analysis/session/${row.last_attempt_id}` : `/sessions/${row.quiz_id}`}>
{row.mode === 'learning' ? 'Study mode:' : 'Exam mode:'} {row.title}
diff --git a/frontend/src/pages/AnalysisSessionPage.css b/frontend/src/pages/AnalysisSessionPage.css
index 666d929..d9ce333 100644
--- a/frontend/src/pages/AnalysisSessionPage.css
+++ b/frontend/src/pages/AnalysisSessionPage.css
@@ -90,3 +90,14 @@
.an-pager button { width: 30px; height: 30px; border: 1px solid var(--border); border-radius: 8px; background: var(--card-bg); cursor: pointer; color: var(--text-muted); }
.an-pager button:disabled { opacity: 0.4; cursor: default; }
.an-pager button:not(:disabled):hover { border-color: var(--primary); color: var(--primary); }
+
+/* Actions sit with the heading rather than under the figures: they act on the
+ session as a whole, which is what the heading names. */
+.an-head-actions { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
+.an-danger { color: var(--wrong-fg); border-color: var(--wrong-bd); background: var(--card-bg); }
+.an-danger:hover { background: var(--wrong-bg); }
+.an-danger-note {
+ margin: 0 0 14px; padding: 10px 12px; font-size: 0.84rem; line-height: 1.55;
+ color: var(--wrong-fg); background: var(--wrong-bg);
+ border: 1px solid var(--wrong-bd); border-radius: 8px;
+}
diff --git a/frontend/src/pages/AnalysisSessionPage.jsx b/frontend/src/pages/AnalysisSessionPage.jsx
index 9c29bc1..46805b8 100644
--- a/frontend/src/pages/AnalysisSessionPage.jsx
+++ b/frontend/src/pages/AnalysisSessionPage.jsx
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
-import { Link, useParams } from 'react-router-dom'
+import { Link, useNavigate, useParams } from 'react-router-dom'
import api from '../api/client'
import './AnalysisSessionPage.css'
@@ -76,6 +76,20 @@ 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 [confirmDelete, setConfirmDelete] = useState(false)
+ const [deleting, setDeleting] = useState(false)
+ const navigate = useNavigate()
+
+ const deleteSession = async () => {
+ setDeleting(true)
+ try {
+ await api.delete(`/attempts/${attemptId}`)
+ navigate('/sessions', { replace: true })
+ } catch {
+ setDeleting(false)
+ setError('Could not delete this session')
+ }
+ }
const load = useCallback(() => {
setLoading(true)
@@ -116,7 +130,7 @@ export default function AnalysisSessionPage() {
{sessions.map(session => (
+ to={session.last_attempt_id ? `/analysis/session/${session.last_attempt_id}` : `/sessions/${session.quiz_id}`}>
{session.mode === 'learning' ? 'Study mode' : 'Exam mode'}:
@@ -143,8 +157,33 @@ export default function AnalysisSessionPage() {
Your performance for {data.title}
-
Review answers
+
+ Review answers
+ {data.quiz_id && (
+ Retake
+ )}
+ {/* Deleting is destructive and irreversible, so it asks first —
+ inline, because a browser confirm() is not something this
+ codebase uses. */}
+ {confirmDelete ? (
+ <>
+ {deleting ? 'Deleting…' : 'Delete for good'}
+ setConfirmDelete(false)}>Keep
+ >
+ ) : (
+ setConfirmDelete(true)}>Delete session
+ )}
+
+ {confirmDelete && (
+
+ This removes the attempt and its answers. Your overall statistics are
+ recalculated without it, and it cannot be undone.
+
+ )}
{[
diff --git a/frontend/src/pages/CourseDetailPage.jsx b/frontend/src/pages/CourseDetailPage.jsx
index 29336da..16ad78e 100644
--- a/frontend/src/pages/CourseDetailPage.jsx
+++ b/frontend/src/pages/CourseDetailPage.jsx
@@ -467,7 +467,7 @@ export default function CourseDetailPage() {
)}
{canAttempt && (
- navigate(`/quizzes/${activeLesson.quiz_id}?return_to=/courses/${courseId}`)}>
+ navigate(`/sessions/${activeLesson.quiz_id}?return_to=/courses/${courseId}`)}>
{attempts.length > 0 ? 'Retake Quiz' : 'Start Quiz'}
)}
diff --git a/frontend/src/pages/CustomQuizPage.jsx b/frontend/src/pages/CustomQuizPage.jsx
index e6b39cb..2aa1160 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(`/quizzes/${result.data.id}`)
+ navigate(`/sessions/${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.')
@@ -216,7 +216,7 @@ export default function CustomQuizPage() {
return (
-
← Quizzes
+
← Quizzes
Create Custom Test
Choose questions from your bank, {user?.name || 'learner'}.
diff --git a/frontend/src/pages/CustomQuizPage.test.jsx b/frontend/src/pages/CustomQuizPage.test.jsx
index 294c035..78ba54f 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 949796c..f849eca 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(`/quizzes/${res.data.quiz_id}`)
+ navigate(`/sessions/${res.data.quiz_id}`)
}
} else if (res.data.id) {
- navigate(`/quizzes/${res.data.id}`)
+ navigate(`/sessions/${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(`/quizzes/${quizId}`) }}
+ onDone={(quizId) => { setActiveJob(null); if (activeJob.type === 'flashcard') navigate('/flashcards'); else navigate(`/sessions/${quizId}`) }}
onClose={() => setActiveJob(null)}
/>
)}
diff --git a/frontend/src/pages/JobsPage.jsx b/frontend/src/pages/JobsPage.jsx
index c1b3a55..e396e41 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/LandingPage.jsx b/frontend/src/pages/LandingPage.jsx
index 6092533..b6bc6e0 100644
--- a/frontend/src/pages/LandingPage.jsx
+++ b/frontend/src/pages/LandingPage.jsx
@@ -400,7 +400,7 @@ export default function LandingPage() {
{user ? <>
Dashboard
- My Quizzes
+ My Quizzes
Question Bank
> : <>
setAuthModal('login')} className="btn btn-primary" style={{ padding: '12px 28px', fontSize: '1rem', borderRadius: 10 }}>Sign In
diff --git a/frontend/src/pages/PublicQuizPage.jsx b/frontend/src/pages/PublicQuizPage.jsx
index ddddfdc..99da8a2 100644
--- a/frontend/src/pages/PublicQuizPage.jsx
+++ b/frontend/src/pages/PublicQuizPage.jsx
@@ -46,7 +46,7 @@ export default function PublicQuizPage() {
)}
{user ? (
- navigate(`/quizzes/${quiz.quiz_id}`)}>
+ navigate(`/sessions/${quiz.quiz_id}`)}>
Take this quiz
) : (
diff --git a/frontend/src/pages/QuestionBankPage.jsx b/frontend/src/pages/QuestionBankPage.jsx
index 8b6d41e..9801b1a 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(`/quizzes/${res.data.id}`)
+ navigate(`/sessions/${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(`/quizzes/${res.data.id}`)
+ navigate(`/sessions/${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 27ffb38..bdc792c 100644
--- a/frontend/src/pages/QuizEditPage.jsx
+++ b/frontend/src/pages/QuizEditPage.jsx
@@ -192,7 +192,7 @@ export default function QuizEditPage() {
setQuiz(qRes.data)
setTitleValue(qRes.data.title)
setQuestions(questRes.data)
- }).catch(() => navigate('/quizzes'))
+ }).catch(() => navigate('/sessions'))
.finally(() => setLoading(false))
}, [id])
@@ -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 220293b..822ecc3 100644
--- a/frontend/src/pages/QuizPage.jsx
+++ b/frontend/src/pages/QuizPage.jsx
@@ -920,9 +920,13 @@ const timerStarted = timeLeft !== null
clearInterval(timerRef.current)
api.delete(`/attempts/progress/${attemptId}`).catch(() => {})
if (returnTo) {
+ // A course quiz reports back to its course, and has no analysis of its
+ // own — the answer review is the whole of its result.
navigate(`/results/${attemptId}?return_to=${encodeURIComponent(returnTo)}`, { state: { result: res.data } })
} else {
- navigate(`/results/${attemptId}`, { state: { result: res.data } })
+ // 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 } })
}
} catch (err) {
const detail = err.response?.data?.detail
@@ -961,7 +965,7 @@ const timerStarted = timeLeft !== null
{isModerator && (
- ✏️ Edit Questions
+ ✏️ Edit Questions
)}
{starting ? (
@@ -1204,7 +1208,7 @@ const timerStarted = timeLeft !== null
) : (
setRestartConfirm(true)} title="Start this quiz over from the beginning">↺ Restart
)}
- {isModerator &&
✏️ Edit}
+ {isModerator &&
✏️ Edit}
{voices.length > 1 && (
@@ -1255,7 +1259,7 @@ const timerStarted = timeLeft !== null
{current.category_breadcrumbs.map((category, index) => (
{index > 0 && › }
- {category.name}
+ {category.name}
))}
diff --git a/frontend/src/pages/QuizPage.test.jsx b/frontend/src/pages/QuizPage.test.jsx
index 6552bf3..0aeb07f 100644
--- a/frontend/src/pages/QuizPage.test.jsx
+++ b/frontend/src/pages/QuizPage.test.jsx
@@ -47,8 +47,14 @@ beforeEach(() => {
api.delete.mockResolvedValue({})
})
-function mount(entry = '/quizzes/10') {
- render( } />Submitted results } />)
+function mount(entry = '/sessions/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} />
+ Course results} />
+ )
}
/** The session rail repeats each stem as an excerpt, so stem lookups are
* scoped to the question card to stay unambiguous. */
@@ -145,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', '/quizzes/create?category=11')
+ expect(screen.getByRole('link', { name: 'Neonatology' })).toHaveAttribute('href', '/sessions/create?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)
@@ -189,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('/quizzes/10?return_to=%2Fcourses%2F1')
+ mount('/sessions/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 c3daa35..579539c 100644
--- a/frontend/src/pages/QuizPlayer.css
+++ b/frontend/src/pages/QuizPlayer.css
@@ -226,3 +226,11 @@
.qz-overview-meta { margin: 0 0 10px; font-size: 0.86rem; color: var(--text-muted); }
.qz-overview-note { margin: 0 0 18px; font-size: 0.85rem; color: var(--text-subtle); }
.qz-overview-actions { display: flex; gap: 10px; justify-content: center; flex-wrap: wrap; }
+
+/* Answer review header. The score belongs to the analysis page; what this line
+ carries is the way back and just enough of the result to know where you are. */
+.results-crumb { margin-bottom: 20px; }
+.results-crumb > a { font-size: 0.85rem; color: var(--primary); text-decoration: none; }
+.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); }
diff --git a/frontend/src/pages/QuizzesPage.css b/frontend/src/pages/QuizzesPage.css
deleted file mode 100644
index 5b1b295..0000000
--- a/frontend/src/pages/QuizzesPage.css
+++ /dev/null
@@ -1,266 +0,0 @@
-/* Quiz management area — session list, library grid and category admin.
- Mobile-first: rows stack under 640px and the action menu becomes a sheet. */
-
-.quizzes-page { max-width: 1080px; margin: 0 auto; }
-
-/* ── Hero ─────────────────────────────────────────────────────────── */
-.qz-hero {
- background: var(--card-bg);
- border: var(--card-border);
- border-radius: var(--card-radius);
- box-shadow: var(--card-shadow);
- padding: 18px 20px;
- margin-bottom: 14px;
- display: flex;
- gap: 16px;
- align-items: center;
- flex-wrap: wrap;
-}
-.qz-hero-text { flex: 1; min-width: 220px; }
-.qz-hero h1 { margin: 0 0 4px; font-size: 1.25rem; }
-.qz-hero p { margin: 0; color: var(--text-muted); font-size: 0.88rem; line-height: 1.5; }
-.qz-hero-actions { display: flex; gap: 8px; flex-wrap: wrap; }
-
-/* ── Tabs ─────────────────────────────────────────────────────────── */
-.qz-tabs {
- display: flex;
- gap: 4px;
- border-bottom: 1px solid var(--border);
- margin-bottom: 14px;
- overflow-x: auto;
- scrollbar-width: none;
-}
-.qz-tabs::-webkit-scrollbar { display: none; }
-.qz-tab {
- background: none;
- border: none;
- border-bottom: 2px solid transparent;
- padding: 9px 14px;
- font-size: 0.9rem;
- font-weight: 600;
- color: var(--text-muted);
- cursor: pointer;
- white-space: nowrap;
-}
-.qz-tab:hover { color: var(--text); }
-.qz-tab[aria-selected='true'] { color: var(--primary); border-bottom-color: var(--primary); }
-.qz-tab-count { font-weight: 400; opacity: 0.7; margin-left: 5px; }
-
-/* ── Toolbar (search + filters) ───────────────────────────────────── */
-.qz-toolbar { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 14px; }
-.qz-search { flex: 1; min-width: 190px; position: relative; }
-.qz-search input {
- width: 100%;
- padding: 9px 13px 9px 34px;
- border: 1px solid var(--border);
- border-radius: 8px;
- font-size: 0.9rem;
- background: var(--input-bg);
- color: var(--text);
-}
-.qz-search-icon { position: absolute; left: 11px; top: 50%; transform: translateY(-50%); color: var(--text-subtle); }
-.qz-chips { display: flex; gap: 6px; flex-wrap: wrap; }
-.qz-chip {
- border: 1px solid var(--border);
- background: var(--card-bg);
- color: var(--text-muted);
- border-radius: 20px;
- padding: 6px 13px;
- font-size: 0.8rem;
- font-weight: 600;
- cursor: pointer;
- white-space: nowrap;
-}
-.qz-chip[aria-pressed='true'] { background: var(--primary); border-color: var(--primary); color: var(--primary-fg); }
-
-/* ── Session list ─────────────────────────────────────────────────── */
-.qz-daygroup { margin-bottom: 18px; }
-.qz-dayhead {
- display: flex;
- align-items: center;
- gap: 10px;
- margin: 0 0 8px;
- font-size: 0.72rem;
- font-weight: 700;
- letter-spacing: 0.08em;
- text-transform: uppercase;
- color: var(--text-subtle);
-}
-.qz-dayhead::after { content: ''; flex: 1; height: 1px; background: var(--border); }
-
-.qz-sessions { display: flex; flex-direction: column; gap: 8px; }
-.qz-session {
- background: var(--card-bg);
- border: var(--card-border);
- border-radius: 10px;
- box-shadow: var(--card-shadow);
- padding: 13px 15px;
- display: grid;
- grid-template-columns: 1fr auto;
- gap: 10px 14px;
- align-items: center;
-}
-.qz-session-main { min-width: 0; }
-.qz-session-kicker {
- font-size: 0.7rem;
- font-weight: 700;
- text-transform: uppercase;
- letter-spacing: 0.05em;
- color: var(--text-subtle);
- display: flex;
- gap: 8px;
- align-items: center;
- flex-wrap: wrap;
- margin-bottom: 3px;
-}
-.qz-badge { border-radius: 20px; padding: 1px 8px; font-size: 0.66rem; letter-spacing: 0.04em; }
-.qz-badge-study { background: #d1fae5; color: #065f46; }
-.qz-badge-exam { background: #e0e7ff; color: #3730a3; }
-.qz-badge-draft { background: #fef3c7; color: #92400e; }
-.qz-badge-shared { background: #ede9fe; color: #5b21b6; }
-
-.qz-session-title {
- display: block;
- font-weight: 700;
- font-size: 0.95rem;
- color: var(--text);
- text-decoration: none;
- line-height: 1.35;
- overflow-wrap: anywhere;
-}
-.qz-session-title:hover { color: var(--primary); }
-.qz-session-meta { font-size: 0.76rem; color: var(--text-muted); margin-top: 3px; }
-
-.qz-progress { display: flex; align-items: center; gap: 8px; margin-top: 7px; }
-.qz-progress-track {
- flex: 1;
- max-width: 260px;
- height: 6px;
- border-radius: 3px;
- background: var(--border);
- overflow: hidden;
-}
-.qz-progress-fill { height: 100%; border-radius: 3px; background: var(--primary); transition: width 0.25s ease; }
-.qz-progress-fill.is-done { background: var(--correct-fg); }
-.qz-progress-label { font-size: 0.76rem; font-weight: 600; color: var(--text-muted); white-space: nowrap; }
-
-.qz-session-actions { display: flex; gap: 6px; align-items: center; flex-shrink: 0; }
-.qz-menu-wrap { position: relative; }
-.qz-kebab {
- background: none;
- border: 1px solid transparent;
- border-radius: 8px;
- cursor: pointer;
- color: var(--text-muted);
- font-size: 1.05rem;
- line-height: 1;
- padding: 7px 9px;
-}
-.qz-kebab:hover { background: var(--bg); color: var(--text); }
-.qz-menu {
- position: absolute;
- right: 0;
- top: calc(100% + 4px);
- z-index: 60;
- min-width: 190px;
- background: var(--card-bg);
- border: 1px solid var(--border);
- border-radius: 10px;
- box-shadow: 0 12px 32px rgba(0, 0, 0, 0.16);
- padding: 5px;
- display: flex;
- flex-direction: column;
-}
-.qz-menu-item {
- background: none;
- border: none;
- text-align: left;
- padding: 9px 12px;
- border-radius: 7px;
- font-size: 0.85rem;
- color: var(--text);
- cursor: pointer;
- text-decoration: none;
- display: block;
- width: 100%;
-}
-.qz-menu-item:hover:not(:disabled) { background: var(--bg); }
-.qz-menu-item:disabled { color: var(--text-subtle); cursor: not-allowed; }
-.qz-menu-item.is-danger { color: var(--wrong-fg); }
-.qz-menu-sep { height: 1px; background: var(--border); margin: 4px 2px; }
-.qz-menu-backdrop { position: fixed; inset: 0; z-index: 55; }
-
-.qz-rename { display: flex; gap: 6px; margin-top: 8px; flex-wrap: wrap; }
-.qz-rename input {
- flex: 1;
- min-width: 160px;
- padding: 7px 11px;
- border: 1px solid var(--border);
- border-radius: 8px;
- font-size: 0.87rem;
- background: var(--input-bg);
- color: var(--text);
-}
-.qz-inline-confirm {
- display: flex;
- gap: 6px;
- align-items: center;
- flex-wrap: wrap;
- margin-top: 8px;
- font-size: 0.82rem;
- color: var(--wrong-fg);
-}
-.qz-error { color: var(--wrong-fg); font-size: 0.8rem; margin: 6px 0 0; }
-
-/* ── Library grid ─────────────────────────────────────────────────── */
-.qz-group-head {
- font-size: 0.75rem;
- font-weight: 700;
- color: var(--text-muted);
- text-transform: uppercase;
- letter-spacing: 0.06em;
- margin: 0 0 10px;
-}
-.qz-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(215px, 1fr)); gap: 14px; margin-bottom: 22px; }
-
-/* ── Empty state ──────────────────────────────────────────────────── */
-.qz-empty {
- background: var(--card-bg);
- border: var(--card-border);
- border-radius: var(--card-radius);
- padding: 34px 20px;
- text-align: center;
- color: var(--text-muted);
- font-size: 0.9rem;
-}
-.qz-empty strong { display: block; color: var(--text); font-size: 1rem; margin-bottom: 6px; }
-
-/* ── Mobile ───────────────────────────────────────────────────────── */
-@media (max-width: 640px) {
- .qz-hero { padding: 15px; }
- .qz-hero-actions { width: 100%; }
- .qz-hero-actions .btn { flex: 1; text-align: center; }
-
- .qz-session { grid-template-columns: 1fr; padding: 13px; }
- .qz-session-actions { width: 100%; }
- .qz-session-actions .btn { flex: 1; }
- .qz-progress-track { max-width: none; }
-
- /* Action menu becomes a bottom sheet so it never overflows the viewport. */
- .qz-menu-backdrop { background: rgba(15, 23, 42, 0.4); }
- .qz-menu {
- position: fixed;
- inset: auto 0 0 0;
- top: auto;
- min-width: 0;
- border-radius: 16px 16px 0 0;
- padding: 8px 8px calc(8px + env(safe-area-inset-bottom));
- box-shadow: 0 -10px 34px rgba(0, 0, 0, 0.22);
- }
- .qz-menu-item { padding: 13px 14px; font-size: 0.92rem; }
-
- .qz-grid { grid-template-columns: 1fr; }
-}
-
-.qz-seemore { display: flex; gap: 8px; justify-content: center; flex-wrap: wrap; margin: 6px 0 4px; }
-@media (max-width: 640px) { .qz-seemore .btn { flex: 1; text-align: center; } }
diff --git a/frontend/src/pages/QuizzesPage.jsx b/frontend/src/pages/QuizzesPage.jsx
deleted file mode 100644
index fca26ac..0000000
--- a/frontend/src/pages/QuizzesPage.jsx
+++ /dev/null
@@ -1,548 +0,0 @@
-import { useState, useEffect, useRef, useMemo, lazy, Suspense } from 'react'
-import { useNavigate, Link } from 'react-router-dom'
-import { useAuth } from '../context/AuthContext'
-import api from '../api/client'
-import ConfirmButton from '../components/ConfirmButton'
-import Dialog from '../components/Dialog'
-import { useDialog } from '../hooks/useDialog'
-import './QuizzesPage.css'
-
-const TeachChat = lazy(() => import('../components/TeachChat'))
-import QuestionReadingLinks from '../components/QuestionReadingLinks'
-
-const dayLabel = (iso) => {
- if (!iso) return 'Earlier'
- const d = new Date(iso)
- if (Number.isNaN(d.getTime())) return 'Earlier'
- const today = new Date()
- const sameDay = (a, b) => a.toDateString() === b.toDateString()
- const yesterday = new Date(today)
- yesterday.setDate(today.getDate() - 1)
- if (sameDay(d, today)) return 'Today'
- if (sameDay(d, yesterday)) return 'Yesterday'
- return d.toLocaleDateString(undefined, { month: 'short', day: '2-digit', year: 'numeric' })
-}
-
-function HighlightText({ text, query }) {
- if (!query || !text) return {text}
- const idx = text.toLowerCase().indexOf(query.toLowerCase())
- if (idx === -1) return {text}
- return (
-
- {text.slice(0, idx)}
- {text.slice(idx, idx + query.length)}
- {text.slice(idx + query.length)}
-
- )
-}
-
-function QuestionStudyModal({ question, query, onClose }) {
- const [answered, setAnswered] = useState(null)
- return (
- <>
- e.target === e.currentTarget && onClose()}>
-
-
- Study Question
- ✕
-
-
-
-
- {question.image_path && (
-
-
e.target.style.display = 'none'} />
-
- )}
- {question.options && (
-
- {question.options.map((opt, i) => {
- const isSelected = answered === opt
- const hasAnswered = !!answered
- const isCorrectOpt = opt === question.correct_answer
- return (
-
!hasAnswered && setAnswered(opt)}
- style={{ cursor: hasAnswered ? 'default' : 'pointer' }}>
- {String.fromCharCode(65 + i)}
-
- {hasAnswered && isCorrectOpt && ✓ {isSelected ? 'Your answer' : 'Correct'} }
- {hasAnswered && isSelected && !isCorrectOpt && ✗ Wrong }
-
- )
- })}
-
- )}
- {answered && (question.explanation || question.explanation_image_path) && (
-
-
Explanation:
- {question.explanation &&
{question.explanation}
}
- {question.explanation_image_path && (
-
-
e.currentTarget.style.display = 'none'} />
-
- )}
-
- )}
-
- {answered && (
-
- setAnswered(null)}>Try again
- Close
-
- )}
-
-
- {/* AI Tutor — z-index above the modal */}
-
-
-
- >
- )
-}
-
-/* ── One management row: progress, primary action, and the action menu ── */
-function SessionRow({ row, isModerator, onChanged, onDelete }) {
- const [menuOpen, setMenuOpen] = useState(false)
- const [renaming, setRenaming] = useState(false)
- const [draftTitle, setDraftTitle] = useState(row.title)
- const [confirmDelete, setConfirmDelete] = useState(false)
- const [busy, setBusy] = useState(false)
- const [error, setError] = useState('')
-
- const canManage = isModerator || row.is_owner
- const total = row.total || row.questions_per_attempt || row.questions_count || 0
- const answered = Math.min(row.answered || 0, total)
- const percent = total > 0 ? Math.round((answered / total) * 100) : 0
- const done = row.state === 'completed'
-
- const primary = done
- ? { label: 'Review', to: `/results/${row.last_attempt_id}` }
- : row.state === 'in_progress'
- ? { label: 'Resume', to: `/quizzes/${row.quiz_id}` }
- : { label: 'Start', to: `/quizzes/${row.quiz_id}` }
-
- const closeMenu = () => setMenuOpen(false)
-
- const rename = async () => {
- const title = draftTitle.trim()
- if (!title || title === row.title) { setRenaming(false); return }
- setBusy(true); setError('')
- try {
- const res = await api.patch(`/quizzes/${row.quiz_id}`, { title })
- onChanged(row.quiz_id, { title: res.data.title })
- setRenaming(false)
- } catch (err) { setError(err.response?.data?.detail || 'Could not rename this test') }
- finally { setBusy(false) }
- }
-
- const toggleShare = async () => {
- closeMenu(); setBusy(true); setError('')
- try {
- const res = await api.patch(`/quizzes/${row.quiz_id}/share`, null, { params: { shared: row.is_shared !== 1 } })
- onChanged(row.quiz_id, { is_shared: res.data.is_shared, is_published: res.data.is_published })
- } catch (err) { setError(err.response?.data?.detail || 'Could not update sharing') }
- finally { setBusy(false) }
- }
-
- const togglePublish = async () => {
- closeMenu(); setBusy(true); setError('')
- try {
- const res = await api.patch(`/quizzes/${row.quiz_id}/publish`, null, { params: { published: row.is_published === 0 } })
- onChanged(row.quiz_id, { is_published: res.data.is_published, is_shared: res.data.is_shared })
- } catch (err) { setError(err.response?.data?.detail || 'Could not change visibility') }
- finally { setBusy(false) }
- }
-
- return (
-
-
-
-
- {row.mode === 'learning' ? 'Study mode' : 'Exam mode'}
-
- {row.is_published === 0 && Hidden }
- {row.is_shared === 1 && Shared }
- {row.category_name && {row.category_name} }
-
-
-
{row.title}
-
-
- {total} question{total !== 1 ? 's' : ''}
- {row.time_limit_minutes ? ` · ${row.time_limit_minutes} min` : ''}
- {row.attempts_count > 0 && ` · ${row.attempts_count} attempt${row.attempts_count !== 1 ? 's' : ''}`}
- {done && row.last_percentage !== null && ` · last score ${row.last_percentage}%`}
-
-
-
-
- {renaming && (
-
- setDraftTitle(e.target.value)}
- onKeyDown={e => { if (e.key === 'Enter') rename(); if (e.key === 'Escape') setRenaming(false) }} />
- Save
- { setRenaming(false); setDraftTitle(row.title) }}>Cancel
-
- )}
-
- {confirmDelete && (
-
- Move this test to Trash?
- { setConfirmDelete(false); onDelete(row.quiz_id) }}>Yes, move to Trash
- setConfirmDelete(false)}>Cancel
-
- )}
-
- {row.is_shared === 1 &&
Shared test link
}
- {error &&
{error}
}
-
-
-
-
{primary.label}
-
-
setMenuOpen(v => !v)}>⋯
- {menuOpen && (
- <>
-
-
-
{ if (!row.last_attempt_id) e.preventDefault(); else closeMenu() }}
- aria-disabled={!row.last_attempt_id}
- style={row.last_attempt_id ? undefined : { color: 'var(--text-subtle)', pointerEvents: 'none' }}>
- Analysis
-
-
- Repeat
-
- {canManage && (
-
{ closeMenu(); setDraftTitle(row.title); setRenaming(true) }}>Rename
- )}
- {canManage && (
-
- {row.is_shared === 1 ? 'Unshare test' : 'Share test'}
-
- )}
- {isModerator && <>
-
-
Edit questions
-
- {row.is_published === 0 ? 'Publish' : 'Hide from learners'}
-
- >}
- {canManage && <>
-
-
{ closeMenu(); setConfirmDelete(true) }}>Delete
- >}
-
- >
- )}
-
-
-
- )
-}
-
-function QuizCard({ quiz, isModerator, onOpenSessions }) {
- const navigate = useNavigate()
- return (
- navigate(`/quizzes/${quiz.quiz_id}`)}>
-
-
-
- {quiz.mode === 'learning' ? 'Study mode' : 'Exam mode'}
-
- {quiz.is_published === 0 && isModerator && Hidden }
-
-
e.stopPropagation()}>{quiz.title}
-
- {quiz.questions_per_attempt || quiz.questions_count} question{(quiz.questions_per_attempt || quiz.questions_count) !== 1 ? 's' : ''}
- {quiz.last_percentage !== null && quiz.last_percentage !== undefined && ` · last score ${quiz.last_percentage}%`}
-
-
e.stopPropagation()}>
- Manage in Sessions
-
-
-
- )
-}
-
-export default function QuizzesPage() {
- const [rows, setRows] = useState([])
- const [loading, setLoading] = useState(true)
- const [tab, setTab] = useState('sessions')
- const [stateFilter, setStateFilter] = useState('all')
- // The full history lives on the analysis page; this is a launcher.
- const SESSION_PREVIEW = 6
- const [searchQuery, setSearchQuery] = useState('')
- const [searchMode, setSearchMode] = useState('all')
- const [searchResults, setSearchResults] = useState(null)
- const [searching, setSearching] = useState(false)
- const [expandedSearch, setExpandedSearch] = useState(false)
- const [studyQuestion, setStudyQuestion] = useState(null)
- const { dialogProps, openAlert } = useDialog()
- const debounceRef = useRef(null)
- const { user } = useAuth()
- const navigate = useNavigate()
- const isModerator = user?.role === 'admin' || user?.role === 'moderator'
-
- useEffect(() => {
- api.get('/quizzes/sessions')
- .then(res => setRows(Array.isArray(res.data) ? res.data : [])).catch(console.error).finally(() => setLoading(false))
- }, [])
-
- useEffect(() => {
- clearTimeout(debounceRef.current)
- if (searchQuery.trim().length < 2) { setSearchResults(null); return }
- setSearching(true)
- debounceRef.current = setTimeout(async () => {
- try {
- const res = await api.get('/quizzes/search', { params: { q: searchQuery.trim(), mode: searchMode } })
- setSearchResults(res.data)
- } catch { setSearchResults([]) }
- finally { setSearching(false) }
- }, 350)
- return () => clearTimeout(debounceRef.current)
- }, [searchQuery, searchMode])
-
- const deleteQuiz = async (quizId) => {
- try {
- await api.delete(`/quizzes/${quizId}`)
- setRows(prev => prev.filter(r => r.quiz_id !== quizId))
- if (searchResults) setSearchResults(prev => prev.filter(r => r.quiz_id !== quizId))
- } catch (err) { console.error(err) }
- }
-
- const patchRow = (quizId, patch) =>
- setRows(prev => prev.map(r => (r.quiz_id === quizId ? { ...r, ...patch } : r)))
-
- const counts = useMemo(() => ({
- all: rows.length,
- in_progress: rows.filter(r => r.state === 'in_progress').length,
- completed: rows.filter(r => r.state === 'completed').length,
- not_started: rows.filter(r => r.state === 'not_started').length,
- }), [rows])
-
- const visibleRows = useMemo(
- () => (stateFilter === 'all' ? rows : rows.filter(r => r.state === stateFilter)),
- [rows, stateFilter],
- )
-
- const previewRows = useMemo(
- () => visibleRows.slice(0, SESSION_PREVIEW),
- [visibleRows],
- )
-
- const dayGroups = useMemo(() => {
- const groups = []
- for (const row of previewRows) {
- const label = dayLabel(row.last_activity)
- const last = groups[groups.length - 1]
- if (last && last.label === label) last.rows.push(row)
- else groups.push({ label, rows: [row] })
- }
- return groups
- }, [previewRows])
-
- // One list, newest first. Quiz categories were a second taxonomy beside the
- // real one and are gone; grouping by them left a heading over every test.
- const libraryRows = useMemo(
- () => [...rows].sort((a, b) => (b.created_at || '').localeCompare(a.created_at || '')),
- [rows])
-
- const isSearching = searchQuery.trim().length >= 2
- const allSearchQuestions = searchResults?.flatMap(r => r.matching_questions.map(q => ({ ...q, quiz_title: r.quiz_title }))) ?? []
-
- if (loading) return
-
- return (
-
-
- {studyQuestion && (
-
setStudyQuestion(null)} />
- )}
-
-
-
-
Your tests
-
Build a custom test from the question bank, then start, resume, review or repeat it from here.
-
-
-
-
-
-
- 🔍
- { setSearchQuery(e.target.value); setExpandedSearch(false) }} />
-
-
- {[['all', 'All'], ['title', 'Title only'], ['questions', 'Questions only']].map(([val, label]) => (
- setSearchMode(val)}>{label}
- ))}
-
-
-
- {isSearching && (
-
- {searching ? 'Searching…' : searchResults ? `${searchResults.length} test result${searchResults.length !== 1 ? 's' : ''}, ${allSearchQuestions.length} matching questions` : ''}
- {!searching && allSearchQuestions.length > 0 && (
- setExpandedSearch(v => !v)}>
- {expandedSearch ? 'Show summary' : `View all ${allSearchQuestions.length} questions →`}
-
- )}
-
- )}
-
- {/* Expanded full question search results */}
- {isSearching && expandedSearch && allSearchQuestions.length > 0 && (
-
-
- All matching questions for "{searchQuery}"
-
- {allSearchQuestions.map((q, i) => (
-
-
-
{q.quiz_title}
-
- 200 ? '…' : '')} query={searchQuery} />
-
-
-
setStudyQuestion(q)} style={{ flexShrink: 0 }}>Study
-
- ))}
-
- )}
-
- {/* Search summary results (compact) */}
- {isSearching && !expandedSearch && searchResults !== null && (
- searchResults.length === 0 ? (
- No matches for "{searchQuery}"
- ) : searchResults.map(result => (
-
-
0 ? 10 : 0 }}>
-
navigate(`/quizzes/${result.quiz_id}`)}>
-
-
-
-
- {result.questions_count} questions
- {result.match_type !== 'title' && ` · ${result.matching_questions.length} question match${result.matching_questions.length !== 1 ? 'es' : ''}`}
-
-
- {isModerator && (
-
deleteQuiz(result.quiz_id)} />
- )}
-
- {result.matching_questions.slice(0, 2).map(q => (
-
-
130 ? '…' : '')} query={searchQuery} />
-
setStudyQuestion({ ...q, quiz_title: result.quiz_title })} style={{ flexShrink: 0 }}>Study
-
- ))}
- {result.matching_questions.length > 2 && (
-
setExpandedSearch(true)}>
- +{result.matching_questions.length - 2} more — view all questions →
-
- )}
-
- ))
- )}
-
- {!isSearching && (
- <>
-
- setTab('sessions')}>
- Sessions{counts.all}
-
- setTab('library')}>
- Library{libraryRows.length}
-
-
-
- {tab === 'sessions' && (
- rows.length === 0 ? (
-
- No tests yet
- Create a custom test from the question bank to start studying.
-
- ) : (
- <>
-
- {[['all', 'All'], ['in_progress', 'In progress'], ['completed', 'Completed'], ['not_started', 'Not started']].map(([val, label]) => (
- setStateFilter(val)}>
- {label} ({counts[val]})
-
- ))}
-
-
- {visibleRows.length === 0 ? (
- Nothing here yet — try another filter.
- ) : dayGroups.map(group => (
-
-
{group.label}
-
- {group.rows.map(row => (
-
- ))}
-
-
- ))}
-
- {visibleRows.length > SESSION_PREVIEW && (
-
- {/* Expanding a list in place answers a smaller question than
- the one being asked. The history page has the filters. */}
-
- Full session history ({visibleRows.length}) →
-
-
- )}
-
- >
- )
- )}
-
- {tab === 'library' && (
- libraryRows.length === 0 ? (
- No tests to show yet.
- ) : (
-
- {libraryRows.map(row => (
- { setTab('sessions'); setStateFilter('all') }} />
- ))}
-
- )
- )}
-
- >
- )}
-
- )
-}
diff --git a/frontend/src/pages/QuizzesPage.test.jsx b/frontend/src/pages/QuizzesPage.test.jsx
deleted file mode 100644
index a7e6995..0000000
--- a/frontend/src/pages/QuizzesPage.test.jsx
+++ /dev/null
@@ -1,113 +0,0 @@
-import { render, screen, waitFor, within } from '@testing-library/react'
-import userEvent from '@testing-library/user-event'
-import { MemoryRouter } from 'react-router-dom'
-import { expect, it, vi, beforeEach } from 'vitest'
-import QuizzesPage from './QuizzesPage'
-import api from '../api/client'
-
-vi.mock('../api/client', () => ({ default: { get: vi.fn(), patch: vi.fn(), post: vi.fn(), delete: vi.fn() } }))
-vi.mock('../context/AuthContext', () => ({ useAuth: () => ({ user: { id: 1, role: 'user' } }) }))
-
-const session = (over = {}) => ({
- quiz_id: 10, title: 'My Test', mode: 'learning', origin: 'bank',
- questions_count: 20, questions_per_attempt: 20, time_limit_minutes: null,
- category_id: null, category_name: null, is_published: 0, is_shared: 0, is_owner: true,
- created_at: '2026-09-09T10:00:00', state: 'not_started', active_attempt_id: null,
- answered: 0, total: 20, attempts_count: 0, last_attempt_id: null, last_percentage: null,
- last_score: null, last_total: null, last_completed_at: null, best_percentage: null,
- last_activity: '2026-09-09T10:00:00', ...over,
-})
-
-const mockSessions = (rows) => {
- api.get.mockImplementation(url =>
- Promise.resolve({ data: url === '/quizzes/sessions' ? rows : [] }))
-}
-
-beforeEach(() => vi.clearAllMocks())
-
-it('offers creation, sharing and revocation for an owner, and displays server errors', async () => {
- mockSessions([session()])
- render( )
- expect(await screen.findByRole('link', { name: 'Create Custom Test' })).toHaveAttribute('href', '/quizzes/create')
-
- await userEvent.click(screen.getByRole('button', { name: 'Actions for My Test' }))
- api.patch.mockResolvedValueOnce({ data: { is_shared: 1, is_published: 0 } })
- await userEvent.click(screen.getByRole('menuitem', { name: 'Share test' }))
- expect(await screen.findByRole('link', { name: 'Shared test link' })).toHaveAttribute('href', '/quizzes/10')
- expect(api.patch).toHaveBeenLastCalledWith('/quizzes/10/share', null, { params: { shared: true } })
-
- await userEvent.click(screen.getByRole('button', { name: 'Actions for My Test' }))
- api.patch.mockResolvedValueOnce({ data: { is_shared: 0, is_published: 0 } })
- await userEvent.click(screen.getByRole('menuitem', { name: 'Unshare test' }))
- await waitFor(() => expect(screen.queryByRole('link', { name: 'Shared test link' })).not.toBeInTheDocument())
-
- await userEvent.click(screen.getByRole('button', { name: 'Actions for My Test' }))
- api.patch.mockRejectedValueOnce({ response: { data: { detail: 'This test contains private or course-only questions' } } })
- await userEvent.click(screen.getByRole('menuitem', { name: 'Share test' }))
- expect(await screen.findByRole('alert')).toHaveTextContent('private or course-only')
-})
-
-it('shows the right primary action and progress for each session state', async () => {
- mockSessions([
- session({ quiz_id: 1, title: 'Fresh', state: 'not_started', answered: 0, total: 20 }),
- session({ quiz_id: 2, title: 'Halfway', state: 'in_progress', answered: 4, total: 20, active_attempt_id: 55 }),
- session({
- quiz_id: 3, title: 'Finished', state: 'completed', answered: 20, total: 20,
- attempts_count: 1, last_attempt_id: 77, last_percentage: 75,
- }),
- ])
- render( )
-
- const fresh = (await screen.findByText('Fresh')).closest('.qz-session')
- expect(within(fresh).getByRole('link', { name: 'Start' })).toHaveAttribute('href', '/quizzes/1')
- expect(within(fresh).getByText('0/20')).toBeInTheDocument()
-
- const halfway = screen.getByText('Halfway').closest('.qz-session')
- expect(within(halfway).getByRole('link', { name: 'Resume' })).toHaveAttribute('href', '/quizzes/2')
- expect(within(halfway).getByText('4/20')).toBeInTheDocument()
-
- const finished = screen.getByText('Finished').closest('.qz-session')
- expect(within(finished).getByRole('link', { name: 'Review' })).toHaveAttribute('href', '/results/77')
- expect(within(finished).getByText(/last score 75%/)).toBeInTheDocument()
-})
-
-it('filters sessions by state', async () => {
- mockSessions([
- session({ quiz_id: 1, title: 'Fresh', state: 'not_started' }),
- session({ quiz_id: 2, title: 'Halfway', state: 'in_progress' }),
- ])
- render( )
- await screen.findByText('Fresh')
- await userEvent.click(screen.getByRole('button', { name: 'In progress (1)' }))
- expect(screen.queryByText('Fresh')).not.toBeInTheDocument()
- expect(screen.getByText('Halfway')).toBeInTheDocument()
-})
-
-it('offers Analysis and Repeat, and renames a test inline', async () => {
- mockSessions([session({ state: 'completed', last_attempt_id: 77, attempts_count: 1, answered: 20 })])
- render( )
- await userEvent.click(await screen.findByRole('button', { name: 'Actions for My Test' }))
- expect(screen.getByRole('menuitem', { name: 'Analysis' })).toHaveAttribute('href', '/results/77')
- expect(screen.getByRole('menuitem', { name: 'Repeat' })).toHaveAttribute('href', '/quizzes/10?restart=1')
-
- await userEvent.click(screen.getByRole('menuitem', { name: 'Rename' }))
- const input = screen.getByLabelText('Test name')
- await userEvent.clear(input)
- await userEvent.type(input, 'Renamed test')
- api.patch.mockResolvedValueOnce({ data: { title: 'Renamed test' } })
- await userEvent.click(screen.getByRole('button', { name: 'Save' }))
- expect(api.patch).toHaveBeenCalledWith('/quizzes/10', { title: 'Renamed test' })
- expect(await screen.findByText('Renamed test')).toBeInTheDocument()
-})
-
-it('deletes a test only after inline confirmation', async () => {
- mockSessions([session()])
- api.delete.mockResolvedValue({})
- render( )
- await userEvent.click(await screen.findByRole('button', { name: 'Actions for My Test' }))
- await userEvent.click(screen.getByRole('menuitem', { name: 'Delete' }))
- expect(api.delete).not.toHaveBeenCalled()
- await userEvent.click(screen.getByRole('button', { name: 'Yes, move to Trash' }))
- await waitFor(() => expect(api.delete).toHaveBeenCalledWith('/quizzes/10'))
- await waitFor(() => expect(screen.queryByText('My Test')).not.toBeInTheDocument())
-})
diff --git a/frontend/src/pages/ResultsPage.jsx b/frontend/src/pages/ResultsPage.jsx
index dc2a42d..568f150 100644
--- a/frontend/src/pages/ResultsPage.jsx
+++ b/frontend/src/pages/ResultsPage.jsx
@@ -13,8 +13,6 @@ export default function ResultsPage() {
const returnTo = searchParams.get('return_to')
const [result, setResult] = useState(location.state?.result || null)
const [loading, setLoading] = useState(!result)
- const [deleting, setDeleting] = useState(false)
- const [confirmDelete, setConfirmDelete] = useState(false)
const [reviewIndex, setReviewIndex] = useState(0)
const [tool, setTool] = useState(null)
const [responseStats, setResponseStats] = useState(null)
@@ -22,23 +20,11 @@ export default function ResultsPage() {
const isCourseQuiz = result?.course_id != null
const reviewAllowed = result?.allow_review !== false
- const deleteAttempt = async () => {
- if (!confirmDelete) { setConfirmDelete(true); return }
- setDeleting(true)
- try {
- await api.delete(`/attempts/${result.id}`)
- navigate(returnTo || '/quizzes')
- } catch {
- setDeleting(false)
- setConfirmDelete(false)
- }
- }
-
useEffect(() => {
if (!result) {
api.get(`/attempts/${id}`)
.then(res => setResult(res.data))
- .catch(() => navigate(returnTo || '/quizzes'))
+ .catch(() => navigate(returnTo || '/sessions'))
.finally(() => setLoading(false))
}
}, [id])
@@ -63,72 +49,33 @@ export default function ResultsPage() {
return (
{tool &&
setTool(null)} />}
- {/* Score card */}
-
-
-
{pct}%
-
- {correct} of {total} correct
-
-
- {pct >= 90 ? "Excellent — you've mastered this material." :
- pct >= 75 ? 'Good work! Review any missed questions below.' :
- pct >= 50 ? 'Getting there — study the explanations and retake.' :
- 'Review all explanations carefully before retaking.'}
-
+ {/* The score, the bands and "what next" are the analysis page's job.
+ Repeating them here gave the learner two different-looking verdicts on
+ the same attempt. This page is the answer-by-answer review, so it
+ opens with the way back to the analysis and nothing else.
- {/* Score bar */}
-
-
-
= 75 ? '#22c55e' : pct >= 50 ? '#f59e0b' : '#ef4444',
- }} />
+ A course quiz has no analysis page — there the score card stays. */}
+ {isCourseQuiz ? (
+
+
+
{pct}%
+
+ {correct} of {total} correct
-
- 0% 75% 100%
-
-
-
-
- {isCourseQuiz ? (
- <>
- {returnTo && (
-
Back to Course
- )}
- >
- ) : (
- <>
-
Retake Quiz
-
All Quizzes
-
Dashboard
- {confirmDelete ? (
- <>
-
- {deleting ? 'Deleting…' : 'Confirm Delete'}
-
-
setConfirmDelete(false)}>Cancel
- >
- ) : (
-
- Delete Attempt
-
- )}
- >
+ {returnTo && (
+
+ Back to Course
+
)}
-
+ ) : (
+
+ ← Session analysis
+
Answer review
+ {correct} of {total} correct · {pct}%
+
+ )}
{/* Question review — only if allowed */}
{reviewAllowed && result.answers && result.answers.length > 0 && (
@@ -159,7 +106,7 @@ export default function ResultsPage() {
const cardClass = ans.is_correct ? 'correct-card' : ans.user_answer ? 'wrong-card' : 'skipped-card'
return (
-
{ans.category_breadcrumbs?.length ? ans.category_breadcrumbs.map((category, index) => {index > 0 && ' › '} {category.name} ) : Uncategorized }
+
{ans.category_breadcrumbs?.length ? ans.category_breadcrumbs.map((category, index) => {index > 0 && ' › '} {category.name} ) : Uncategorized }
{/* Question header */}
diff --git a/frontend/src/pages/SessionsPage.css b/frontend/src/pages/SessionsPage.css
index 84bdefe..50df917 100644
--- a/frontend/src/pages/SessionsPage.css
+++ b/frontend/src/pages/SessionsPage.css
@@ -75,3 +75,5 @@
.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
index e428799..e92e475 100644
--- a/frontend/src/pages/SessionsPage.jsx
+++ b/frontend/src/pages/SessionsPage.jsx
@@ -33,10 +33,10 @@ const SORTS = [
/**
* Every session, in full, with the sidebar doing the narrowing.
*
- * The quizzes page shows a handful and stops. That is right for a landing
- * point and wrong for the question "what have I actually done" — which is what
- * this page answers, so nothing here is truncated and nothing needs a
- * "show all".
+ * 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([])
@@ -93,10 +93,16 @@ export default function SessionsPage() {
-
Session history
+
Sessions
Every test you have started or finished.
-
Quizzes
+ {/* 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
+
@@ -175,7 +181,7 @@ export default function SessionsPage() {
once it has been sat, the session itself otherwise.
It used to launch the test on click. */}
+ ? `/analysis/session/${row.last_attempt_id}` : `/sessions/${row.quiz_id}`}>
{row.title}
@@ -202,13 +208,13 @@ export default function SessionsPage() {
{row.state === 'in_progress' && (
- Resume
+ Resume
)}
{row.last_attempt_id && (
Review
)}
{row.state === 'not_started' && (
- Start
+ Start
)}
diff --git a/frontend/src/pages/StudyPlanPage.jsx b/frontend/src/pages/StudyPlanPage.jsx
index f946303..8e66ad5 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(`/quizzes/${res.data.id}`)
+ navigate(`/sessions/${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 c68d91a..41d31f4 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', '/quizzes/77')
+ expect(within(started).getByRole('link', { name: 'Review this block' })).toHaveAttribute('href', '/sessions/77')
const fresh = screen.getByText('Block 2').closest('.block')
api.post.mockResolvedValue({ data: { id: 91 } })