+ )
+}
diff --git a/frontend/src/pages/AnalysisSessionPage.css b/frontend/src/pages/AnalysisSessionPage.css
index 6233fdf..d983a5b 100644
--- a/frontend/src/pages/AnalysisSessionPage.css
+++ b/frontend/src/pages/AnalysisSessionPage.css
@@ -43,13 +43,38 @@
.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. */
-.an-table-wrap { overflow-x: auto; }
-.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); }
+/* The question stays; 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 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 a { color: var(--text); text-decoration: none; }
.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-num { font-variant-numeric: tabular-nums; white-space: nowrap; }
.an-num em { font-style: normal; font-size: 0.72rem; color: var(--text-subtle); }
diff --git a/frontend/src/pages/AnalysisSessionPage.jsx b/frontend/src/pages/AnalysisSessionPage.jsx
index 0e5184d..1e68303 100644
--- a/frontend/src/pages/AnalysisSessionPage.jsx
+++ b/frontend/src/pages/AnalysisSessionPage.jsx
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom'
import AnalysisShell from '../components/AnalysisShell'
+import RepeatSession from '../components/RepeatSession'
import api from '../api/client'
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.
const [page, setPage] = useState(0)
const [confirmDelete, setConfirmDelete] = useState(false)
+ const [repeating, setRepeating] = useState(false)
const [deleting, setDeleting] = useState(false)
const navigate = useNavigate()
@@ -202,10 +204,12 @@ export default function AnalysisSessionPage() {
) : (
<>
- {data.completed_at && data.quiz_id && (
-
- Repeat session
-
+ {/* Repeating rarely means all of it: the questions worth
+ sitting again are the ones you got wrong and the ones you
+ never reached. The dialog asks which, and how many. */}
+ {data.questions?.length > 0 && (
+
)}
Review answers
@@ -268,7 +272,7 @@ export default function AnalysisSessionPage() {
-
Question
+
Question
Status
Difficulty
Time
@@ -278,9 +282,21 @@ export default function AnalysisSessionPage() {
{shown.map(row => (
-
+
{row.position}.
- {row.excerpt}…
+ {/* 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. */}
+
+ {row.excerpt}
+
{row.category && {row.category}}
{row.status}
@@ -310,6 +326,10 @@ export default function AnalysisSessionPage() {
)}
+ {repeating && (
+ setRepeating(false)} />
+ )}
)
}
diff --git a/frontend/src/pages/AnalysisSessionPage.test.jsx b/frontend/src/pages/AnalysisSessionPage.test.jsx
index cbb8752..7c12699 100644
--- a/frontend/src/pages/AnalysisSessionPage.test.jsx
+++ b/frontend/src/pages/AnalysisSessionPage.test.jsx
@@ -93,7 +93,9 @@ describe('a session that has been sat', () => {
const figures = (await screen.findByText("2/2")).closest('.an-figures')
expect(within(figures).getByText('50%')).toBeInTheDocument()
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()
// Nothing outstanding, so no "still unanswered" note.
expect(screen.queryByText(/still unanswered/)).not.toBeInTheDocument()
diff --git a/frontend/src/pages/QuizPage.jsx b/frontend/src/pages/QuizPage.jsx
index a831c94..f74c7be 100644
--- a/frontend/src/pages/QuizPage.jsx
+++ b/frontend/src/pages/QuizPage.jsx
@@ -558,6 +558,18 @@ export default function QuizPage() {
}, [attemptId, quizMode, id, answers, currentIdx, selectedVoice, timeLeft,
startedAt, totalTime, navigate, returnTo])
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 isStudy = quizMode === 'study'
diff --git a/frontend/src/pages/ResultsPage.jsx b/frontend/src/pages/ResultsPage.jsx
index 62b97ab..1d33b6c 100644
--- a/frontend/src/pages/ResultsPage.jsx
+++ b/frontend/src/pages/ResultsPage.jsx
@@ -14,7 +14,13 @@ export default function ResultsPage() {
const returnTo = searchParams.get('return_to')
const [result, setResult] = useState(location.state?.result || null)
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 [responseStats, setResponseStats] = useState(null)
@@ -30,6 +36,11 @@ export default function ResultsPage() {
}
}, [id])
+ useEffect(() => {
+ const total = result?.answers?.length
+ if (total && reviewIndex > total - 1) setReviewIndex(total - 1)
+ }, [result, reviewIndex])
+
const reviewQuestion = result?.answers?.[reviewIndex]
useEffect(() => {
let active = true