diff --git a/backend/app/routers/study_tools.py b/backend/app/routers/study_tools.py index ea43b77..93d774b 100644 --- a/backend/app/routers/study_tools.py +++ b/backend/app/routers/study_tools.py @@ -552,6 +552,7 @@ def study_recommendations( # ── What the learner has answered ────────────────────────────── answered_rows = db.query( AttemptAnswer.question_id, AttemptAnswer.is_correct, Question.question_category_id, + AttemptAnswer.used_hint, ).join(QuizAttempt, QuizAttempt.id == AttemptAnswer.attempt_id ).join(Quiz, Quiz.id == QuizAttempt.quiz_id ).join(Question, Question.id == AttemptAnswer.question_id @@ -571,15 +572,20 @@ def study_recommendations( answered: dict[int, int] = defaultdict(int) correct: dict[int, int] = defaultdict(int) + # Right after opening a tip. Counted as correct, because it was, and kept + # apart so a topic can show how much of its score leaned on one. + hinted: dict[int, int] = defaultdict(int) seen_questions: dict[int, set[int]] = defaultdict(set) total_answers = len(answered_rows) - total_correct = sum(1 for _, is_correct, _ in answered_rows if is_correct) - for question_id, is_correct, primary in answered_rows: + total_correct = sum(1 for _, is_correct, _, _ in answered_rows if is_correct) + for question_id, is_correct, primary, used_hint in answered_rows: for key in groups_for(question_id, primary): answered[key] += 1 seen_questions[key].add(question_id) if is_correct: correct[key] += 1 + if used_hint: + hinted[key] += 1 # ── How much bank material each group holds ──────────────────── available: dict[int, int] = defaultdict(int) @@ -667,6 +673,7 @@ def study_recommendations( "key": key, "answered": n, "correct": c, + "correct_with_hints": hinted.get(key, 0), "seen_questions": seen, "available": pool, "coverage": coverage, diff --git a/docker-compose.yml b/docker-compose.yml index a1e3747..3be721d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -80,10 +80,14 @@ services: # so a flush of one cannot clear the other's challenges. cap: image: tiago2/cap:latest + ports: + # Its own host, so other sites on this machine can use it too — which is + # the point of self-hosting it rather than pathing it under one app. + - "127.0.0.1:8093:3000" environment: ADMIN_KEY: ${CAP_ADMIN_KEY} REDIS_URL: redis://redis:6379/3 - CORS_ORIGIN: ${APP_URL:-https://pedshub.com} + CORS_ORIGIN: ${CAP_CORS_ORIGIN:-https://pedshub.com} SERVER_PORT: 3000 depends_on: - redis diff --git a/frontend/docker-entrypoint.sh b/frontend/docker-entrypoint.sh index ee6f53d..4ba48e9 100755 --- a/frontend/docker-entrypoint.sh +++ b/frontend/docker-entrypoint.sh @@ -3,7 +3,8 @@ # This avoids baking secrets/keys into the Docker image at build time. cat > /usr/share/nginx/html/config.js </challenge and answering NOT_FOUND. - rewrite ^/cap/(.*)$ /$1 break; - proxy_pass $cap; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - } - # API proxy to backend location /api/ { resolver 127.0.0.11 valid=10s; diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index f2ec8c7..0d38240 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -5,6 +5,7 @@ import { SessionDrawerProvider } from './context/SessionDrawer' import { ThemeProvider } from './context/ThemeContext' import Navbar from './components/Navbar' import SiteFooter from './components/SiteFooter' +import ChooseObjective from './components/ChooseObjective' import ErrorBoundary from './components/ErrorBoundary' import lazyPage from './utils/lazyPage' @@ -66,6 +67,10 @@ function AppLayout() { is a spinner, and the footer used to sit halfway up with the page background showing beneath it. */
+ {/* Asked of anybody who has never answered it. No objective quietly + means the whole bank, which is a reasonable default and a poor thing + to arrive at without being asked. */} +
diff --git a/frontend/src/components/Captcha.jsx b/frontend/src/components/Captcha.jsx index 6f02671..0580923 100644 --- a/frontend/src/components/Captcha.jsx +++ b/frontend/src/components/Captcha.jsx @@ -22,6 +22,11 @@ export function captchaSiteKey() { return window.__APP_CONFIG__?.CAP_SITE_KEY || '' } +/** Where Cap answers. Its own host, so other sites can use the same one. */ +export function captchaOrigin() { + return window.__APP_CONFIG__?.CAP_API_URL || '' +} + /** * The captcha on the forms strangers can reach — sign-up and contact. * @@ -71,12 +76,11 @@ export default function Captcha({ onVerify }) { const siteKey = captchaSiteKey() if (!siteKey) return null - // Same origin, proxied to the cap service by nginx. The custom element is - // rendered whether or not its script has arrived yet; it upgrades itself - // when the definition lands, and until then it is an empty box and the form - // stays unsubmittable, which is the honest outcome. + // The custom element is rendered whether or not its script has arrived yet; + // it upgrades itself when the definition lands, and until then it is an + // empty box and the form stays unsubmittable, which is the honest outcome. return ( + data-cap-api-endpoint={`${captchaOrigin().replace(/\/$/, '')}/${siteKey}/`} /> ) } diff --git a/frontend/src/components/Captcha.test.jsx b/frontend/src/components/Captcha.test.jsx index 6f8585e..439c4ab 100644 --- a/frontend/src/components/Captcha.test.jsx +++ b/frontend/src/components/Captcha.test.jsx @@ -6,7 +6,10 @@ const script = () => document.getElementById('cap-widget-script') const widget = () => document.querySelector('cap-widget') beforeEach(() => { - window.__APP_CONFIG__ = { CAP_SITE_KEY: 'configured-test-site-key' } + window.__APP_CONFIG__ = { + CAP_SITE_KEY: 'configured-test-site-key', + CAP_API_URL: 'https://cap.example.test', + } }) afterEach(() => { delete window.__APP_CONFIG__ @@ -30,12 +33,13 @@ it('loads the pinned widget once, however many are on the page', () => { expect(script().src).toBe('https://cdn.jsdelivr.net/npm/@cap.js/widget@0.1.56') }) -it('points the widget at this origin, not at anybody else', () => { +it('points the widget at our own Cap, not at anybody else', () => { render() - // Same-origin through nginx, which is the whole reason for self-hosting: - // nothing about the person signing up reaches a third party. + // Our host, which is the whole reason for self-hosting: nothing about the + // person signing up reaches a third party. Its own subdomain rather than a + // path under this app, so other sites can use the same one. expect(widget().getAttribute('data-cap-api-endpoint')) - .toBe('/cap/configured-test-site-key/') + .toBe('https://cap.example.test/configured-test-site-key/') }) it('hands the solved token up', () => { diff --git a/frontend/src/components/ChooseObjective.css b/frontend/src/components/ChooseObjective.css new file mode 100644 index 0000000..8fda2bd --- /dev/null +++ b/frontend/src/components/ChooseObjective.css @@ -0,0 +1,32 @@ +.co-overlay { + position: fixed; inset: 0; z-index: 200; display: grid; place-items: center; + padding: 20px; background: rgba(15, 23, 42, 0.55); +} +.co-card { + width: min(520px, 100%); max-height: 90vh; overflow-y: auto; + padding: 26px 24px; border-radius: 16px; + background: var(--card-bg); border: 1px solid var(--border); + box-shadow: 0 24px 60px rgba(15, 23, 42, 0.3); +} +.co-card h2 { margin: 0 0 8px; font-size: 1.3rem; font-weight: 700; } +.co-lead { margin: 0 0 18px; font-size: 0.88rem; line-height: 1.6; color: var(--text-muted); } +.co-error { margin: 0 0 12px; font-size: 0.85rem; color: var(--wrong-fg); } + +.co-list { list-style: none; margin: 0 0 16px; padding: 0; display: flex; flex-direction: column; gap: 8px; } +.co-list button { + display: flex; flex-direction: column; gap: 3px; width: 100%; + padding: 13px 15px; text-align: left; cursor: pointer; + background: var(--bg); color: var(--text); + border: 1px solid var(--border); border-radius: 11px; font: inherit; +} +.co-list button:hover:not(:disabled) { border-color: var(--primary); background: var(--option-sel-bg); } +.co-list button:disabled { opacity: 0.6; cursor: default; } +.co-list strong { font-size: 0.95rem; font-weight: 650; } +.co-list small { font-size: 0.78rem; color: var(--text-muted); } + +/* Quieter than the choices, because it is one — but not the one being urged. */ +.co-later { + width: 100%; padding: 10px; font: inherit; font-size: 0.84rem; cursor: pointer; + background: none; color: var(--text-muted); border: 0; +} +.co-later:hover:not(:disabled) { color: var(--primary); text-decoration: underline; } diff --git a/frontend/src/components/ChooseObjective.jsx b/frontend/src/components/ChooseObjective.jsx new file mode 100644 index 0000000..57ae83d --- /dev/null +++ b/frontend/src/components/ChooseObjective.jsx @@ -0,0 +1,97 @@ +import { useEffect, useState } from 'react' +import api from '../api/client' +import './ChooseObjective.css' + +const DEFERRED = 'pedshub.objectiveDeferred' + +/** + * Asked once, of anybody who has not answered it. + * + * The study objective decides which questions exist, how relevance is + * weighted, and what the analysis is measuring against — and it was possible + * to sit a whole board paper without ever being asked for one, because no + * objective quietly means "the entire bank". That is a reasonable default and + * a terrible thing to arrive at by accident. + * + * So it is a question rather than a setting to discover. It can be declined — + * "everything" is a real answer, and trapping somebody behind a modal because + * a list failed to load would be worse than the gap it closes — but declining + * is a choice made, which is the whole point. + */ +export default function ChooseObjective() { + const [exams, setExams] = useState(null) + const [busy, setBusy] = useState(false) + const [error, setError] = useState('') + const [dismissed, setDismissed] = useState(() => { + try { return sessionStorage.getItem(DEFERRED) === 'true' } catch { return false } + }) + + useEffect(() => { + if (dismissed) return undefined + let live = true + api.get('/exams/') + .then(res => { + if (!live) return + // Already answered: nothing to ask. + if (res.data?.active_exam_id) { setExams([]); return } + setExams(res.data?.exams || []) + }) + .catch(() => { if (live) setExams([]) }) + return () => { live = false } + }, [dismissed]) + + const choose = async (examId) => { + setBusy(true); setError('') + try { + await api.put('/exams/active', { exam_id: examId }) + // A hard reload rather than a state update: the objective scopes almost + // every query on the page, and half a screen answering the old question + // is worse than a second of waiting. + window.location.reload() + } catch { + setError('Could not save that. Try again, or pick it later from the bar at the top.') + setBusy(false) + } + } + + const later = () => { + try { sessionStorage.setItem(DEFERRED, 'true') } catch { /* private browsing */ } + setDismissed(true) + } + + if (dismissed || exams === null || exams.length === 0) return null + + return ( +
+
+

What are you studying for?

+

+ It decides which questions you are shown, how much each topic is + worth, and what your readiness is measured against. You can change it + any time from the bar at the top. +

+ + {error &&

{error}

} + +
    + {exams.map(exam => ( +
  • + +
  • + ))} +
+ + +
+
+ ) +} diff --git a/frontend/src/components/ChooseObjective.test.jsx b/frontend/src/components/ChooseObjective.test.jsx new file mode 100644 index 0000000..7902659 --- /dev/null +++ b/frontend/src/components/ChooseObjective.test.jsx @@ -0,0 +1,66 @@ +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { beforeEach, expect, it, vi } from 'vitest' +import ChooseObjective from './ChooseObjective' +import api from '../api/client' + +vi.mock('../api/client', () => ({ default: { get: vi.fn(), put: vi.fn() } })) + +const EXAMS = [ + { id: 1, name: 'Pediatrics Boards', question_count: 2924 }, + { id: 2, name: 'USMLE Step 2 CK', question_count: 0 }, +] + +const answer = (activeExamId) => api.get.mockResolvedValue({ + data: { active_exam_id: activeExamId, exams: EXAMS }, +}) + +beforeEach(() => { + vi.clearAllMocks() + sessionStorage.clear() + api.put.mockResolvedValue({ data: {} }) +}) + +it('asks anybody who has never answered', async () => { + answer(null) + render() + expect(await screen.findByRole('dialog')).toBeInTheDocument() + expect(screen.getByRole('button', { name: /Pediatrics Boards/ })).toBeInTheDocument() + // An objective with nothing behind it says so rather than looking ready. + expect(screen.getByRole('button', { name: /USMLE Step 2 CK/ })) + .toHaveTextContent('No questions filed under it yet') +}) + +it('does not ask somebody who has already answered', async () => { + answer(1) + render() + await waitFor(() => expect(api.get).toHaveBeenCalled()) + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() +}) + +it('saves the choice', async () => { + answer(null) + render() + await userEvent.click(await screen.findByRole('button', { name: /Pediatrics Boards/ })) + await waitFor(() => expect(api.put).toHaveBeenCalledWith('/exams/active', { exam_id: 1 })) +}) + +it('lets somebody decline, and does not ask again this session', async () => { + answer(null) + const { unmount } = render() + // "Everything" is a real answer, and trapping somebody behind a modal is + // worse than the gap it closes. + await userEvent.click(await screen.findByRole('button', { name: /Show me everything/ })) + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + + unmount() + render() + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()) +}) + +it('stays out of the way when the list cannot be fetched', async () => { + api.get.mockRejectedValue(new Error('down')) + render() + await waitFor(() => expect(api.get).toHaveBeenCalled()) + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() +}) diff --git a/frontend/src/pages/AnalysisPage.css b/frontend/src/pages/AnalysisPage.css index eafd858..6f469a3 100644 --- a/frontend/src/pages/AnalysisPage.css +++ b/frontend/src/pages/AnalysisPage.css @@ -231,3 +231,7 @@ @media (max-width: 900px) { .an-readiness { grid-template-columns: minmax(0, 1fr); } } + +/* Right after a tip: the same family as right, lighter, because it is the + same answer arrived at with help. */ +.an-bar-split .is-hinted, .an-detail-legend i.is-hinted { background: var(--correct-bd); } diff --git a/frontend/src/pages/AnalysisPage.jsx b/frontend/src/pages/AnalysisPage.jsx index 595f673..388abe5 100644 --- a/frontend/src/pages/AnalysisPage.jsx +++ b/frontend/src/pages/AnalysisPage.jsx @@ -325,6 +325,9 @@ function FocusRow({ row, onPractise }) { const [open, setOpen] = useState(false) const seenShare = row.available ? Math.min(100, (row.seen_questions / row.available) * 100) : 0 const correctShare = row.answered ? (row.correct / row.answered) * 100 : 0 + // Right after a tip is part of correct, drawn as a slice of it rather than + // beside it, so the two bars still add to what was answered. + const hintedShare = row.answered ? ((row.correct_with_hints || 0) / row.answered) * 100 : 0 const accuracyLabel = row.answered ? `${row.accuracy}% (${row.correct} out of ${row.answered})` : 'Not attempted' @@ -382,21 +385,21 @@ function FocusRow({ row, onPractise }) { material into it would make a perfect score on a tenth of the questions look like a poor one. - The reference splits it three ways — right, right after a tip, - wrong. Per-topic hint counts are not in the recommendations - payload, only `answered` and `correct`, so the middle slice is - left undrawn rather than inferred from the lifetime figure, - which is about a different set of answers. */} + Three ways: right, right after opening a tip, wrong. The + middle one counts as correct — it was — and is drawn apart so + a topic can show how much of its score leaned on a nudge. */}
{row.answered ? ( <> - + + ) : }
  • Correct
  • + {hintedShare > 0 &&
  • Correct using hints
  • }
  • Incorrect
diff --git a/frontend/src/pages/QuizPage.jsx b/frontend/src/pages/QuizPage.jsx index 4195cd1..8b95c4a 100644 --- a/frontend/src/pages/QuizPage.jsx +++ b/frontend/src/pages/QuizPage.jsx @@ -215,6 +215,15 @@ const clock = (seconds) => { return `${String(Math.floor(s / 60)).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}` } +// Hours as well as minutes, for the one figure a candidate looks at most. A +// block clock reading 89:00 is a different amount of time depending on how +// long you thought the block was, and it is read at a glance. +const blockClock = (seconds) => { + const s = Math.max(0, Math.round(seconds)) + return [Math.floor(s / 3600), Math.floor((s % 3600) / 60), s % 60] + .map(part => String(part).padStart(2, '0')).join(':') +} + /** * Session time, time on this question, and the running average. * @@ -450,6 +459,12 @@ export default function QuizPage() { const [timeUp, setTimeUp] = useState(false) const [afterTimeUp, setAfterTimeUp] = useState(null) const timeUpRef = useRef(false) + // The attempt is closed: it was handed in, or the clock ran out and it was + // handed in for you. This is what "finished" means. It used to be read off + // the answer count, which called a block finished only once every question + // had an answer — an exam that ran out with nothing answered is just as + // over, and that was the one case the old reading got wrong. + const [attemptClosed, setAttemptClosed] = useState(false) // Seconds spent on each question, banked when you leave it. Without this the // analysis can report a total but never a per-question time. const [questionTimes, setQuestionTimes] = useState({}) @@ -472,6 +487,10 @@ export default function QuizPage() { const [statsError, setStatsError] = useState('') const [showStats, setShowStats] = useState(() => localStorage.getItem('pedshub_show_stats') !== '0') const [showAllExplanations, setShowAllExplanations] = useState(false) + // Questions whose answer was asked for rather than given. Being shown the + // answer is not answering: these stay out of `answers`, so the rail, the + // count and what is handed in all still say the question is unanswered. + const [revealed, setRevealed] = useState(() => new Set()) // Which options have had their reasoning opened by clicking them. Separate // from the show-all toggle so one does not fight the other. const [openExplanations, setOpenExplanations] = useState(() => new Set()) @@ -873,6 +892,26 @@ const timerStarted = timeLeft !== null handleSubmit(true) }, [timeLeft]) + /** + * A closed block is served with its answers; an open one never is. + * + * The exam player is sent questions with no correct option and no + * explanation — that is the integrity rule, and it is enforced on the server + * rather than by hiding what the page already holds. So review cannot simply + * un-hide anything: once the attempt is completed the same request returns + * the marked version, and this asks for it. A study session was served the + * answers at the start, so there is nothing here for it to fetch. + */ + useEffect(() => { + if (!attemptClosed || !attemptId) return undefined + if (questions.some(question => question.correct_answer)) return undefined + let live = true + api.get(`/quizzes/${id}?attempt_id=${attemptId}`) + .then(res => { if (live && res.data) setQuiz(res.data) }) + .catch(() => { /* The unmarked questions stay on screen, which is honest */ }) + return () => { live = false } + }, [attemptClosed, attemptId, id]) + const saveProgressNow = useCallback((overrides = {}) => { if (!attemptId || !quizMode) return Promise.resolve() return api.post('/attempts/progress', { @@ -964,13 +1003,41 @@ const timerStarted = timeLeft !== null const setAnswer = (questionId, value) => setAnswers(prev => ({ ...prev, [questionId]: value })) // Reset one question rather than the whole attempt: a misclick should cost - // the answer you just gave, not the nineteen before it. + // the answer you just gave, not the nineteen before it. A question you only + // asked to see the answer to is put back the same way, because otherwise one + // press would close it for the rest of the session. const resetQuestion = (questionId) => { setAnswers(prev => { const next = { ...prev } delete next[questionId] return next }) + setRevealed(prev => { + if (!prev.has(questionId)) return prev + const next = new Set(prev) + next.delete(questionId) + return next + }) + setTyped('') + } + + /** + * Show me the answer. + * + * The other direction from resetQuestion, and the same shape: it opens one + * question's answer where that one closes it again. Until now the only way + * to read an explanation was to choose an option, so a learner who was stuck + * had to guess first — and a guess entered to unlock the explanation is a + * wrong answer in the score, in the rail and in every figure the analysis + * draws afterwards. Asking is not answering, so nothing is recorded. + */ + const revealAnswer = (questionId) => { + setRevealed(prev => { + if (prev.has(questionId)) return prev + const next = new Set(prev) + next.add(questionId) + return next + }) setTyped('') } @@ -997,9 +1064,14 @@ const timerStarted = timeLeft !== null * on every question. Clicking an option marks it: green if it was right, red * if it was not, with the explanation. Exam mode records it and moves on * when you do. + * + * A question whose answer is already on screen takes no more answers: after + * the block is closed, and after Show answer, there is nothing left to + * decide and anything recorded now would be a copy rather than a response. */ const chooseAnswer = value => { - if (!current || (isStudy && answers[current.id])) return + if (!current || attemptClosed) return + if (isStudy && (answers[current.id] || revealed.has(current.id))) return setAnswer(current.id, value) } @@ -1024,21 +1096,23 @@ const timerStarted = timeLeft !== null // not, so a typed answer is held until Enter or leaving the field. const [typed, setTyped] = useState('') const commitTyped = () => { - if (!current || !typed.trim()) return - if (isStudy && answers[current.id]) return + if (!current || !typed.trim() || attemptClosed) return + if (isStudy && (answers[current.id] || revealed.has(current.id))) return setAnswer(current.id, typed.trim()) } useEffect(() => { let active = true setResponseStats(null); setStatsError('') - if (isStudy && attemptId && current && answers[current.id]) { + // What everybody else picked is worth reading whether the answer was given + // or asked for — it is the same page of feedback either way. + if (isStudy && attemptId && current && (answers[current.id] || revealed.has(current.id))) { api.get(`/study-tools/attempts/${attemptId}/questions/${current.id}/responses`) .then(r => { if (active) setResponseStats(r.data) }) .catch(() => { if (active) setStatsError('Response statistics are unavailable.') }) } return () => { active = false } - }, [isStudy, attemptId, current?.id, answers[current?.id]]) + }, [isStudy, attemptId, current?.id, answers[current?.id], revealed.has(current?.id)]) const clearCurrentHighlights = () => { if (!current || !manualHighlights[current.id]) return @@ -1107,6 +1181,10 @@ const timerStarted = timeLeft !== null } const res = await api.post(`/attempts/${attemptId}/submit`, submission) clearInterval(timerRef.current) + // Handed in, so the block is closed and the player may show its answers. + // Set here rather than when the clock hit zero because the server only + // reveals a completed attempt, and it is not completed until this returns. + setAttemptClosed(true) api.delete(`/attempts/progress/${attemptId}`).catch(() => {}) const target = returnTo // A course quiz reports back to its course, and has no analysis of its @@ -1238,9 +1316,20 @@ const timerStarted = timeLeft !== null const answeredCount = Object.keys(answers).length const totalCount = questions.length const isLast = currentIdx === totalCount - 1 - // Whether the answer is in. Category and difficulty are hints, so they wait - // for this; in exam mode nothing is revealed until the whole test is over. - const answerRevealed = isStudy && !!answers[current?.id] + // Reading the block back rather than sitting it. There is nothing left to + // protect once it is closed, so a finished exam reads like study mode: the + // rail says what each question was, and the answers are on the page. + const reviewing = attemptClosed + // Whether this question's answer is on screen — given, asked for with Show + // answer, or open to everybody because the block is over. Category and + // difficulty are hints, so they wait for it too; while an exam is still + // being sat this is false for every question, which is the whole rule. + const answerRevealed = + reviewing || (isStudy && (!!answers[current?.id] || revealed.has(current?.id))) + // The block chrome — item and block counters, the countdown, Pause and End + // Block — belongs to a block being sat. Study mode never had any of it, and + // a closed block has nothing left to pause or hand in. + const examChrome = !isStudy && !reviewing const quizNavigation = (position = 'bottom') => (
@@ -1305,9 +1396,6 @@ const timerStarted = timeLeft !== null } } - // Handed in: the block is over, so the rail may say what each question was. - const reviewing = answeredCount >= totalCount && totalCount > 0 - const QuestionRailItem = ({ q, i }) => { const isActive = i === currentIdx const isDone = !!answers[q.id] @@ -1323,7 +1411,7 @@ const timerStarted = timeLeft !== null const excerpt = seen ? questionStem(q).replace(/\s+/g, ' ').trim() : '' return (
- {timeLeft !== null && } + {/* One clock. While a block is being sat the countdown lives in + the bar at the foot of the screen, where a paper puts it; this + is what is left for anything else that runs to a limit. */} + {timeLeft !== null && !examChrome && } {/* Suspend, Restart and Edit were three buttons above a question nobody was looking away from to press them. Exit is in the bar at the bottom, where the session's own controls are; restarting @@ -1535,11 +1630,9 @@ const timerStarted = timeLeft !== null {drawerTab === 'questions' ? ( <>
- {/* Everything answered means there is nothing left to sit; + {/* The block is closed, so there is nothing left to sit; what you are doing now is reading it back. */} - {answeredCount >= totalCount && totalCount > 0 && ( - Review - )} + {reviewing && Review} {isStudy ? 'Study mode' : 'Exam mode'}: {quiz.title} {answeredCount}/{totalCount}
@@ -1960,20 +2092,33 @@ const timerStarted = timeLeft !== null {/* The session's own bar, outside the scrolling columns so it is always on screen — the player is a fixed-height shell and the question scrolls inside it, rather than the whole page scrolling. */} -
- - {quizNavigation('bottom')} - {/* Reviewing a block before handing it in is an exam idea. A study - session has nothing to hand in: it keeps going until every - question is answered, and at that point it is the review. This - button sat in both because the study player inherited the exam - player's bar. */} - {!isStudy && answeredCount > 0 && ( - +
+ {examChrome ? ( + <> +
+ + {/* The block's own clock, and the only one: a second countdown + elsewhere on the screen is a second chance to misread it. An + untimed block says so rather than showing an empty space + where the figure everybody looks for should be. */} + + Block Time Remaining: {timeLeft === null ? 'Untimed' : blockClock(timeLeft)} + +
+ {/* Pause is the exam's own pause — the same flag the clocks read, + and the same Exam Paused dialog that covers the questions. */} + + + + ) : ( + <> + + {quizNavigation('bottom')} + )}
diff --git a/frontend/src/pages/QuizPage.test.jsx b/frontend/src/pages/QuizPage.test.jsx index fa3f790..73c1ca8 100644 --- a/frontend/src/pages/QuizPage.test.jsx +++ b/frontend/src/pages/QuizPage.test.jsx @@ -64,6 +64,10 @@ const findStem = async (text) => { return waitFor(() => inCard().getByText(text)) } +/** The one control that ends a block: the End Block in the block's own bar, + * not the one in the dialog it opens. */ +const endBlock = () => within(document.querySelector('.quiz-footbar')).getByRole('button', { name: 'End Block' }) + async function begin(study = true) { quizModeVar = study ? 'learning' : 'timed' mount() @@ -288,6 +292,60 @@ describe('quiz player', () => { expect(screen.queryByRole('button', { name: /Rule out option/ })).toBeNull() }) + it('shows the answer to a learner who asks, without answering for them', async () => { + await begin() + expect(screen.queryByText(/Full explanation/)).not.toBeInTheDocument() + await userEvent.click(screen.getByRole('button', { name: 'Show answer' })) + + // The correct option and the explanation, the same as they would be after + // a right answer — being stuck is a reason to read them, not to guess. + expect(await screen.findByText(/Full explanation, preserved without shortening/)).toBeInTheDocument() + expect(inCard().getByText('First answer').closest('.option')).toHaveClass('correct') + // Once it is showing there is nothing left to offer. + expect(screen.queryByRole('button', { name: 'Show answer' })).toBeNull() + + // Nothing was answered by it: not in the rail, not in the count, and not + // in what is handed in. + expect(document.querySelectorAll('.quiz-rail-item')[0].className).not.toMatch(/is-done/) + expect(within(document.querySelector('.quiz-header-card')).getByText('0 answered')).toBeInTheDocument() + // Nor can an option be picked afterwards — that would be copying, not + // answering, and it would count. + await userEvent.click(inCard().getByText('Second answer').closest('.option')) + expect(document.querySelector('.option.selected')).toBeNull() + + await userEvent.click(screen.getAllByRole('button', { name: /Next/ })[0]) + await findStem('Full second clinical question.') + await userEvent.click(screen.getByRole('button', { name: 'Finish session' })) + await userEvent.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'End Block' })) + // Handing in nothing is announced before it happens, so this waits out + // that message rather than racing it. + expect(await screen.findByText('Submitted results', {}, { timeout: 3000 })).toBeInTheDocument() + expect(api.post).toHaveBeenCalledWith('/attempts/50/submit', expect.objectContaining({ answers: [] })) + }) + + it('puts a question shown back the way it puts an answered one back', async () => { + await begin() + await userEvent.click(screen.getByRole('button', { name: 'Show answer' })) + await screen.findByText(/Full explanation, preserved without shortening/) + // Reset is the other direction from Show answer, so one press must not + // close a question for the rest of the session. + await userEvent.click(screen.getByRole('button', { name: /Reset question/ })) + expect(screen.queryByText(/Full explanation/)).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Show answer' })).toBeInTheDocument() + fireEvent.keyDown(window, { key: '1' }) + expect(await screen.findByText(/Full explanation, preserved without shortening/)).toBeInTheDocument() + }) + + it('offers no way to see the answer while an exam is being sat', async () => { + await begin(false) + // The exam is the one place where being shown the answer is not on offer, + // and the server does not send it either. + expect(screen.queryByRole('button', { name: 'Show answer' })).toBeNull() + fireEvent.keyDown(window, { key: '1' }) + expect(screen.queryByRole('button', { name: 'Show answer' })).toBeNull() + expect(screen.queryByText(/Full explanation/)).not.toBeInTheDocument() + }) + it('marks a study answer the moment it is chosen, and shows genuine response data', async () => { await begin() // Choosing is answering. It used to hold the choice and wait for "Submit @@ -465,7 +523,7 @@ describe('quiz player', () => { fireEvent.keyDown(window, { key: '1' }) expect(screen.queryByText(/Full explanation/)).not.toBeInTheDocument() expect(api.get.mock.calls.some(([url]) => url.startsWith('/study-tools/attempts/'))).toBe(false) - await userEvent.click(screen.getAllByRole('button', { name: 'Review & Complete' })[0]) + await userEvent.click(endBlock()) const review = screen.getByRole('dialog', { name: /This block is incomplete/ }) // The sentence names what is missing, not what is done — that is the // decision being confirmed. @@ -476,7 +534,7 @@ describe('quiz player', () => { await userEvent.click(screen.getAllByRole('button', { name: /Next/ })[0]) await findStem('Full second clinical question.') expect(api.post.mock.calls.some(([url]) => url === '/attempts/50/submit')).toBe(false) - await userEvent.click(screen.getAllByRole('button', { name: 'Review & Complete' })[0]) + await userEvent.click(endBlock()) await userEvent.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'End Block' })) expect(await screen.findByText('Submitted results')).toBeInTheDocument() expect(api.post).toHaveBeenCalledWith('/attempts/50/submit', expect.objectContaining({ @@ -491,7 +549,7 @@ describe('quiz player', () => { await userEvent.click(await screen.findByRole('button', { name: 'stridor' })) expect(screen.getByRole('note')).toHaveTextContent('extrathoracic') fireEvent.keyDown(window, { key: '1' }) - await userEvent.click(screen.getAllByRole('button', { name: 'Review & Complete' })[0]) + await userEvent.click(endBlock()) await userEvent.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'End Block' })) expect(await screen.findByText('Submitted results')).toBeInTheDocument() expect(api.post).toHaveBeenCalledWith('/attempts/50/submit', expect.objectContaining({ @@ -508,7 +566,7 @@ describe('quiz player', () => { await beginWithTip() fireEvent.keyDown(window, { key: '1' }) await userEvent.click(await screen.findByRole('button', { name: 'stridor' })) - await userEvent.click(screen.getAllByRole('button', { name: 'Review & Complete' })[0]) + await userEvent.click(endBlock()) await userEvent.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'End Block' })) expect(await screen.findByText('Submitted results')).toBeInTheDocument() expect(api.post).toHaveBeenCalledWith('/attempts/50/submit', expect.objectContaining({ @@ -554,7 +612,12 @@ describe('quiz player', () => { it('an exam still offers it, because a block is handed in', async () => { await begin(false) fireEvent.keyDown(window, { key: '1' }) - expect(screen.getByRole('button', { name: /^Review \(1\/2\)/ })).toBeInTheDocument() + // One control, at the right-hand end of the block's own bar. It used to be + // three — the top bar, the rail and this bar — under two names for the + // same dialog. + expect(endBlock()).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Review & Complete' })).toBeNull() + expect(screen.queryByRole('button', { name: /^Review \(/ })).toBeNull() }) it('a finished study session submits without asking twice', async () => { @@ -568,6 +631,84 @@ describe('quiz player', () => { expect(await screen.findByText('Submitted results')).toBeInTheDocument() }) + it('reviews a block that ran out of time, with nothing answered', async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }) + try { + timeLimit = 1 + await begin(false) + // Sitting it: a column of numbers, and no answer anywhere on the page. + expect(document.querySelectorAll('.quiz-rail-item')[0].className).toMatch(/is-numbers/) + expect(screen.queryByText('Review')).not.toBeInTheDocument() + + // The server hands back a completed attempt with its answers, which is + // what switching the fixture to the marked questions stands in for. + mode = 'study' + await act(async () => { vi.advanceTimersByTime(61_000) }) + expect(await screen.findByText(/You have run out of time/)).toBeInTheDocument() + // Nothing was answered and the block is still finished — the case + // "everything is answered" got wrong. + expect(api.post).toHaveBeenCalledWith('/attempts/50/submit', expect.objectContaining({ answers: [] })) + + expect(await screen.findByText('Review')).toBeInTheDocument() + const item = document.querySelectorAll('.quiz-rail-item')[0] + expect(item.className).not.toMatch(/is-numbers/) + expect(within(item).getByText('Full first clinical question.')).toBeInTheDocument() + expect(await screen.findByText(/Full explanation, preserved without shortening/)).toBeInTheDocument() + expect(inCard().getByText('First answer').closest('.option')).toHaveClass('correct') + // Nothing left to pause or hand in. + expect(screen.queryByRole('button', { name: 'End Block' })).toBeNull() + expect(screen.queryByRole('button', { name: 'Pause' })).toBeNull() + } finally { + vi.useRealTimers() + timeLimit = null + } + }) + + it('sits an exam between an item counter and a block bar', async () => { + timeLimit = 60 + try { + await begin(false) + const meta = document.querySelector('.quiz-block-meta') + expect(meta.textContent).toContain('Item: 1 of 2') + expect(meta.textContent).toContain('Block: 1 of 1') + + // Previous / n of m / Next in the middle, not tucked in beside the tools. + const nav = document.querySelector('.quiz-item-nav') + expect(within(nav).getByRole('button', { name: /Previous/ })).toBeDisabled() + expect(within(nav).getByText('1 / 2')).toBeInTheDocument() + await userEvent.click(within(nav).getByRole('button', { name: /Next/ })) + await findStem('Full second clinical question.') + expect(document.querySelector('.quiz-block-meta').textContent).toContain('Item: 2 of 2') + expect(within(document.querySelector('.quiz-item-nav')).getByRole('button', { name: /Next/ })).toBeDisabled() + // The tools kept their end of the bar. + expect(within(document.querySelector('.quiz-top-actions')).getByRole('button', { name: 'Lab values' })).toBeInTheDocument() + + // One clock, at the foot of the screen where a paper puts it. + const bar = document.querySelector('.quiz-footbar') + expect(within(bar).getByText(/Block Time Remaining/).textContent).toMatch(/\d\d:\d\d:\d\d/) + expect(document.querySelectorAll('.quiz-block-time')).toHaveLength(1) + + // Pause is the exam's own pause, and End Block its one confirmation. + await userEvent.click(within(bar).getByRole('button', { name: 'Pause' })) + expect(screen.getByRole('dialog', { name: 'Exam Paused' })).toBeInTheDocument() + await userEvent.click(screen.getByRole('button', { name: 'Return to exam' })) + await userEvent.click(endBlock()) + expect(screen.getByRole('dialog', { name: /This block is incomplete/ })).toBeInTheDocument() + } finally { + timeLimit = null + } + }) + + it('leaves the study bar alone — the block chrome is the exam\'s', async () => { + await begin() + expect(document.querySelector('.quiz-block-meta')).toBeNull() + expect(document.querySelector('.quiz-block-time')).toBeNull() + expect(screen.queryByRole('button', { name: 'Pause' })).toBeNull() + const bar = document.querySelector('.quiz-footbar') + expect(within(bar).getByRole('button', { name: 'Exit session' })).toBeInTheDocument() + expect(within(bar).getByRole('button', { name: /Next/ })).toBeInTheDocument() + }) + it('says so when five minutes are left, once', async () => { vi.useFakeTimers({ shouldAdvanceTime: true }) try { @@ -604,7 +745,7 @@ describe('quiz player', () => { if (url === '/attempts/50/submit' && !failed) { failed = true; return Promise.reject({ response: { data: { detail: 'Try again safely' } } }) } return originalPost(url, ...args) }) - await userEvent.click(screen.getAllByRole('button', { name: 'Review & Complete' })[0]) + await userEvent.click(endBlock()) await userEvent.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'End Block' })) expect(await screen.findByRole('alert')).toHaveTextContent('Try again safely') expect(api.delete).not.toHaveBeenCalled() diff --git a/frontend/src/pages/QuizPlayer.css b/frontend/src/pages/QuizPlayer.css index fec220b..f739954 100644 --- a/frontend/src/pages/QuizPlayer.css +++ b/frontend/src/pages/QuizPlayer.css @@ -60,7 +60,15 @@ .quiz-top-actions { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; justify-content: flex-end; } .quiz-top-actions button { font-size: .85rem; min-height: 42px; } .quiz-top-actions button[title] { background: transparent; } -.quiz-top-actions .quiz-review-button { background: #496fa5; color: white; font-weight: 650; text-transform: uppercase; padding: 12px 18px; margin-left: 12px; } +/* ── The block bars ─────────────────────────────────────────────────── + A paper says where you are before it says anything else, so the counters + come first, the arrows sit in the middle where the hand already is, and the + tools keep the right-hand end they have always had. */ +.quiz-block-meta { display: flex; flex-direction: column; gap: 2px; margin: 0; font-size: .82rem; color: #737982; white-space: nowrap; } +.quiz-block-meta strong { color: #30343a; font-weight: 650; } +.quiz-item-nav { display: flex; align-items: center; gap: 6px; } +.quiz-topbar .quiz-item-nav button { min-width: 92px; font-size: .85rem; min-height: 42px; } +.quiz-item-count { font-size: .9rem; font-weight: 650; color: #30343a; font-variant-numeric: tabular-nums; min-width: 58px; text-align: center; } .quiz-player button:focus-visible, .quiz-player a:focus-visible, .quiz-results a:focus-visible { outline: 3px solid #779ad1; outline-offset: 3px; } .quiz-player .question-card { border: 0; padding: 0; border-radius: 0; box-shadow: none; margin-bottom: 22px; } /* One compact meta strip — the category trail used to be a 78px block that @@ -116,6 +124,24 @@ .quiz-response-track { display: block; width: min(36vw, 340px); height: 8px; background: #edeef4; flex-shrink: 1; } .quiz-response-track > span { display: block; height: 100%; background: #444; } .option.correct .quiz-response-track > span { background: #71b298; } +/* ── Show answer ────────────────────────────────────────────────────── + Under the options, quiet enough that choosing one is still the obvious + thing to do, and beside a line saying what it costs — which is nothing. */ +.quiz-reveal { display: flex; align-items: center; flex-wrap: wrap; gap: 10px; padding: 14px 0 2px; } +.quiz-reveal-button { + background: none; border: 1px solid var(--primary); border-radius: 2px; + padding: 9px 18px; font: inherit; font-size: .85rem; font-weight: 650; + color: var(--primary); cursor: pointer; +} +.quiz-reveal-button:hover { background: var(--primary); color: #fff; } +.quiz-reveal span { font-size: .78rem; color: var(--text-muted); } + +/* Read back rather than sat, said in the header where the mode is. */ +.quiz-review-badge { + padding: 1px 8px; border-radius: 12px; font-weight: 700; letter-spacing: .06em; + text-transform: uppercase; font-size: .68rem; + background: #fdf6e8; color: #8a6417; border: 1px solid #eddfbe; +} .quiz-review-tabs { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; border-bottom: 1px solid var(--border); padding: 10px 0; margin-top: 18px; } .quiz-review-tabs > span:first-child { background: #333; color: white; padding: 11px 24px; font-size: .85rem; font-weight: 600; } .quiz-review-tabs .quiz-source-page { color: #6f7886; padding: 10px; font-size: .84rem; } @@ -147,7 +173,8 @@ .quiz-top-actions button[title] span { display: none; } .quiz-top-actions button[aria-label="Next question"] { font-size: 0; } .quiz-top-actions button[aria-label="Next question"]::after { content: '›'; font-size: 1.25rem; } - .quiz-top-actions .quiz-review-button { padding: 9px; margin-left: 0; font-size: .73rem; } + .quiz-block-meta { font-size: .74rem; } + .quiz-topbar .quiz-item-nav button { min-width: 0; padding: 8px 10px; } .quiz-qmeta { padding: 10px 0 8px; } .quiz-breadcrumbs { font-size: .76rem; } .quiz-actionbar button span, .quiz-actionbar .manual-highlight-toolbar button span { display: none; } @@ -174,7 +201,6 @@ .quiz-player .quiz-header-card > div { gap: 10px; } .quiz-topbar { gap: 10px; } .quiz-nav-controls { gap: 8px; } -.quiz-review-button { margin-left: auto; } @media (max-width: 640px) { .quiz-topbar { flex-wrap: wrap; } .quiz-header-card { padding-bottom: 12px; } @@ -316,7 +342,20 @@ } .quiz-footbar .quiz-nav-controls { flex: 1; justify-content: center; margin: 0; } .quiz-exit { flex-shrink: 0; } -.quiz-review-link { flex-shrink: 0; } + +/* The block's bar: the clock at the left where it is read from, Pause in the + middle, and the one control that ends the block at the far right, away from + the hand that is moving between items. */ +.quiz-footbar.is-exam { justify-content: space-between; } +.quiz-footbar-left { display: flex; align-items: center; gap: 14px; min-width: 0; } +.quiz-block-time { font-size: .85rem; color: #737982; white-space: nowrap; } +.quiz-block-time strong { color: #30343a; font-weight: 700; font-variant-numeric: tabular-nums; font-size: .95rem; } +.quiz-block-pause { flex-shrink: 0; min-width: 96px; } +.quiz-block-end { + flex-shrink: 0; background: #496fa5; color: #fff; border: 1px solid #496fa5; + font-weight: 650; text-transform: uppercase; letter-spacing: .03em; padding: 10px 18px; +} +.quiz-block-end:hover:not(:disabled) { background: #365b8d; } /* Nothing else on the page while a session is being sat. */ body:has(.quiz-player.is-boxed) .site-footer { display: none; }