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