From c20b1e678f311570b90c0f29c282e073b7c2eb06 Mon Sep 17 00:00:00 2001 From: Daniel Date: Fri, 11 Sep 2026 19:31:13 +0200 Subject: [PATCH] feat: an objective menu, no unscoped choice, and pages that survive a deploy The switcher opened the whole picker on the first click. Now it opens a short menu: what you are studying for, the objectives you have been on lately (one click to switch back), and a way through to the full list. "All content" is gone. Studying for nothing in particular is not an objective, and the whole bank at once makes the filters and the analysis mean less rather than more. Saving requires an objective to be chosen. Suspending a session now lands on that session's analysis, where what has been answered so far is scored and Resume sits, rather than on a list of every session you own. A course quiz still returns to its course. lazyPage() replaces lazy() for every route and lazily-loaded component. Each build fingerprints the chunk filenames, so a tab still holding the previous index.html asks for a file the new image does not have and the user meets "This page failed to load" for a page that is fine. One reload per tab fetches the new index; a failure that survives the reload is shown, not looped. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN --- frontend/src/App.jsx | 87 ++++++------- frontend/src/components/ExamSwitcher.css | 42 ++++++- frontend/src/components/ExamSwitcher.jsx | 114 +++++++++++++++--- frontend/src/components/ExamSwitcher.test.jsx | 55 +++++++++ frontend/src/components/QuestionEditors.jsx | 5 +- frontend/src/components/QuestionPreview.jsx | 5 +- frontend/src/pages/CourseEditorPage.jsx | 5 +- frontend/src/pages/QuizPage.jsx | 14 ++- frontend/src/pages/QuizPage.test.jsx | 10 ++ frontend/src/utils/lazyPage.js | 48 ++++++++ frontend/src/utils/lazyPage.test.js | 45 +++++++ 11 files changed, 357 insertions(+), 73 deletions(-) create mode 100644 frontend/src/components/ExamSwitcher.test.jsx create mode 100644 frontend/src/utils/lazyPage.js create mode 100644 frontend/src/utils/lazyPage.test.js diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 960c809..7a0fdec 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,53 +1,54 @@ -import { lazy, Suspense } from 'react' +import { Suspense } from 'react' import { BrowserRouter, Routes, Route, Navigate, Outlet, useLocation, useParams } from 'react-router-dom' import { AuthProvider, useAuth } from './context/AuthContext' import { ThemeProvider } from './context/ThemeContext' import Navbar from './components/Navbar' import SiteFooter from './components/SiteFooter' import ErrorBoundary from './components/ErrorBoundary' +import lazyPage from './utils/lazyPage' -const LoginPage = lazy(() => import('./pages/LoginPage')) -const RegisterPage = lazy(() => import('./pages/RegisterPage')) -const DashboardPage = lazy(() => import('./pages/DashboardPage')) -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 ResultsPage = lazy(() => import('./pages/ResultsPage')) -const AdminPage = lazy(() => import('./pages/AdminPage')) -const AccountPage = lazy(() => import('./pages/AccountPage')) -const SettingsPage = lazy(() => import('./pages/SettingsPage')) -const QuestionBankPage = lazy(() => import('./pages/QuestionBankPage')) -const QuestionManagerPage = lazy(() => import('./pages/QuestionManagerPage')) -const CategoriesPage = lazy(() => import('./pages/CategoriesPage')) -const QuestionEditPage = lazy(() => import('./pages/QuestionEditPage')) -const AnalysisPage = lazy(() => import('./pages/AnalysisPage')) -const JobsPage = lazy(() => import('./pages/JobsPage')) -const TrashPage = lazy(() => import('./pages/TrashPage')) -const QuizEditPage = lazy(() => import('./pages/QuizEditPage')) -const VerifyEmailPage = lazy(() => import('./pages/VerifyEmailPage')) -const ForgotPasswordPage = lazy(() => import('./pages/ForgotPasswordPage')) -const ResetPasswordPage = lazy(() => import('./pages/ResetPasswordPage')) -const NotFoundPage = lazy(() => import('./pages/NotFoundPage')) -const LandingPage = lazy(() => import('./pages/LandingPage')) -const FlashcardsPage = lazy(() => import('./pages/FlashcardsPage')) -const ArticlesPage = lazy(() => import('./pages/ArticlesPage')) -const SearchPage = lazy(() => import('./pages/SearchPage')) -const AnalysisSessionPage = lazy(() => import('./pages/AnalysisSessionPage')) -const AiModePage = lazy(() => import('./pages/AiModePage')) -const MediaPage = lazy(() => import('./pages/MediaPage')) -const EditorialPage = lazy(() => import('./pages/EditorialPage')) -const AccessPage = lazy(() => import('./pages/AccessPage')) -const StudyPlansPage = lazy(() => import('./pages/StudyPlansPage')) -const StudyPlanPage = lazy(() => import('./pages/StudyPlanPage')) -const StudyPlanBlockPage = lazy(() => import('./pages/StudyPlanBlockPage')) -const ArticlePage = lazy(() => import('./pages/ArticlesPage').then(m => ({ default: m.ArticlePage }))) -const PublicQuizPage = lazy(() => import('./pages/PublicQuizPage')) -const FlashcardStudyPage = lazy(() => import('./pages/FlashcardStudyPage')) -const CoursesPage = lazy(() => import('./pages/CoursesPage')) -const CourseDetailPage = lazy(() => import('./pages/CourseDetailPage')) -const CourseEditorPage = lazy(() => import('./pages/CourseEditorPage')) -const SsoCallbackPage = lazy(() => import('./pages/SsoCallbackPage')) +const LoginPage = lazyPage(() => import('./pages/LoginPage')) +const RegisterPage = lazyPage(() => import('./pages/RegisterPage')) +const DashboardPage = lazyPage(() => import('./pages/DashboardPage')) +const UploadPage = lazyPage(() => import('./pages/UploadPage')) +const DocumentDetailPage = lazyPage(() => import('./pages/DocumentDetailPage')) +const QuizPage = lazyPage(() => import('./pages/QuizPage')) +const CustomQuizPage = lazyPage(() => import('./pages/CustomQuizPage')) +const ResultsPage = lazyPage(() => import('./pages/ResultsPage')) +const AdminPage = lazyPage(() => import('./pages/AdminPage')) +const AccountPage = lazyPage(() => import('./pages/AccountPage')) +const SettingsPage = lazyPage(() => import('./pages/SettingsPage')) +const QuestionBankPage = lazyPage(() => import('./pages/QuestionBankPage')) +const QuestionManagerPage = lazyPage(() => import('./pages/QuestionManagerPage')) +const CategoriesPage = lazyPage(() => import('./pages/CategoriesPage')) +const QuestionEditPage = lazyPage(() => import('./pages/QuestionEditPage')) +const AnalysisPage = lazyPage(() => import('./pages/AnalysisPage')) +const JobsPage = lazyPage(() => import('./pages/JobsPage')) +const TrashPage = lazyPage(() => import('./pages/TrashPage')) +const QuizEditPage = lazyPage(() => import('./pages/QuizEditPage')) +const VerifyEmailPage = lazyPage(() => import('./pages/VerifyEmailPage')) +const ForgotPasswordPage = lazyPage(() => import('./pages/ForgotPasswordPage')) +const ResetPasswordPage = lazyPage(() => import('./pages/ResetPasswordPage')) +const NotFoundPage = lazyPage(() => import('./pages/NotFoundPage')) +const LandingPage = lazyPage(() => import('./pages/LandingPage')) +const FlashcardsPage = lazyPage(() => import('./pages/FlashcardsPage')) +const ArticlesPage = lazyPage(() => import('./pages/ArticlesPage')) +const SearchPage = lazyPage(() => import('./pages/SearchPage')) +const AnalysisSessionPage = lazyPage(() => import('./pages/AnalysisSessionPage')) +const AiModePage = lazyPage(() => import('./pages/AiModePage')) +const MediaPage = lazyPage(() => import('./pages/MediaPage')) +const EditorialPage = lazyPage(() => import('./pages/EditorialPage')) +const AccessPage = lazyPage(() => import('./pages/AccessPage')) +const StudyPlansPage = lazyPage(() => import('./pages/StudyPlansPage')) +const StudyPlanPage = lazyPage(() => import('./pages/StudyPlanPage')) +const StudyPlanBlockPage = lazyPage(() => import('./pages/StudyPlanBlockPage')) +const ArticlePage = lazyPage(() => import('./pages/ArticlesPage').then(m => ({ default: m.ArticlePage }))) +const PublicQuizPage = lazyPage(() => import('./pages/PublicQuizPage')) +const FlashcardStudyPage = lazyPage(() => import('./pages/FlashcardStudyPage')) +const CoursesPage = lazyPage(() => import('./pages/CoursesPage')) +const CourseDetailPage = lazyPage(() => import('./pages/CourseDetailPage')) +const CourseEditorPage = lazyPage(() => import('./pages/CourseEditorPage')) +const SsoCallbackPage = lazyPage(() => import('./pages/SsoCallbackPage')) function LoadingFallback() { return
diff --git a/frontend/src/components/ExamSwitcher.css b/frontend/src/components/ExamSwitcher.css index a1db30d..796886a 100644 --- a/frontend/src/components/ExamSwitcher.css +++ b/frontend/src/components/ExamSwitcher.css @@ -19,6 +19,47 @@ } @media (max-width: 900px) { .exam-switcher-label { display: none; } } +/* ── The short menu ─────────────────────────────────────────────────── + What you are on, where you have been lately, and a way to the full + list — so changing objective does not start with a full-screen dialog. */ +.exam-switcher { position: relative; display: inline-flex; } +.exm { + position: absolute; top: calc(100% + 6px); left: 0; z-index: 1090; + min-width: 260px; max-width: min(320px, calc(100vw - 24px)); + padding: 6px; background: var(--card-bg); + border: 1px solid var(--border); border-radius: 10px; + box-shadow: 0 14px 34px rgba(15, 23, 42, 0.18); +} +.exm-current { padding: 9px 11px 11px; border-bottom: 1px solid var(--border); } +.exm-current small { + display: block; font-size: 0.62rem; font-weight: 700; letter-spacing: 0.07em; + text-transform: uppercase; color: var(--text-subtle); +} +.exm-current strong { display: block; margin-top: 3px; font-size: 0.9rem; font-weight: 650; } +.exm-heading { + margin: 8px 0 2px; padding: 0 11px; + font-size: 0.62rem; font-weight: 700; letter-spacing: 0.07em; + text-transform: uppercase; color: var(--text-subtle); +} +.exm-item { + display: block; width: 100%; padding: 8px 11px; + font: inherit; font-size: 0.86rem; text-align: left; cursor: pointer; + background: none; border: 0; border-radius: 7px; color: var(--text); +} +.exm-item:hover:not(:disabled) { background: var(--bg); } +.exm-item:disabled { opacity: 0.6; cursor: default; } +.exm-item small { display: block; margin-top: 1px; font-size: 0.74rem; color: var(--text-muted); } +.exm-item.is-more { + margin-top: 4px; border-top: 1px solid var(--border); border-radius: 0 0 7px 7px; + color: var(--primary); font-weight: 600; +} +.exm-error { margin: 6px 11px 2px; font-size: 0.78rem; color: var(--wrong-fg); } + +/* On a phone the menu is the width of the screen rather than of the button. */ +@media (max-width: 520px) { + .exm { left: auto; right: 0; min-width: 240px; } +} + /* ── The dialog ───────────────────────────────────────────────────── */ .exo-overlay { position: fixed; inset: 0; z-index: 1100; display: flex; @@ -67,7 +108,6 @@ .exo-option input { width: 18px; height: 18px; margin-top: 1px; flex-shrink: 0; } .exo-option strong { display: block; font-size: 0.92rem; font-weight: 650; } .exo-option small { display: block; margin-top: 2px; font-size: 0.78rem; color: var(--text-muted); } -.exo-option.is-all { margin-top: 8px; } /* Shown, so an educator can see it exists; not selectable, because choosing it would scope the bank to nothing. */ .exo-option.is-empty { opacity: 0.55; cursor: not-allowed; } diff --git a/frontend/src/components/ExamSwitcher.jsx b/frontend/src/components/ExamSwitcher.jsx index 0892d84..287e836 100644 --- a/frontend/src/components/ExamSwitcher.jsx +++ b/frontend/src/components/ExamSwitcher.jsx @@ -14,16 +14,40 @@ import './ExamSwitcher.css' * and confirmed rather than applied the instant the pointer passes over an * option. The choice is stored on the user, so it follows them between * devices rather than living in this browser. + * + * The button opens a short menu first: what you are studying for now, the ones + * you have been on lately, and a way through to the full picker. Switching + * back to last week's objective is one click; the whole list is only opened + * when you actually want the whole list. */ +const RECENT_KEY = 'pedshub.recentObjectives' +const RECENT_MAX = 3 + +// A per-browser convenience, not a record: if it is missing or unreadable the +// menu is simply shorter. +function readRecent() { + try { return JSON.parse(localStorage.getItem(RECENT_KEY) || '[]').filter(Number.isInteger) } + catch { return [] } +} + +function rememberRecent(id) { + if (!Number.isInteger(id)) return + try { + const next = [id, ...readRecent().filter(x => x !== id)].slice(0, RECENT_MAX + 1) + localStorage.setItem(RECENT_KEY, JSON.stringify(next)) + } catch { /* private browsing, or storage turned off */ } +} export default function ExamSwitcher({ onChange }) { const [exams, setExams] = useState([]) const [activeId, setActiveId] = useState(null) + const [menu, setMenu] = useState(false) const [open, setOpen] = useState(false) const [chosen, setChosen] = useState(null) const [query, setQuery] = useState('') const [busy, setBusy] = useState(false) const [error, setError] = useState('') const search = useRef(null) + const wrap = useRef(null) const load = () => api.get('/exams/') .then(res => { setExams(res.data.exams || []); setActiveId(res.data.active_exam_id ?? null) }) @@ -31,6 +55,18 @@ export default function ExamSwitcher({ onChange }) { useEffect(() => { load() }, []) + useEffect(() => { + if (!menu) return undefined + const away = e => { if (!wrap.current?.contains(e.target)) setMenu(false) } + const onKey = e => { if (e.key === 'Escape') setMenu(false) } + document.addEventListener('mousedown', away) + document.addEventListener('keydown', onKey) + return () => { + document.removeEventListener('mousedown', away) + document.removeEventListener('keydown', onKey) + } + }, [menu]) + useEffect(() => { if (!open) return undefined setChosen(activeId) @@ -55,28 +91,73 @@ export default function ExamSwitcher({ onChange }) { return [...groups.entries()] }, [exams, query]) - const save = async () => { + const apply = async (examId) => { setBusy(true); setError('') try { - await api.put('/exams/active', { exam_id: chosen }) - setActiveId(chosen) + await api.put('/exams/active', { exam_id: examId }) + rememberRecent(examId) + setActiveId(examId) setOpen(false) - onChange?.(chosen) + setMenu(false) + onChange?.(examId) } catch { setError('Could not change your study objective') } finally { setBusy(false) } } if (exams.length === 0) return null const active = exams.find(e => e.id === activeId) + // Lately, minus where you are now, and minus anything that has since been + // emptied or withdrawn. + const recent = readRecent() + .filter(id => id !== activeId) + .map(id => exams.find(e => e.id === id)) + .filter(e => e && e.question_count > 0) + .slice(0, RECENT_MAX) return ( - <> - + {menu && ( +
+ {active ? ( +
+ Current objective + {active.name} +
+ ) : ( +
+ No objective set + Everything is in scope until you pick one +
+ )} + + {recent.length > 0 && ( + <> +

Recently

+ {recent.map(exam => ( + + ))} + + )} + + + {error && !open &&

{error}

} +
+ )} + {open && (
e.target === e.currentTarget && setOpen(false)}>
@@ -94,15 +175,9 @@ export default function ExamSwitcher({ onChange }) { onChange={e => setQuery(e.target.value)} />
- - + {/* There is no unscoped choice. Studying for nothing in + particular is not an objective, and the whole bank at once + makes the filters and the analysis mean less, not more. */} {families.map(([family, list]) => (

{family}

@@ -133,12 +208,13 @@ export default function ExamSwitcher({ onChange }) { {error &&

{error}

}
- +
)} - +
) } diff --git a/frontend/src/components/ExamSwitcher.test.jsx b/frontend/src/components/ExamSwitcher.test.jsx new file mode 100644 index 0000000..76df576 --- /dev/null +++ b/frontend/src/components/ExamSwitcher.test.jsx @@ -0,0 +1,55 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { render, screen, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import ExamSwitcher from './ExamSwitcher' +import api from '../api/client' + +vi.mock('../api/client', () => ({ default: { get: vi.fn(), put: vi.fn() } })) + +const EXAMS = [ + { id: 1, name: 'Pediatrics Boards', family: 'Boards', question_count: 2948 }, + { id: 2, name: 'Shelf – Psychiatry', family: 'Shelf', question_count: 140 }, + { id: 3, name: 'USMLE Step 2 CK', family: 'USMLE', question_count: 0 }, +] + +beforeEach(() => { + vi.clearAllMocks() + localStorage.clear() + api.get.mockResolvedValue({ data: { exams: EXAMS, active_exam_id: 1 } }) + api.put.mockResolvedValue({ data: {} }) +}) + +const openMenu = async () => { + render() + await userEvent.click(await screen.findByRole('button', { name: /Pediatrics Boards/ })) +} + +describe('ExamSwitcher', () => { + it('opens a short menu before the full picker', async () => { + await openMenu() + const menu = screen.getByRole('menu') + expect(within(menu).getByText('Current objective')).toBeInTheDocument() + expect(within(menu).getByText('Pediatrics Boards')).toBeInTheDocument() + // The whole list is a step further in, not the first thing you meet. + expect(screen.queryByRole('dialog')).toBeNull() + await userEvent.click(within(menu).getByRole('menuitem', { name: 'Choose a new study objective' })) + expect(await screen.findByRole('dialog', { name: 'Current study objective' })).toBeInTheDocument() + }) + + it('switches straight back to one you were on lately', async () => { + localStorage.setItem('pedshub.recentObjectives', JSON.stringify([2])) + await openMenu() + const menu = screen.getByRole('menu') + await userEvent.click(within(menu).getByRole('menuitem', { name: /Shelf – Psychiatry/ })) + expect(api.put).toHaveBeenCalledWith('/exams/active', { exam_id: 2 }) + }) + + it('offers no unscoped choice, and no objective with nothing behind it', async () => { + await openMenu() + await userEvent.click(screen.getByRole('menuitem', { name: 'Choose a new study objective' })) + const dialog = await screen.findByRole('dialog') + // Studying for nothing in particular is not an objective. + expect(within(dialog).queryByText('All content')).toBeNull() + expect(within(dialog).getByRole('radio', { name: /USMLE Step 2 CK/ })).toBeDisabled() + }) +}) diff --git a/frontend/src/components/QuestionEditors.jsx b/frontend/src/components/QuestionEditors.jsx index 7cd2c3e..652d9b3 100644 --- a/frontend/src/components/QuestionEditors.jsx +++ b/frontend/src/components/QuestionEditors.jsx @@ -1,9 +1,10 @@ /* Question authoring modals shared by the question bank and the question manager. */ -import { useState, useEffect, lazy, Suspense } from 'react' +import { useState, useEffect, Suspense } from 'react' +import lazyPage from '../utils/lazyPage' import api from '../api/client' import CategoryTree from './CategoryTree' -const RichEditor = lazy(() => import('../components/RichEditor')) +const RichEditor = lazyPage(() => import('../components/RichEditor')) const LETTERS = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H'] diff --git a/frontend/src/components/QuestionPreview.jsx b/frontend/src/components/QuestionPreview.jsx index 8cf7ab9..a253595 100644 --- a/frontend/src/components/QuestionPreview.jsx +++ b/frontend/src/components/QuestionPreview.jsx @@ -1,10 +1,11 @@ -import { Suspense, lazy } from 'react' +import { Suspense } from 'react' +import lazyPage from '../utils/lazyPage' import { Link } from 'react-router-dom' import RichText from './RichText' import QuestionReadingLinks from './QuestionReadingLinks' import { uploadUrl } from '../utils/uploads' -const TeachChat = lazy(() => import('./TeachChat')) +const TeachChat = lazyPage(() => import('./TeachChat')) /** * One question, shown whole, without leaving the page you found it on. diff --git a/frontend/src/pages/CourseEditorPage.jsx b/frontend/src/pages/CourseEditorPage.jsx index 2083c5a..d337457 100644 --- a/frontend/src/pages/CourseEditorPage.jsx +++ b/frontend/src/pages/CourseEditorPage.jsx @@ -1,10 +1,11 @@ -import { useState, useEffect, useCallback, useRef, lazy, Suspense } from 'react' +import { useState, useEffect, useCallback, useRef, Suspense } from 'react' +import lazyPage from '../utils/lazyPage' import { useParams, useNavigate } from 'react-router-dom' import { useAuth } from '../context/AuthContext' import api from '../api/client' import ConfirmButton from '../components/ConfirmButton' -const RichEditor = lazy(() => import('../components/RichEditor')) +const RichEditor = lazyPage(() => import('../components/RichEditor')) function stripHtml(html) { if (!html) return '' diff --git a/frontend/src/pages/QuizPage.jsx b/frontend/src/pages/QuizPage.jsx index d6b461d..0ca8ece 100644 --- a/frontend/src/pages/QuizPage.jsx +++ b/frontend/src/pages/QuizPage.jsx @@ -1,6 +1,7 @@ import { uploadUrl } from '../utils/uploads' import QuestionReadingLinks from '../components/QuestionReadingLinks' -import { useState, useEffect, useRef, useCallback, lazy, Suspense } from 'react' +import { useState, useEffect, useRef, useCallback, Suspense } from 'react' +import lazyPage from '../utils/lazyPage' import { useParams, useNavigate, useSearchParams, Link } from 'react-router-dom' import RichText from '../components/RichText' import { mergeTextRanges } from '../utils/highlightOffsets' @@ -14,7 +15,7 @@ import '../components/Feedback.css' import QuizTools, { QuizDialog } from '../components/QuizTools' import './QuizPlayer.css' -const TeachChat = lazy(() => import('../components/TeachChat')) +const TeachChat = lazyPage(() => import('../components/TeachChat')) const OPTION_LETTERS = ['A', 'B', 'C', 'D', 'E', 'F'] const QUESTION_HIGHLIGHT_WORDS = 8 @@ -508,6 +509,11 @@ export default function QuizPage() { }, [id, manualHighlights]) const [leaveTarget, setLeaveTarget] = useState(null) + + // Suspending is not abandoning: it ends on the session's own analysis, where + // what has been answered so far is scored and the Resume button sits. A + // course quiz still returns to the course it belongs to. + const exitTarget = () => returnTo || (attemptId ? `/sessions/${attemptId}` : '/') const questions = quiz?.questions || [] const current = questions[currentIdx] const isStudy = quizMode === 'study' @@ -1240,7 +1246,7 @@ const timerStarted = timeLeft !== null
{timeLeft !== null && } - {restartConfirm ? ( @@ -1638,7 +1644,7 @@ const timerStarted = timeLeft !== null scrolls inside it, rather than the whole page scrolling. */}
+ onClick={() => setLeaveTarget(exitTarget())}>Exit session {quizNavigation('bottom')} {answeredCount > 0 && (