From ebb9e701ee0b72ab948bd9e2bfed19c63348533a Mon Sep 17 00:00:00 2001 From: Daniel Date: Fri, 11 Sep 2026 04:10:25 +0200 Subject: [PATCH] fix: one sessions list, at /sessions, with plan material out of it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The page at /quizzes showed the same fifteen rows twice — once under a "Sessions" tab as a list, once under a "Library" tab as cards — with nothing distinguishing them. The navbar carried the duplication too, with "Sessions" and "History" both pointing at the same page. Board Review I-XII already exist as study plans. The Library tab was showing the bulk quizzes those plans were built from, so the same twelve titles appeared in both systems. Those quizzes are now origin='plan': still real, still the parent of their questions via source_quiz_id, but no longer offered as something to pick off a list. Once a learner has actually sat one it is history, so the session list keeps it. - QuizzesPage deleted; /sessions is the only listing - /quizzes/* redirects to /sessions/*, preserving path and query - submitting a session lands on its analysis, not the old score page - the answer review drops its score hero, which the analysis owns and stated differently; a course quiz keeps its card, having no analysis - delete-attempt moves to the analysis page, where the session lives Backend 208/208, frontend 246/246. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN --- .../versions/b2c3d4e5f6a7_plan_origin.py | 47 ++ backend/app/models/quiz.py | 10 +- backend/app/routers/quizzes.py | 8 +- frontend/src/App.jsx | 24 +- .../src/components/CategoryPerformance.jsx | 4 +- frontend/src/components/ContinueStudy.jsx | 4 +- frontend/src/components/Navbar.jsx | 10 +- frontend/src/components/PractiseTopic.jsx | 2 +- .../src/components/PractiseTopic.test.jsx | 2 +- frontend/src/components/SiteFooter.jsx | 2 +- frontend/src/components/SiteFooter.test.jsx | 2 +- frontend/src/pages/AnalysisPage.jsx | 6 +- frontend/src/pages/AnalysisSessionPage.css | 11 + frontend/src/pages/AnalysisSessionPage.jsx | 45 +- frontend/src/pages/CourseDetailPage.jsx | 2 +- frontend/src/pages/CustomQuizPage.jsx | 4 +- frontend/src/pages/CustomQuizPage.test.jsx | 6 +- frontend/src/pages/DocumentDetailPage.jsx | 6 +- frontend/src/pages/JobsPage.jsx | 2 +- frontend/src/pages/LandingPage.jsx | 2 +- frontend/src/pages/PublicQuizPage.jsx | 2 +- frontend/src/pages/QuestionBankPage.jsx | 4 +- frontend/src/pages/QuizEditPage.jsx | 4 +- frontend/src/pages/QuizPage.jsx | 12 +- frontend/src/pages/QuizPage.test.jsx | 14 +- frontend/src/pages/QuizPlayer.css | 8 + frontend/src/pages/QuizzesPage.css | 266 --------- frontend/src/pages/QuizzesPage.jsx | 548 ------------------ frontend/src/pages/QuizzesPage.test.jsx | 113 ---- frontend/src/pages/ResultsPage.jsx | 101 +--- frontend/src/pages/SessionsPage.css | 2 + frontend/src/pages/SessionsPage.jsx | 24 +- frontend/src/pages/StudyPlanPage.jsx | 4 +- frontend/src/pages/StudyPlanPage.test.jsx | 2 +- 34 files changed, 236 insertions(+), 1067 deletions(-) create mode 100644 backend/alembic/versions/b2c3d4e5f6a7_plan_origin.py delete mode 100644 frontend/src/pages/QuizzesPage.css delete mode 100644 frontend/src/pages/QuizzesPage.jsx delete mode 100644 frontend/src/pages/QuizzesPage.test.jsx 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() { + onClick={() => navigate(`/sessions/create?adaptive=1&count=${adaptiveCount}`)}>Start adaptive session {main.length > 0 && ( )} diff --git a/frontend/src/components/ContinueStudy.jsx b/frontend/src/components/ContinueStudy.jsx index 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 ? ( + <> + + + + ) : ( + + )} +
    + {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 && ( - )} 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/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 ? ( - ) : ( 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 ) : ( )} - {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 && ( -
    - Question illustration 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 && ( -
    - Explanation illustration e.currentTarget.style.display = 'none'} /> -
    - )} -
    - )} - - {answered && ( -
    - - -
    - )} -
    -
    - {/* 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}%`} -
    - -
    -
    -
    -
    - {answered}/{total} -
    - - {renaming && ( -
    - setDraftTitle(e.target.value)} - onKeyDown={e => { if (e.key === 'Enter') rename(); if (e.key === 'Escape') setRenaming(false) }} /> - - -
    - )} - - {confirmDelete && ( -
    - Move this test to Trash? - - -
    - )} - - {row.is_shared === 1 &&
    Shared test link
    } - {error &&

    {error}

    } -
    - -
    - {primary.label} -
    - - {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 && ( - - )} - {canManage && ( - - )} - {isModerator && <> -
    - Edit questions - - } - {canManage && <> -
    - - } -
    - - )} -
    -
    -
    - ) -} - -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()}> - -
    -
    -
    - ) -} - -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
    Loading...
    - - return ( -
    - - {studyQuestion && ( - setStudyQuestion(null)} /> - )} - -
    -
    -

    Your tests

    -

    Build a custom test from the question bank, then start, resume, review or repeat it from here.

    -
    -
    - Create Custom Test - - Telegram bot - -
    -
    - -
    -
    - - { setSearchQuery(e.target.value); setExpandedSearch(false) }} /> -
    -
    - {[['all', 'All'], ['title', 'Title only'], ['questions', 'Questions only']].map(([val, label]) => ( - - ))} -
    -
    - - {isSearching && ( -
    - {searching ? 'Searching…' : searchResults ? `${searchResults.length} test result${searchResults.length !== 1 ? 's' : ''}, ${allSearchQuestions.length} matching questions` : ''} - {!searching && allSearchQuestions.length > 0 && ( - - )} -
    - )} - - {/* 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} /> -
    -
    - -
    - ))} -
    - )} - - {/* 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} />
    - -
    - ))} - {result.matching_questions.length > 2 && ( - - )} -
    - )) - )} - - {!isSearching && ( - <> -
    - - -
    - - {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]) => ( - - ))} -
    - - {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 ? ( - <> - - - - ) : ( - - )} - + {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 (
    - + {/* 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 } })