feat: click a question and land on it; repeat only the ones worth repeating

The analytics table linked to /results/{attempt}?q=3 and the page ignored
the q entirely, so clicking the ninth row put you at the top of the
session to page through and find it again. Both the review and the
player honour it now. A session still running opens in the player at
that question, ready to be answered; a finished one opens its review
there.

The question column is pinned while the measures scroll past it. Five
columns do not fit a phone and barely fit a laptop, and the one you need
in order to know which row you are reading is the first — so it stays,
with the stem cut to a line and the whole of it on the link's title.

Repeat session is a dialog rather than a restart. Sitting all of it
again is rarely what anyone wants: the questions worth doing again are
the ones you got wrong and the ones you never reached, and mixing in
forty you already know turns twenty useful minutes into an hour of
mostly not. It asks which outcomes and how many, counts what is
available for each, and builds a session from exactly those — shuffled,
so repeating twice is not the same order twice.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
This commit is contained in:
Daniel 2026-09-11 23:07:45 +02:00
parent 27a1679774
commit f9acdf42fa
7 changed files with 266 additions and 13 deletions

View file

@ -0,0 +1,59 @@
.rs-overlay {
position: fixed; inset: 0; z-index: 1200;
display: flex; align-items: center; justify-content: center; padding: 20px;
background: rgba(15, 23, 42, 0.55);
}
.rs {
width: min(440px, 100%); max-height: calc(100dvh - 40px); overflow-y: auto;
padding: 20px; border-radius: 14px; background: var(--card-bg);
box-shadow: 0 18px 48px rgba(15, 23, 42, 0.28);
}
.rs-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.rs-head h2 { margin: 0; font-size: 1.05rem; }
.rs-head button {
border: 0; background: none; padding: 4px 8px; font-size: 1rem; line-height: 1;
cursor: pointer; color: var(--text-muted);
}
.rs-outcomes { margin: 14px 0 0; padding: 0; border: 0; }
.rs-outcomes legend {
padding: 0; margin-bottom: 8px;
font-size: 0.66rem; font-weight: 700; letter-spacing: 0.07em;
text-transform: uppercase; color: var(--text-subtle);
}
.rs-outcomes label {
display: flex; align-items: center; gap: 10px;
padding: 9px 2px; cursor: pointer; font-size: 0.9rem;
border-bottom: 1px solid var(--border);
}
.rs-outcomes label:last-of-type { border-bottom: 0; }
.rs-outcomes label.is-empty { opacity: 0.45; cursor: default; }
.rs-outcomes label span { flex: 1; }
.rs-outcomes label em {
font-style: normal; font-size: 0.8rem; font-variant-numeric: tabular-nums;
color: var(--text-muted);
}
.rs-dot { width: 10px; height: 10px; border-radius: 50%; flex: none; }
.rs-dot.is-right { background: var(--correct-fg); }
.rs-dot.is-wrong { background: var(--wrong-fg); }
.rs-dot.is-none { background: var(--border); }
.rs-count { margin-top: 16px; padding-top: 14px; border-top: 1px solid var(--border); }
.rs-count > label {
display: block; margin-bottom: 8px;
font-size: 0.66rem; font-weight: 700; letter-spacing: 0.07em;
text-transform: uppercase; color: var(--text-subtle);
}
.rs-count > div { display: flex; align-items: center; gap: 12px; }
.rs-count input[type="range"] { flex: 1; min-width: 0; accent-color: var(--primary); }
.rs-count-value { font-size: 0.85rem; color: var(--text-muted); white-space: nowrap; }
.rs-count-value strong { font-size: 1.05rem; color: var(--text); font-variant-numeric: tabular-nums; }
.rs-start { width: 100%; margin-top: 18px; }
.rs-error { margin: 12px 0 0; font-size: 0.82rem; color: var(--wrong-fg); }
.rs-note { margin: 10px 0 0; font-size: 0.82rem; color: var(--text-muted); text-align: center; }
@media (max-width: 520px) {
.rs-overlay { padding: 0; align-items: flex-end; }
.rs { max-height: 88dvh; border-radius: 14px 14px 0 0; padding-bottom: max(20px, env(safe-area-inset-bottom)); }
}

