diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index e1aec17..172354b 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -33,6 +33,7 @@ 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 SessionsPage = lazy(() => import('./pages/SessionsPage')) const AiModePage = lazy(() => import('./pages/AiModePage')) const MediaPage = lazy(() => import('./pages/MediaPage')) const EditorialPage = lazy(() => import('./pages/EditorialPage')) @@ -94,6 +95,7 @@ function AppRoutes() { }> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/components/Navbar.test.jsx b/frontend/src/components/Navbar.test.jsx index 75118bf..af963ce 100644 --- a/frontend/src/components/Navbar.test.jsx +++ b/frontend/src/components/Navbar.test.jsx @@ -14,6 +14,9 @@ vi.mock('./GlobalSearch', () => ({ default: () => { + mount() + const bar = document.querySelector('.navbar-sections') + await scrollTo(400) + expect(bar).toHaveClass('is-hidden') + + // Losing 46px of bar makes the page shorter and the browser corrects the + // scroll position; that correction arrives as an upward scroll. Acting on it + // would show the bar, lengthen the page, and start the loop over. + await scrollTo(354) + expect(bar).toHaveClass('is-hidden') + }) + it('stays put near the top, so a short page never loses it', async () => { mount() const bar = document.querySelector('.navbar-sections') diff --git a/frontend/src/components/QuizTools.css b/frontend/src/components/QuizTools.css index 7c9c407..afd225e 100644 --- a/frontend/src/components/QuizTools.css +++ b/frontend/src/components/QuizTools.css @@ -64,3 +64,15 @@ .quiz-lab-age-line:last-child { border-bottom: none; } .quiz-lab-age { color: var(--text-muted); font-size: .78rem; min-width: 0; overflow-wrap: anywhere; } .quiz-lab-row { grid-template-columns: minmax(150px, 1fr) minmax(210px, 1.5fr); } + +/* Editing inside a lookup has to look like a mode you are in, not a stray + checkbox above the thing it changes. */ +.quiz-lab-editing { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin: 4px 0 10px; } +.quiz-lab-edit-toggle { + min-height: 32px; padding: 6px 12px; border-radius: 7px; cursor: pointer; + border: 1px solid var(--border); background: var(--card-bg); + font: inherit; font-size: 0.8rem; font-weight: 600; color: var(--text-muted); +} +.quiz-lab-edit-toggle:hover { border-color: var(--primary); color: var(--primary); } +.quiz-lab-edit-toggle.is-on { background: var(--primary); border-color: var(--primary); color: #fff; } +.quiz-lab-editing-note { font-size: 0.76rem; color: #b45309; } diff --git a/frontend/src/components/QuizTools.jsx b/frontend/src/components/QuizTools.jsx index b66eb4a..ea30376 100644 --- a/frontend/src/components/QuizTools.jsx +++ b/frontend/src/components/QuizTools.jsx @@ -117,7 +117,19 @@ function LabValues() { const ordered = [...filtered].sort((a, b) => a.group.localeCompare(b.group) || a.name.localeCompare(b.name)) return <>

Ranges vary with age, laboratory and method. Confirm against your own laboratory before relying on a value.

- {isEducator && } + {/* "Manage references and drafts" said nothing useful: "references" here + means lab values, not citations, and "drafts" means unpublished ones. + This is an editing switch inside a lookup, so it says so. */} + {isEducator &&
+ + {manage && + Unpublished values are shown too. Anything you change here every learner sees. + } +
} {error &&

{error}

} {linkError &&

{linkError}

}
@@ -172,8 +184,9 @@ function LabValues() { ))} {manage && - setCardLink(prev => ({ ...prev, [row.id]: e.target.value }))} /> - + setCardLink(prev => ({ ...prev, [row.id]: e.target.value }))} /> + }
})()} diff --git a/frontend/src/components/QuizTools.test.jsx b/frontend/src/components/QuizTools.test.jsx index 43f024d..c19ef0f 100644 --- a/frontend/src/components/QuizTools.test.jsx +++ b/frontend/src/components/QuizTools.test.jsx @@ -54,11 +54,11 @@ describe('lab values panel', () => { it('lets an educator link and unlink a card per reference', async () => { renderLabs() await screen.findByRole('heading', { name: 'CSF' }) - await userEvent.click(screen.getByRole('checkbox', { name: /Manage references and drafts/ })) + await userEvent.click(screen.getByRole('button', { name: 'Edit values' })) const csfSection = screen.getByRole('heading', { name: 'CSF' }).closest('section') const input = await within(csfSection).findByLabelText('Card ID to link to CSF white cell count') await userEvent.type(input, '12') - await userEvent.click(within(csfSection).getByRole('button', { name: 'Link card' })) + await userEvent.click(within(csfSection).getByRole('button', { name: 'Attach card' })) await waitFor(() => expect(api.put).toHaveBeenCalledWith('/study-tools/lab-values/2/cards/12')) await userEvent.click(screen.getByRole('button', { name: /Unlink card 9 from CSF white cell count/ })) await waitFor(() => expect(api.delete).toHaveBeenCalledWith('/study-tools/lab-values/2/cards/9')) diff --git a/frontend/src/hooks/useHidingBar.js b/frontend/src/hooks/useHidingBar.js index 4c3734e..fbee4e9 100644 --- a/frontend/src/hooks/useHidingBar.js +++ b/frontend/src/hooks/useHidingBar.js @@ -11,9 +11,15 @@ import { useEffect, useRef, useState } from 'react' * short page never loses it, and a movement under `threshold` is treated as * noise — trackpads and momentum scrolling emit a lot of one-pixel jitter. */ -export default function useHidingBar({ offset = 90, threshold = 6 } = {}) { +export default function useHidingBar({ offset = 90, threshold = 6, settle = 350 } = {}) { const [hidden, setHidden] = useState(false) const lastY = useRef(0) + // When the bar collapses, the page gets 46px shorter and the browser adjusts + // the scroll position to compensate. That adjustment is itself a scroll event, + // in the opposite direction, which shows the bar again — which makes the page + // taller, which scrolls again. The result is a bar that vibrates. Nothing may + // change state until the layout has settled. + const lockedUntil = useRef(0) useEffect(() => { if (typeof window === 'undefined') return @@ -25,10 +31,19 @@ export default function useHidingBar({ offset = 90, threshold = 6 } = {}) { frame = window.requestAnimationFrame(() => { frame = 0 const y = window.scrollY + if (performance.now() < lockedUntil.current) { + // Still the echo of our own resize: follow the page, decide nothing. + lastY.current = y + return + } const delta = y - lastY.current if (Math.abs(delta) < threshold) return lastY.current = y - setHidden(y > offset && delta > 0) + const next = y > offset && delta > 0 + setHidden(prev => { + if (prev !== next) lockedUntil.current = performance.now() + settle + return next + }) }) } @@ -37,7 +52,7 @@ export default function useHidingBar({ offset = 90, threshold = 6 } = {}) { window.removeEventListener('scroll', onScroll) if (frame) window.cancelAnimationFrame(frame) } - }, [offset, threshold]) + }, [offset, threshold, settle]) return hidden } diff --git a/frontend/src/pages/QuizPage.jsx b/frontend/src/pages/QuizPage.jsx index 6b7e566..7c5df95 100644 --- a/frontend/src/pages/QuizPage.jsx +++ b/frontend/src/pages/QuizPage.jsx @@ -911,13 +911,9 @@ const timerStarted = timeLeft !== null {i + 1} {marked && } - {seen && ( - - Question {i + 1} - {excerpt.slice(0, 64)}{excerpt.length > 64 ? '…' : ''} - {q.difficulty && {q.difficulty}} - - )} + {/* One line each. The number already says which question it is, and a + five-line excerpt makes a rail of twenty into a page of its own. */} + {seen && {excerpt}} ) } diff --git a/frontend/src/pages/QuizPage.test.jsx b/frontend/src/pages/QuizPage.test.jsx index 511f01f..74ff026 100644 --- a/frontend/src/pages/QuizPage.test.jsx +++ b/frontend/src/pages/QuizPage.test.jsx @@ -70,10 +70,12 @@ describe('quiz player', () => { const items = document.querySelectorAll('.quiz-rail-item') expect(items).toHaveLength(2) - // The current question shows its text, difficulty and label. - expect(within(items[0]).getByText('Question 1')).toBeInTheDocument() + // One row, one line: the number, then the stem truncated by CSS. A repeated + // "Question N" label and a stacked difficulty pill turned a rail of twenty + // into a page of its own. + expect(within(items[0]).getByText('1')).toBeInTheDocument() expect(within(items[0]).getByText('Full first clinical question.')).toBeInTheDocument() - expect(within(items[0]).getByText('hard')).toBeInTheDocument() + expect(within(items[0]).queryByText('Question 1')).not.toBeInTheDocument() expect(items[0]).toHaveAttribute('aria-current', 'true') // The unopened one is a bare number — previewing it would spoil the case. diff --git a/frontend/src/pages/QuizPlayer.css b/frontend/src/pages/QuizPlayer.css index df82025..94055d4 100644 --- a/frontend/src/pages/QuizPlayer.css +++ b/frontend/src/pages/QuizPlayer.css @@ -26,10 +26,10 @@ .quiz-rail-item.is-done .quiz-rail-num { background: #dff0e8; color: #327b64; } .quiz-rail-item.is-active .quiz-rail-num { background: #496fa5; color: #fff; } .quiz-rail-mark { position: absolute; top: -4px; right: -4px; font-size: .6rem; color: #d99a2b; } -.quiz-rail-body { min-width: 0; display: flex; flex-direction: column; gap: 2px; } -.quiz-rail-label { font-size: .68rem; font-weight: 700; letter-spacing: .04em; text-transform: uppercase; color: #98a0aa; } -.quiz-rail-text { font-size: .78rem; line-height: 1.4; overflow-wrap: anywhere; } -.quiz-rail-diff { align-self: flex-start; font-size: .62rem; font-weight: 700; text-transform: uppercase; letter-spacing: .04em; border-radius: 10px; padding: 1px 7px; margin-top: 2px; } +/* One row, one line: truncated rather than wrapped, so twenty questions + stay a list you can scan instead of a page you have to scroll. */ +.quiz-rail-text { flex: 1; min-width: 0; font-size: .78rem; line-height: 1.4; + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .quiz-rail-diff.is-easy { background: #eef7f3; color: #327b64; } .quiz-rail-diff.is-medium { background: #fdf6e8; color: #8a6417; } .quiz-rail-diff.is-hard { background: #fbecf0; color: #a13c51; } diff --git a/frontend/src/pages/QuizzesPage.jsx b/frontend/src/pages/QuizzesPage.jsx index 70d42bb..179c2eb 100644 --- a/frontend/src/pages/QuizzesPage.jsx +++ b/frontend/src/pages/QuizzesPage.jsx @@ -23,100 +23,6 @@ const dayLabel = (iso) => { return d.toLocaleDateString(undefined, { month: 'short', day: '2-digit', year: 'numeric' }) } -function PastAttemptsSection() { - const [open, setOpen] = useState(false) - const [history, setHistory] = useState(null) - const [loading, setLoading] = useState(false) - const [deletingId, setDeletingId] = useState(null) - - const load = async () => { - if (history !== null) { setOpen(v => !v); return } - setLoading(true) - try { - const res = await api.get('/attempts/history') - setHistory(res.data) - } catch { setHistory([]) } - finally { setLoading(false); setOpen(true) } - } - - const deleteAttempt = async (attemptId) => { - setDeletingId(attemptId) - try { - await api.delete(`/attempts/${attemptId}`) - setHistory(prev => - prev - .map(q => ({ ...q, attempts: q.attempts.filter(a => a.attempt_id !== attemptId) })) - .filter(q => q.attempts.length > 0) - ) - } catch { } - finally { setDeletingId(null) } - } - - const totalCompleted = history?.reduce((s, q) => s + q.attempts.length, 0) ?? 0 - - return ( -
- - - {open && history !== null && ( -
- {history.length === 0 ? ( -
No completed attempts yet.
- ) : history.map(quiz => ( -
-
{quiz.title}
-
- {quiz.attempts.map((a) => ( -
- - {new Date(a.date).toLocaleDateString()} — {a.score}/{a.total} - - = 80 ? 'var(--correct-fg)' : a.percentage >= 50 ? '#d97706' : 'var(--wrong-fg)', - }}>{a.percentage}% - - Review → - - -
- ))} -
-
- ))} -
- )} -
- ) -} - function HighlightText({ text, query }) { if (!query || !text) return {text} const idx = text.toLowerCase().indexOf(query.toLowerCase()) @@ -383,7 +289,6 @@ export default function QuizzesPage() { const [tab, setTab] = useState('sessions') const [stateFilter, setStateFilter] = useState('all') // The full history lives on the analysis page; this is a launcher. - const [showAllSessions, setShowAllSessions] = useState(false) const SESSION_PREVIEW = 6 const [searchQuery, setSearchQuery] = useState('') const [searchMode, setSearchMode] = useState('all') @@ -440,8 +345,8 @@ export default function QuizzesPage() { ) const previewRows = useMemo( - () => (showAllSessions ? visibleRows : visibleRows.slice(0, SESSION_PREVIEW)), - [visibleRows, showAllSessions], + () => visibleRows.slice(0, SESSION_PREVIEW), + [visibleRows], ) const dayGroups = useMemo(() => { @@ -616,24 +521,14 @@ export default function QuizzesPage() { {visibleRows.length > SESSION_PREVIEW && (
- {showAllSessions ? ( - - ) : ( - <> - - - Full session history → - - - )} + {/* 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}) → +
)} - ) )} diff --git a/frontend/src/pages/SessionsPage.css b/frontend/src/pages/SessionsPage.css new file mode 100644 index 0000000..84bdefe --- /dev/null +++ b/frontend/src/pages/SessionsPage.css @@ -0,0 +1,77 @@ +/* Full session history: the rail narrows, the list never truncates. */ + +.sx-page { max-width: 1080px; margin: 0 auto; padding-bottom: 48px; } +.sx-header { display: flex; justify-content: space-between; align-items: flex-end; gap: 12px; flex-wrap: wrap; margin-bottom: 16px; } +.sx-header h1 { margin: 0 0 4px; font-size: 1.35rem; } +.sx-header p { margin: 0; color: var(--text-muted); font-size: 0.87rem; } + +.sx-body { display: grid; grid-template-columns: 210px 1fr; gap: 20px; align-items: start; } + +.sx-rail { + position: sticky; top: 76px; max-height: calc(100vh - 100px); overflow-y: auto; + background: var(--card-bg); border: 1px solid var(--border); + border-radius: 12px; padding: 12px; +} +.sx-search { + width: 100%; min-height: 38px; padding: 8px 11px; margin-bottom: 12px; + border: 1px solid var(--border); border-radius: 8px; + background: var(--input-bg); color: var(--text); font-size: 0.85rem; +} +.sx-rail h2 { + margin: 12px 0 6px; font-size: 0.69rem; font-weight: 700; + letter-spacing: 0.07em; text-transform: uppercase; color: var(--text-subtle); +} +.sx-rail ul { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 2px; } +.sx-filter { + display: flex; align-items: center; gap: 8px; width: 100%; min-height: 36px; + padding: 7px 10px; border: 0; border-radius: 7px; background: none; + font: inherit; font-size: 0.84rem; color: var(--text); text-align: left; cursor: pointer; +} +.sx-filter span:first-child { flex: 1; min-width: 0; } +.sx-filter:hover { background: var(--bg); } +.sx-filter.is-active { background: var(--option-sel-bg); color: var(--primary); font-weight: 650; } +.sx-count { font-size: 0.74rem; color: var(--text-subtle); font-variant-numeric: tabular-nums; } + +.sx-summary { + margin-top: 14px; padding-top: 12px; border-top: 1px solid var(--border); + display: flex; flex-direction: column; gap: 2px; +} +.sx-summary strong { font-size: 1.3rem; font-variant-numeric: tabular-nums; } +.sx-summary span { font-size: 0.74rem; color: var(--text-muted); } + +.sx-main { min-width: 0; } +.sx-error { color: var(--wrong-fg); font-size: 0.85rem; } +.sx-count-line { margin: 0 0 10px; font-size: 0.8rem; color: var(--text-muted); } +.sx-empty { background: var(--card-bg); border: 1px solid var(--border); border-radius: 12px; padding: 32px; text-align: center; color: var(--text-muted); } + +.sx-list { list-style: none; margin: 0; padding: 0; background: var(--card-bg); border: 1px solid var(--border); border-radius: 12px; overflow: hidden; } +.sx-row { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; padding: 12px 14px; border-bottom: 1px solid var(--border); } +.sx-row:last-child { border-bottom: 0; } +.sx-row:hover { background: var(--bg); } +/* An unfinished test is the one thing on this page you can act on right now. */ +.sx-row.is-in_progress { box-shadow: inset 3px 0 0 var(--primary); } + +.sx-row-main { flex: 1; min-width: 180px; display: flex; flex-direction: column; gap: 2px; } +.sx-title { font-size: 0.92rem; font-weight: 650; color: var(--text); text-decoration: none; overflow-wrap: anywhere; } +.sx-title:hover { color: var(--primary); } +.sx-meta { font-size: 0.76rem; color: var(--text-subtle); } + +.sx-score { flex-shrink: 0; min-width: 76px; font-weight: 700; font-size: 0.92rem; font-variant-numeric: tabular-nums; display: flex; flex-direction: column; } +.sx-score em { font-style: normal; font-size: 0.68rem; font-weight: 600; color: var(--text-subtle); } +.sx-score.is-good { color: var(--correct-fg); } +.sx-score.is-fair { color: #b45309; } +.sx-score.is-poor { color: var(--wrong-fg); } +.sx-score.is-none { color: var(--text-subtle); font-weight: 500; font-size: 0.78rem; } +.sx-progress { flex-shrink: 0; min-width: 100px; font-size: 0.8rem; font-weight: 600; color: var(--primary); } +.sx-when { flex-shrink: 0; min-width: 92px; font-size: 0.78rem; color: var(--text-muted); } +.sx-actions { flex-shrink: 0; display: flex; gap: 6px; } + +@media (max-width: 820px) { + .sx-body { grid-template-columns: 1fr; } + .sx-rail { position: static; max-height: none; } + .sx-rail ul { flex-direction: row; flex-wrap: wrap; } + .sx-filter { width: auto; } + .sx-when, .sx-score { min-width: 0; } + .sx-actions { width: 100%; } + .sx-actions .btn { flex: 1; } +} diff --git a/frontend/src/pages/SessionsPage.jsx b/frontend/src/pages/SessionsPage.jsx new file mode 100644 index 0000000..6667153 --- /dev/null +++ b/frontend/src/pages/SessionsPage.jsx @@ -0,0 +1,218 @@ +import { useCallback, useEffect, useMemo, useState } from 'react' +import { Link } from 'react-router-dom' +import api from '../api/client' +import './SessionsPage.css' + +const pct = (value) => (value == null ? null : Math.round(value)) +const band = (value) => (value == null ? '' : value >= 80 ? ' is-good' : value >= 50 ? ' is-fair' : ' is-poor') + +const when = (value) => { + if (!value) return '—' + const date = new Date(value) + const days = Math.round((Date.now() - date) / 86400000) + if (days === 0) return 'Today' + if (days === 1) return 'Yesterday' + if (days < 7) return `${days} days ago` + return date.toLocaleDateString(undefined, { day: '2-digit', month: 'short', year: 'numeric' }) +} + +const STATES = [ + { key: 'all', label: 'Everything' }, + { key: 'in_progress', label: 'In progress' }, + { key: 'completed', label: 'Completed' }, + { key: 'not_started', label: 'Not started' }, +] + +const SORTS = [ + { key: 'recent', label: 'Most recent' }, + { key: 'worst', label: 'Weakest score' }, + { key: 'best', label: 'Best score' }, + { key: 'title', label: 'Name' }, +] + +/** + * 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". + */ +export default function SessionsPage() { + const [rows, setRows] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState('') + const [state, setState] = useState('all') + const [mode, setMode] = useState('all') + const [sort, setSort] = useState('recent') + const [query, setQuery] = useState('') + + const load = useCallback(() => { + setLoading(true) + api.get('/quizzes/sessions') + .then(res => setRows(res.data || [])) + .catch(() => setError('Could not load your sessions')) + .finally(() => setLoading(false)) + }, []) + + useEffect(() => { load() }, [load]) + + const modes = useMemo( + () => [...new Set(rows.map(r => r.mode).filter(Boolean))].sort(), [rows]) + + const shown = useMemo(() => { + const needle = query.trim().toLowerCase() + const filtered = rows.filter(row => ( + (state === 'all' || row.state === state) + && (mode === 'all' || row.mode === mode) + && (!needle || (row.title || '').toLowerCase().includes(needle)) + )) + const by = { + recent: (a, b) => (b.last_activity || '').localeCompare(a.last_activity || ''), + // Nulls last in both directions: a session never attempted is not the + // weakest score, it is the absence of one. + worst: (a, b) => (a.last_percentage ?? 1e9) - (b.last_percentage ?? 1e9), + best: (a, b) => (b.last_percentage ?? -1) - (a.last_percentage ?? -1), + title: (a, b) => (a.title || '').localeCompare(b.title || ''), + } + return [...filtered].sort(by[sort]) + }, [rows, state, mode, sort, query]) + + const counts = useMemo(() => { + const tally = { all: rows.length } + for (const row of rows) tally[row.state] = (tally[row.state] || 0) + 1 + return tally + }, [rows]) + + const attempted = rows.filter(r => r.last_percentage != null) + const average = attempted.length + ? Math.round(attempted.reduce((sum, r) => sum + r.last_percentage, 0) / attempted.length) + : null + + return ( +
+
+
+

Session history

+

Every test you have started or finished.

+
+ Quizzes +
+ +
+ + +
+ {error &&

{error}

} + {loading ?
+ : shown.length === 0 ? ( +
+ {rows.length === 0 ? 'No sessions yet.' : 'Nothing matches those filters.'} +
+ ) : ( + <> +

+ {shown.length} of {rows.length} session{rows.length === 1 ? '' : 's'} +

+
    + {shown.map(row => ( +
  • +
    + {row.title} + + {row.category_name && <>{row.category_name} · } + {row.mode === 'learning' ? 'Study' : row.mode === 'timed' ? 'Exam' : row.mode} + {' · '}{row.questions_per_attempt || row.questions_count} questions + {row.attempts_count > 1 && <> · {row.attempts_count} attempts} + +
    + + {row.state === 'in_progress' ? ( + + {row.answered}/{row.total} answered + + ) : row.last_percentage != null ? ( + + {pct(row.last_percentage)}% + {row.best_percentage != null && row.best_percentage !== row.last_percentage && ( + best {pct(row.best_percentage)}% + )} + + ) : Not attempted} + + {when(row.last_activity)} + + + {row.state === 'in_progress' && ( + Resume + )} + {row.last_attempt_id && ( + Review + )} + {row.state === 'not_started' && ( + Start + )} + +
  • + ))} +
+ + )} +
+
+
+ ) +}