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 && { setManage(e.target.checked); setForm(null); setGroup('All') }} /> Manage references and drafts }
+ {/* "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 &&
+ { setManage(!manage); setForm(null); setGroup('All') }}>
+ {manage ? 'Done editing' : 'Edit values'}
+
+ {manage &&
+ Unpublished values are shown too. Anything you change here every learner sees.
+ }
+
}
{error && {error}
}
{linkError && {linkError}
}
Search references setQuery(e.target.value)} />
@@ -172,8 +184,9 @@ function LabValues() {
))}
{manage &&
- setCardLink(prev => ({ ...prev, [row.id]: e.target.value }))} />
- linkCard(row.id)}>Link card
+ setCardLink(prev => ({ ...prev, [row.id]: e.target.value }))} />
+ linkCard(row.id)}>Attach card
}
})()}
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 (
-
-
-
- ✅ All past attempts {history !== null && `(${totalCompleted})`}
-
-
- {loading ? 'Loading...' : open ? '▲ Hide' : '▼ Show'}
-
-
-
- {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 →
-
- deleteAttempt(a.attempt_id)}
- disabled={deletingId === a.attempt_id}
- title="Delete this attempt"
- style={{
- background: 'none', border: 'none', cursor: 'pointer',
- color: 'var(--wrong-fg)', fontSize: '0.78rem',
- padding: '2px 4px', borderRadius: 4, flexShrink: 0,
- opacity: deletingId === a.attempt_id ? 0.4 : 0.6,
- }}
- >
- {deletingId === a.attempt_id ? '…' : '✕'}
-
-
- ))}
-
-
- ))}
-
- )}
-
- )
-}
-
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 ? (
- setShowAllSessions(false)}>
- Show fewer
-
- ) : (
- <>
- setShowAllSessions(true)}>
- Show all {visibleRows.length}
-
-
- 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'}
+
+
+ >
+ )}
+
+
+
+ )
+}