From f9acdf42fae1d1277bd08f93e66c10c8242eddce Mon Sep 17 00:00:00 2001 From: Daniel Date: Fri, 11 Sep 2026 23:07:45 +0200 Subject: [PATCH] feat: click a question and land on it; repeat only the ones worth repeating MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN --- frontend/src/components/RepeatSession.css | 59 +++++++++ frontend/src/components/RepeatSession.jsx | 124 ++++++++++++++++++ frontend/src/pages/AnalysisSessionPage.css | 33 ++++- frontend/src/pages/AnalysisSessionPage.jsx | 34 ++++- .../src/pages/AnalysisSessionPage.test.jsx | 4 +- frontend/src/pages/QuizPage.jsx | 12 ++ frontend/src/pages/ResultsPage.jsx | 13 +- 7 files changed, 266 insertions(+), 13 deletions(-) create mode 100644 frontend/src/components/RepeatSession.css create mode 100644 frontend/src/components/RepeatSession.jsx diff --git a/frontend/src/components/RepeatSession.css b/frontend/src/components/RepeatSession.css new file mode 100644 index 0000000..4a849b8 --- /dev/null +++ b/frontend/src/components/RepeatSession.css @@ -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)); } +} diff --git a/frontend/src/components/RepeatSession.jsx b/frontend/src/components/RepeatSession.jsx new file mode 100644 index 0000000..3916dad --- /dev/null +++ b/frontend/src/components/RepeatSession.jsx @@ -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 ( +
e.target === e.currentTarget && onClose()}> +
+
+

Repeat this session

+ +
+ +
+ Include questions that were + {OUTCOMES.map(outcome => { + const available = (byOutcome[outcome.key] || []).length + return ( +