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 ( +