View file

@ -0,0 +1,124 @@
import { useEffect, useMemo, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import api from '../api/client'
import './RepeatSession.css'
//: The outcomes a question can have had, and whether to sit it again.
const OUTCOMES = [
{ key: 'skipped', label: 'Not yet answered', tone: 'none' },
{ key: 'incorrect', label: 'Answered incorrectly', tone: 'wrong' },
{ key: 'correct', label: 'Answered correctly', tone: 'right' },
]
/**
* Sitting a session again but not necessarily all of it.
*
* Repeat used to mean "start this whole thing over", which is rarely what
* anyone wants: the questions worth doing again are the ones you got wrong and
* the ones you never reached, and mixing in forty you already know turns
* twenty minutes of useful work into an hour of mostly not.
*
* So it asks two things which outcomes, and how many and builds a session
* from exactly those.
*/
export default function RepeatSession({ title, rows, onClose }) {
const navigate = useNavigate()
const [chosen, setChosen] = useState(() => new Set(['skipped', 'incorrect']))
const [count, setCount] = useState(0)
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
const byOutcome = useMemo(() => {
const groups = { skipped: [], incorrect: [], correct: [] }
for (const row of rows || []) (groups[row.status] ||= []).push(row.question_id)
return groups
}, [rows])
const pool = useMemo(
() => OUTCOMES.filter(o => chosen.has(o.key)).flatMap(o => byOutcome[o.key] || []),
[chosen, byOutcome])
// Follow the pool: widening the selection should offer the questions it just
// added, not leave the count where it was.
useEffect(() => { setCount(pool.length) }, [pool.length])
useEffect(() => {
const onKey = e => { if (e.key === 'Escape') onClose() }
document.addEventListener('keydown', onKey)
return () => document.removeEventListener('keydown', onKey)
}, [onClose])
const toggle = (key) => setChosen(prev => {
const next = new Set(prev)
if (next.has(key)) next.delete(key)
else next.add(key)
return next
})
const start = async () => {
setBusy(true); setError('')
try {
// Shuffled, so repeating twice is not the same order twice.
const ids = [...pool].sort(() => Math.random() - 0.5).slice(0, count)
const res = await api.post('/questions/from-bank', {
title: `${title} — again`,
question_ids: ids,
mode: 'study',
})
navigate(`/study/${res.data.id}?start=1`)
} catch (err) {
const detail = err?.response?.data?.detail
setError(typeof detail === 'string' ? detail : 'Could not build that session')
setBusy(false)
}
}
return (
<div className="rs-overlay" onClick={e => e.target === e.currentTarget && onClose()}>
<div className="rs" role="dialog" aria-modal="true" aria-labelledby="rs-heading">
<div className="rs-head">
<h2 id="rs-heading">Repeat this session</h2>
<button type="button" onClick={onClose} aria-label="Close"></button>
</div>
<fieldset className="rs-outcomes">
<legend>Include questions that were</legend>
{OUTCOMES.map(outcome => {
const available = (byOutcome[outcome.key] || []).length
return (
<label key={outcome.key} className={available === 0 ? 'is-empty' : undefined}>
<input type="checkbox" checked={chosen.has(outcome.key)} disabled={available === 0}
onChange={() => toggle(outcome.key)} />
<i className={`rs-dot is-${outcome.tone}`} aria-hidden="true" />
<span>{outcome.label}</span>
<em>{available}</em>
</label>
)
})}
</fieldset>
<div className="rs-count">
<label htmlFor="rs-count">How many</label>
<div>
<input id="rs-count" type="range" min={pool.length ? 1 : 0} max={pool.length || 0}
value={count} disabled={pool.length === 0}
onChange={e => setCount(Number(e.target.value))} />
<span className="rs-count-value">
<strong>{count}</strong> / {pool.length}
</span>
</div>
</div>
{error && <p className="rs-error" role="alert">{error}</p>}
<button type="button" className="btn btn-primary rs-start"
disabled={busy || count === 0} onClick={start}>
{busy ? 'Building…' : `Start ${count} question${count === 1 ? '' : 's'}`}
</button>
{pool.length === 0 && (
<p className="rs-note">Nothing matches those outcomes in this session.</p>
)}
</div>
</div>
)
}

View file

@ -43,13 +43,38 @@
.an-sorts button.is-on { background: var(--option-sel-bg); border-color: var(--primary); color: var(--primary); font-weight: 650; } .an-sorts button.is-on { background: var(--option-sel-bg); border-color: var(--primary); color: var(--primary); font-weight: 650; }
/* Wide on purpose; it scrolls in its own box rather than pushing the page. */ /* Wide on purpose; it scrolls in its own box rather than pushing the page. */
.an-table-wrap { overflow-x: auto; } /* The question stays; the measures scroll past it.
.an-table { width: 100%; border-collapse: collapse; font-size: 0.85rem; } *
.an-table th { text-align: left; padding: 8px 10px; font-size: 0.72rem; font-weight: 700; letter-spacing: 0.05em; text-transform: uppercase; color: var(--text-subtle); border-bottom: 1px solid var(--border); } * Five columns do not fit a phone and barely fit a laptop, and the one you
* need in order to know which row you are reading is the first. So it is
* pinned to the left edge and the rest scroll under it which only works if
* it has a fixed width, so the stem is cut to one line with the whole of it
* on the link's title. */
.an-table-wrap { overflow-x: auto; overscroll-behavior-x: contain; }
.an-table { width: 100%; border-collapse: separate; border-spacing: 0; font-size: 0.85rem; }
.an-col-q {
position: sticky; left: 0; z-index: 2;
width: clamp(200px, 38vw, 420px);
min-width: clamp(200px, 38vw, 420px);
/* Opaque, or the scrolled columns show through as they pass beneath. */
background: var(--card-bg);
box-shadow: 1px 0 0 var(--border);
}
thead .an-col-q { z-index: 3; }
/* Number, then stem, then the topic under both. The stem takes what is left
and is cut there, so the number is never what gets truncated. */
td.an-col-q { display: flex; flex-wrap: wrap; align-items: baseline; gap: 6px; }
.an-qtext {
flex: 1; min-width: 0;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.an-col-q .an-qcat { flex-basis: 100%; }
.an-table th { text-align: left; white-space: nowrap; padding: 8px 10px; font-size: 0.72rem; font-weight: 700; letter-spacing: 0.05em; text-transform: uppercase; color: var(--text-subtle); border-bottom: 1px solid var(--border); }
.an-table td { padding: 10px; border-bottom: 1px solid var(--border); vertical-align: top; } .an-table td { padding: 10px; border-bottom: 1px solid var(--border); vertical-align: top; }
.an-table a { color: var(--text); text-decoration: none; } .an-table a { color: var(--text); text-decoration: none; }
.an-table a:hover { color: var(--primary); } .an-table a:hover { color: var(--primary); }
.an-qnum { color: var(--text-subtle); margin-right: 6px; font-variant-numeric: tabular-nums; } .an-qnum { color: var(--text-subtle); font-variant-numeric: tabular-nums; flex: none; }
.an-qcat { display: block; margin-top: 3px; font-style: normal; font-size: 0.72rem; color: var(--text-subtle); } .an-qcat { display: block; margin-top: 3px; font-style: normal; font-size: 0.72rem; color: var(--text-subtle); }
.an-num { font-variant-numeric: tabular-nums; white-space: nowrap; } .an-num { font-variant-numeric: tabular-nums; white-space: nowrap; }
.an-num em { font-style: normal; font-size: 0.72rem; color: var(--text-subtle); } .an-num em { font-style: normal; font-size: 0.72rem; color: var(--text-subtle); }

View file

@ -1,6 +1,7 @@
import { useCallback, useEffect, useMemo, useState } from 'react' import { useCallback, useEffect, useMemo, useState } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom' import { Link, useNavigate, useParams } from 'react-router-dom'
import AnalysisShell from '../components/AnalysisShell' import AnalysisShell from '../components/AnalysisShell'
import RepeatSession from '../components/RepeatSession'
import api from '../api/client' import api from '../api/client'
import './AnalysisSessionPage.css' import './AnalysisSessionPage.css'
@ -78,6 +79,7 @@ export default function AnalysisSessionPage() {
// Ten at a time: a session of forty is a table nobody reads to the end of. // Ten at a time: a session of forty is a table nobody reads to the end of.
const [page, setPage] = useState(0) const [page, setPage] = useState(0)
const [confirmDelete, setConfirmDelete] = useState(false) const [confirmDelete, setConfirmDelete] = useState(false)
const [repeating, setRepeating] = useState(false)
const [deleting, setDeleting] = useState(false) const [deleting, setDeleting] = useState(false)
const navigate = useNavigate() const navigate = useNavigate()
@ -202,10 +204,12 @@ export default function AnalysisSessionPage() {
</Link> </Link>
) : ( ) : (
<> <>
{data.completed_at && data.quiz_id && ( {/* Repeating rarely means all of it: the questions worth
<Link className="btn btn-secondary" to={`/study/${data.quiz_id}?restart=1`}> sitting again are the ones you got wrong and the ones you
Repeat session never reached. The dialog asks which, and how many. */}
</Link> {data.questions?.length > 0 && (
<button type="button" className="btn btn-secondary"
onClick={() => setRepeating(true)}>Repeat session</button>
)} )}
<Link className="btn btn-secondary" <Link className="btn btn-secondary"
to={`/results/${data.attempt_id ?? attemptId}`}>Review answers</Link> to={`/results/${data.attempt_id ?? attemptId}`}>Review answers</Link>
@ -268,7 +272,7 @@ export default function AnalysisSessionPage() {
<table className="an-table"> <table className="an-table">
<thead> <thead>
<tr> <tr>
<th scope="col">Question</th> <th scope="col" className="an-col-q">Question</th>
<th scope="col">Status</th> <th scope="col">Status</th>
<th scope="col">Difficulty</th> <th scope="col">Difficulty</th>
<th scope="col">Time</th> <th scope="col">Time</th>
@ -278,9 +282,21 @@ export default function AnalysisSessionPage() {
<tbody> <tbody>
{shown.map(row => ( {shown.map(row => (
<tr key={row.question_id}> <tr key={row.question_id}>
<td> <td className="an-col-q">
<span className="an-qnum">{row.position}.</span> <span className="an-qnum">{row.position}.</span>
<Link to={`/results/${attemptId}?q=${row.position}`}>{row.excerpt}</Link> {/* One line, cut with an ellipsis: the column is pinned
while the rest scroll, so it must have a fixed width
and a stem is longer than any width would be. */}
{/* A session still running opens in the player at that
question, ready to be answered. A finished one opens
its review. Same click, and in both cases it lands on
the question rather than the top of the session. */}
<Link className="an-qtext" title={row.excerpt}
to={data.completed_at
? `/results/${data.attempt_id ?? attemptId}?q=${row.position}`
: `/study/${data.quiz_id}?start=1&q=${row.position}`}>
{row.excerpt}
</Link>
{row.category && <em className="an-qcat">{row.category}</em>} {row.category && <em className="an-qcat">{row.category}</em>}
</td> </td>
<td><span className={`an-status is-${row.status}`}>{row.status}</span></td> <td><span className={`an-status is-${row.status}`}>{row.status}</span></td>
@ -310,6 +326,10 @@ export default function AnalysisSessionPage() {
)} )}
</section> </section>
</div> </div>
{repeating && (
<RepeatSession title={data.title} rows={data.questions}
onClose={() => setRepeating(false)} />
)}
</AnalysisShell> </AnalysisShell>
) )
} }

View file

@ -93,7 +93,9 @@ describe('a session that has been sat', () => {
const figures = (await screen.findByText("2/2")).closest('.an-figures') const figures = (await screen.findByText("2/2")).closest('.an-figures')
expect(within(figures).getByText('50%')).toBeInTheDocument() expect(within(figures).getByText('50%')).toBeInTheDocument()
expect(screen.getByRole('link', { name: 'Review answers' })).toHaveAttribute('href', '/results/91') expect(screen.getByRole('link', { name: 'Review answers' })).toHaveAttribute('href', '/results/91')
expect(screen.getByRole('link', { name: 'Repeat session' })).toHaveAttribute('href', '/study/3?restart=1') // Repeating opens a dialog: which outcomes, and how many. Sitting all of
// it again is rarely what anyone wants.
expect(screen.getByRole('button', { name: 'Repeat session' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Delete session' })).toBeInTheDocument() expect(screen.getByRole('button', { name: 'Delete session' })).toBeInTheDocument()
// Nothing outstanding, so no "still unanswered" note. // Nothing outstanding, so no "still unanswered" note.
expect(screen.queryByText(/still unanswered/)).not.toBeInTheDocument() expect(screen.queryByText(/still unanswered/)).not.toBeInTheDocument()

View file

@ -558,6 +558,18 @@ export default function QuizPage() {
}, [attemptId, quizMode, id, answers, currentIdx, selectedVoice, timeLeft, }, [attemptId, quizMode, id, answers, currentIdx, selectedVoice, timeLeft,
startedAt, totalTime, navigate, returnTo]) startedAt, totalTime, navigate, returnTo])
const questions = quiz?.questions || [] const questions = quiz?.questions || []
// ?q=3 means "open on question 3". The analytics table links here that way:
// clicking a row in a session you have not finished should put you on that
// question, ready to answer it, rather than back at the start.
const wantedQuestion = Number(searchParams.get('q'))
const jumped = useRef(false)
useEffect(() => {
if (jumped.current || !questions.length) return
if (!Number.isInteger(wantedQuestion) || wantedQuestion < 1) return
jumped.current = true
setCurrentIdx(Math.min(wantedQuestion, questions.length) - 1)
}, [questions.length, wantedQuestion])
const current = questions[currentIdx] const current = questions[currentIdx]
const isStudy = quizMode === 'study' const isStudy = quizMode === 'study'

View file

@ -14,7 +14,13 @@ export default function ResultsPage() {
const returnTo = searchParams.get('return_to') const returnTo = searchParams.get('return_to')
const [result, setResult] = useState(location.state?.result || null) const [result, setResult] = useState(location.state?.result || null)
const [loading, setLoading] = useState(!result) const [loading, setLoading] = useState(!result)
const [reviewIndex, setReviewIndex] = useState(0) // ?q=3 means "open on question 3", which is how the analytics table links
// here: clicking a row should land on that question rather than at the top
// of a session you then have to page through to find it.
const [reviewIndex, setReviewIndex] = useState(() => {
const wanted = Number(searchParams.get('q'))
return Number.isInteger(wanted) && wanted > 0 ? wanted - 1 : 0
})
const [tool, setTool] = useState(null) const [tool, setTool] = useState(null)
const [responseStats, setResponseStats] = useState(null) const [responseStats, setResponseStats] = useState(null)
@ -30,6 +36,11 @@ export default function ResultsPage() {
} }
}, [id]) }, [id])
useEffect(() => {
const total = result?.answers?.length
if (total && reviewIndex > total - 1) setReviewIndex(total - 1)
}, [result, reviewIndex])
const reviewQuestion = result?.answers?.[reviewIndex] const reviewQuestion = result?.answers?.[reviewIndex]
useEffect(() => { useEffect(() => {
let active = true let active = true