fix: the top bar's vibration, the session rail, and three pieces of old design
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
This commit is contained in:
parent
f2b5e146eb
commit
e63c625ed4
12 changed files with 381 additions and 134 deletions
|
|
@ -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() {
|
|||
<Route element={<AppLayout />}>
|
||||
<Route path="/" element={<DashboardPage />} />
|
||||
<Route path="/quizzes" element={<QuizzesPage />} />
|
||||
<Route path="/sessions" element={<SessionsPage />} />
|
||||
<Route path="/quizzes/create" element={<CustomQuizPage />} />
|
||||
<Route path="/quizzes/:id" element={<QuizPage />} />
|
||||
<Route path="/results/:id" element={<ResultsPage />} />
|
||||
|
|
|
|||
|
|
@ -14,6 +14,9 @@ vi.mock('./GlobalSearch', () => ({ default: () => <input aria-label="Search Peds
|
|||
|
||||
const mount = () => render(<MemoryRouter initialEntries={['/articles']}><Navbar /></MemoryRouter>)
|
||||
|
||||
// Long enough for the layout to have settled, which is what the hook waits for.
|
||||
const settle = () => act(async () => { await new Promise(r => setTimeout(r, 400)) })
|
||||
|
||||
const scrollTo = async (y) => {
|
||||
window.scrollY = y
|
||||
await act(async () => {
|
||||
|
|
@ -60,10 +63,24 @@ describe('two-bar header', () => {
|
|||
expect(bar).toHaveClass('is-hidden')
|
||||
|
||||
// Scrolling up is the gesture that means "I am looking for something".
|
||||
await settle()
|
||||
await scrollTo(320)
|
||||
expect(bar).not.toHaveClass('is-hidden')
|
||||
})
|
||||
|
||||
it('does not vibrate when collapsing moves the page under it', async () => {
|
||||
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')
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -117,7 +117,19 @@ function LabValues() {
|
|||
const ordered = [...filtered].sort((a, b) => a.group.localeCompare(b.group) || a.name.localeCompare(b.name))
|
||||
return <>
|
||||
<p className="quiz-reference-note">Ranges vary with age, laboratory and method. Confirm against your own laboratory before relying on a value.</p>
|
||||
{isEducator && <label className="quiz-check"><input type="checkbox" checked={manage} onChange={e => { setManage(e.target.checked); setForm(null); setGroup('All') }} /> Manage references and drafts</label>}
|
||||
{/* "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 && <div className="quiz-lab-editing">
|
||||
<button type="button" className={`quiz-lab-edit-toggle${manage ? ' is-on' : ''}`}
|
||||
aria-pressed={manage}
|
||||
onClick={() => { setManage(!manage); setForm(null); setGroup('All') }}>
|
||||
{manage ? 'Done editing' : 'Edit values'}
|
||||
</button>
|
||||
{manage && <span className="quiz-lab-editing-note">
|
||||
Unpublished values are shown too. Anything you change here every learner sees.
|
||||
</span>}
|
||||
</div>}
|
||||
{error && <p role="alert">{error}</p>}
|
||||
{linkError && <p role="alert">{linkError}</p>}
|
||||
<div className="quiz-reference-controls"><label>Search references<input value={query} onChange={e => setQuery(e.target.value)} /></label>
|
||||
|
|
@ -172,8 +184,9 @@ function LabValues() {
|
|||
</span>
|
||||
))}
|
||||
{manage && <span className="quiz-lab-card-link">
|
||||
<input aria-label={`Card ID to link to ${test.name}`} placeholder="Card ID" value={cardLink[row.id] || ''} onChange={e => setCardLink(prev => ({ ...prev, [row.id]: e.target.value }))} />
|
||||
<button type="button" onClick={() => linkCard(row.id)}>Link card</button>
|
||||
<input aria-label={`Card ID to link to ${test.name}`} placeholder="Flashcard ID"
|
||||
value={cardLink[row.id] || ''} onChange={e => setCardLink(prev => ({ ...prev, [row.id]: e.target.value }))} />
|
||||
<button type="button" onClick={() => linkCard(row.id)}>Attach card</button>
|
||||
</span>}
|
||||
</div>
|
||||
})()}
|
||||
|
|
|
|||
|
|
@ -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'))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -911,13 +911,9 @@ const timerStarted = timeLeft !== null
|
|||
{i + 1}
|
||||
{marked && <span className="quiz-rail-mark" aria-label="Marked">★</span>}
|
||||
</span>
|
||||
{seen && (
|
||||
<span className="quiz-rail-body">
|
||||
<span className="quiz-rail-label">Question {i + 1}</span>
|
||||
<span className="quiz-rail-text">{excerpt.slice(0, 64)}{excerpt.length > 64 ? '…' : ''}</span>
|
||||
{q.difficulty && <span className={`quiz-rail-diff is-${q.difficulty}`}>{q.difficulty}</span>}
|
||||
</span>
|
||||
)}
|
||||
{/* 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 && <span className="quiz-rail-text">{excerpt}</span>}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className="card" style={{ marginTop: 16 }}>
|
||||
<button onClick={load} style={{
|
||||
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
|
||||
width: '100%', background: 'none', border: 'none', cursor: 'pointer',
|
||||
padding: 0, textAlign: 'left',
|
||||
}}>
|
||||
<h2 style={{ fontSize: '1rem', color: 'var(--text)', margin: 0 }}>
|
||||
✅ All past attempts {history !== null && `(${totalCompleted})`}
|
||||
</h2>
|
||||
<span style={{ color: 'var(--text-muted)', fontSize: '0.8rem' }}>
|
||||
{loading ? 'Loading...' : open ? '▲ Hide' : '▼ Show'}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{open && history !== null && (
|
||||
<div style={{ marginTop: 12 }}>
|
||||
{history.length === 0 ? (
|
||||
<div style={{ color: 'var(--text-muted)', fontSize: '0.875rem' }}>No completed attempts yet.</div>
|
||||
) : history.map(quiz => (
|
||||
<div key={quiz.quiz_id} style={{ marginBottom: 14 }}>
|
||||
<div style={{ fontWeight: 600, fontSize: '0.88rem', marginBottom: 6 }}>{quiz.title}</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 5 }}>
|
||||
{quiz.attempts.map((a) => (
|
||||
<div key={a.attempt_id} style={{
|
||||
display: 'flex', alignItems: 'center',
|
||||
padding: '7px 12px', background: 'var(--bg)', borderRadius: 8,
|
||||
fontSize: '0.82rem', gap: 8, flexWrap: 'wrap',
|
||||
}}>
|
||||
<span style={{ color: 'var(--text-muted)', flex: 1 }}>
|
||||
{new Date(a.date).toLocaleDateString()} — {a.score}/{a.total}
|
||||
</span>
|
||||
<span style={{
|
||||
fontWeight: 700, fontSize: '0.85rem',
|
||||
color: a.percentage >= 80 ? 'var(--correct-fg)' : a.percentage >= 50 ? '#d97706' : 'var(--wrong-fg)',
|
||||
}}>{a.percentage}%</span>
|
||||
<Link to={`/results/${a.attempt_id}`} style={{ color: 'var(--primary)', fontSize: '0.78rem', textDecoration: 'none', flexShrink: 0 }}>
|
||||
Review →
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => 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 ? '…' : '✕'}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function HighlightText({ text, query }) {
|
||||
if (!query || !text) return <span>{text}</span>
|
||||
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 && (
|
||||
<div className="qz-seemore">
|
||||
{showAllSessions ? (
|
||||
<button className="btn btn-secondary btn-sm" onClick={() => setShowAllSessions(false)}>
|
||||
Show fewer
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
<button className="btn btn-secondary btn-sm" onClick={() => setShowAllSessions(true)}>
|
||||
Show all {visibleRows.length}
|
||||
</button>
|
||||
<Link className="btn btn-secondary btn-sm" to="/analysis">
|
||||
Full session history →
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
{/* Expanding a list in place answers a smaller question than
|
||||
the one being asked. The history page has the filters. */}
|
||||
<Link className="btn btn-secondary btn-sm" to="/sessions">
|
||||
Full session history ({visibleRows.length}) →
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<PastAttemptsSection />
|
||||
</>
|
||||
)
|
||||
)}
|
||||
|
|
|
|||
77
frontend/src/pages/SessionsPage.css
Normal file
77
frontend/src/pages/SessionsPage.css
Normal file
|
|
@ -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; }
|
||||
}
|
||||
218
frontend/src/pages/SessionsPage.jsx
Normal file
218
frontend/src/pages/SessionsPage.jsx
Normal file
|
|
@ -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 (
|
||||
<div className="sx-page">
|
||||
<div className="sx-header">
|
||||
<div>
|
||||
<h1>Session history</h1>
|
||||
<p>Every test you have started or finished.</p>
|
||||
</div>
|
||||
<Link className="btn btn-secondary" to="/quizzes">Quizzes</Link>
|
||||
</div>
|
||||
|
||||
<div className="sx-body">
|
||||
<aside className="sx-rail">
|
||||
<input className="sx-search" value={query} onChange={e => setQuery(e.target.value)}
|
||||
placeholder="Search by name" aria-label="Search sessions" />
|
||||
|
||||
<h2>Status</h2>
|
||||
<ul>
|
||||
{STATES.map(option => (
|
||||
<li key={option.key}>
|
||||
<button className={`sx-filter${state === option.key ? ' is-active' : ''}`}
|
||||
onClick={() => setState(option.key)}>
|
||||
<span>{option.label}</span>
|
||||
<span className="sx-count">{counts[option.key] || 0}</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{modes.length > 1 && (
|
||||
<>
|
||||
<h2>Mode</h2>
|
||||
<ul>
|
||||
<li>
|
||||
<button className={`sx-filter${mode === 'all' ? ' is-active' : ''}`}
|
||||
onClick={() => setMode('all')}><span>Any mode</span></button>
|
||||
</li>
|
||||
{modes.map(name => (
|
||||
<li key={name}>
|
||||
<button className={`sx-filter${mode === name ? ' is-active' : ''}`}
|
||||
onClick={() => setMode(name)}>
|
||||
<span>{name === 'learning' ? 'Study' : name === 'timed' ? 'Exam' : name}</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
|
||||
<h2>Sort</h2>
|
||||
<ul>
|
||||
{SORTS.map(option => (
|
||||
<li key={option.key}>
|
||||
<button className={`sx-filter${sort === option.key ? ' is-active' : ''}`}
|
||||
onClick={() => setSort(option.key)}><span>{option.label}</span></button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{average != null && (
|
||||
<div className="sx-summary">
|
||||
<strong>{average}%</strong>
|
||||
<span>average across {attempted.length} attempted</span>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
|
||||
<div className="sx-main">
|
||||
{error && <p className="sx-error" role="alert">{error}</p>}
|
||||
{loading ? <div className="loading"><div className="spinner" /></div>
|
||||
: shown.length === 0 ? (
|
||||
<div className="sx-empty">
|
||||
{rows.length === 0 ? 'No sessions yet.' : 'Nothing matches those filters.'}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<p className="sx-count-line" role="status">
|
||||
{shown.length} of {rows.length} session{rows.length === 1 ? '' : 's'}
|
||||
</p>
|
||||
<ul className="sx-list">
|
||||
{shown.map(row => (
|
||||
<li key={row.quiz_id} className={`sx-row is-${row.state}`}>
|
||||
<div className="sx-row-main">
|
||||
<Link className="sx-title" to={`/quizzes/${row.quiz_id}`}>{row.title}</Link>
|
||||
<span className="sx-meta">
|
||||
{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</>}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{row.state === 'in_progress' ? (
|
||||
<span className="sx-progress">
|
||||
{row.answered}/{row.total} answered
|
||||
</span>
|
||||
) : row.last_percentage != null ? (
|
||||
<span className={`sx-score${band(row.last_percentage)}`}>
|
||||
{pct(row.last_percentage)}%
|
||||
{row.best_percentage != null && row.best_percentage !== row.last_percentage && (
|
||||
<em>best {pct(row.best_percentage)}%</em>
|
||||
)}
|
||||
</span>
|
||||
) : <span className="sx-score is-none">Not attempted</span>}
|
||||
|
||||
<span className="sx-when">{when(row.last_activity)}</span>
|
||||
|
||||
<span className="sx-actions">
|
||||
{row.state === 'in_progress' && (
|
||||
<Link className="btn btn-primary btn-sm" to={`/quizzes/${row.quiz_id}`}>Resume</Link>
|
||||
)}
|
||||
{row.last_attempt_id && (
|
||||
<Link className="btn btn-secondary btn-sm" to={`/results/${row.last_attempt_id}`}>Review</Link>
|
||||
)}
|
||||
{row.state === 'not_started' && (
|
||||
<Link className="btn btn-secondary btn-sm" to={`/quizzes/${row.quiz_id}`}>Start</Link>
|
||||
)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Loading…
Reference in a new issue