From e63c625ed440472136b8002fac64774a173c006f Mon Sep 17 00:00:00 2001 From: Daniel Date: Fri, 11 Sep 2026 02:51:46 +0200 Subject: [PATCH] fix: the top bar's vibration, the session rail, and three pieces of old design MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bar vibrated because I made it fight itself Collapsing it takes 46px out of the page, the browser corrects the scroll position to compensate, and that correction arrives as an upward scroll — which shows the bar, lengthens the page, and starts the loop again. The 6px jitter guard could not help: these were real scroll events, just ones we had caused. State now cannot change until the layout has settled, and a test reproduces the echo rather than trusting the fix. Session history, built properly "Show all 15" expanded a list in place, which answers a smaller question than the one being asked. There is now a real /sessions page: a rail that filters by status and mode and sorts by recency, weakest or best, a running average across what has actually been attempted, and nothing truncated. The quizzes page keeps its preview and links straight to it. A session never attempted sorts last under both "weakest" and "best" — the absence of a score is not a bad one. The session rail is one row per question A five-line excerpt per question turned a rail of twenty into a page of its own. Number, then the stem on one line, truncated. The repeated "Question N" label said nothing the number had not. "Manage references and drafts" said nothing useful "References" there means lab values rather than citations, and "drafts" means unpublished ones — an editing switch hidden in a learner's lookup, phrased as neither. It is now an "Edit values" toggle that says, while it is on, that unpublished values are showing and that changes reach every learner. Also gone: the "All past attempts" block, which the Sessions view had already replaced. 248 frontend tests green. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN --- frontend/src/App.jsx | 2 + frontend/src/components/Navbar.test.jsx | 17 ++ frontend/src/components/QuizTools.css | 12 ++ frontend/src/components/QuizTools.jsx | 19 +- frontend/src/components/QuizTools.test.jsx | 4 +- frontend/src/hooks/useHidingBar.js | 21 +- frontend/src/pages/QuizPage.jsx | 10 +- frontend/src/pages/QuizPage.test.jsx | 8 +- frontend/src/pages/QuizPlayer.css | 8 +- frontend/src/pages/QuizzesPage.jsx | 119 +---------- frontend/src/pages/SessionsPage.css | 77 ++++++++ frontend/src/pages/SessionsPage.jsx | 218 +++++++++++++++++++++ 12 files changed, 381 insertions(+), 134 deletions(-) create mode 100644 frontend/src/pages/SessionsPage.css create mode 100644 frontend/src/pages/SessionsPage.jsx 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 + )} + +
  • + ))} +
+ + )} +
+
+
+ ) +}