fix: a session left part-way could not be deleted, and dead space above the footer

`last_attempt_id` counts finished attempts only, so a session with a
live attempt and none completed was addressed as /sessions/q/{quiz} —
a route with no attempt id in it. Delete then sent
`/attempts/undefined` and nothing happened; Review answers pointed at
the same nothing. The page now uses the attempt its own analysis names,
and the rail prefers the live attempt over the last finished one, so the
address has an attempt in it to begin with.

The empty band above the footer was `padding-bottom` reserved for a save
bar that is sticky but still in flow and is the last element on the
page — so the padding sat *under* the bar rather than behind it. Gone
from the custom-test and question-edit pages. The question manager keeps
its padding: that bar is `position: fixed` and genuinely overlays.

Frontend 318/318.

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 18:28:22 +02:00
parent 14a75303a7
commit 73e9de8831
5 changed files with 48 additions and 8 deletions

View file

@ -59,8 +59,14 @@ export default function SessionRail({ sessions, open, onToggle, loading }) {
opens its own overview, which says so and offers to
start it clicking a name used to drop you straight
into a 240-question exam. */}
<NavLink to={row.last_attempt_id
? `/sessions/${row.last_attempt_id}` : `/sessions/q/${row.quiz_id}`}>
{/* The attempt to read about is the live one if there is
one, otherwise the last finished. `last_attempt_id`
counts only finished attempts, so a session left
part-way used to be addressed by quiz and the page
then had no attempt id to delete or review with. */}
<NavLink to={(row.active_attempt_id || row.last_attempt_id)
? `/sessions/${row.active_attempt_id || row.last_attempt_id}`
: `/sessions/q/${row.quiz_id}`}>
<span className="ax-rail-title">
<strong>{row.mode === 'learning' ? 'Study mode:' : 'Exam mode:'}</strong> {row.title}
</span>

View file

@ -84,7 +84,10 @@ export default function AnalysisSessionPage() {
const deleteSession = async () => {
setDeleting(true)
try {
await api.delete(`/attempts/${attemptId}`)
// The attempt the page is showing, not the one named in the URL: reached
// by quiz (a session whose only attempt is still in progress) there is no
// attemptId in the path, and this deleted "/attempts/undefined".
await api.delete(`/attempts/${data?.attempt_id ?? attemptId}`)
navigate('/sessions', { replace: true })
} catch {
setDeleting(false)
@ -134,7 +137,7 @@ export default function AnalysisSessionPage() {
<Link className="btn btn-primary btn-sm" to={`/study/${data.quiz_id}`}>Start this session</Link>
) : (
<>
<Link className="btn btn-secondary btn-sm" to={`/results/${attemptId}`}>Review answers</Link>
<Link className="btn btn-secondary btn-sm" to={`/results/${data.attempt_id ?? attemptId}`}>Review answers</Link>
{!data.completed_at && (
<Link className="btn btn-primary btn-sm" to={`/study/${data.quiz_id}`}>Resume</Link>
)}

View file

@ -1,5 +1,6 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { render, screen, within } from '@testing-library/react'
import { render, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { MemoryRouter, Route, Routes } from 'react-router-dom'
import AnalysisSessionPage from './AnalysisSessionPage'
import api from '../api/client'
@ -106,6 +107,31 @@ describe('a session that has been sat', () => {
})
})
describe('a session whose only attempt is still in progress', () => {
beforeEach(() => {
vi.clearAllMocks()
// Reached by quiz, because last_attempt_id counts finished attempts only
// but the analysis it answers with does name the live attempt.
mock({ ...SAT, attempt_id: 77, completed_at: null, answered: 0, score: 0, percent: 0 })
})
it('deletes the attempt the page is showing, not the one named in the URL', async () => {
mountQuiz()
await screen.findByRole('button', { name: 'Delete session' })
await userEvent.click(screen.getByRole('button', { name: 'Delete session' }))
api.delete.mockResolvedValue({})
await userEvent.click(screen.getByRole('button', { name: 'Delete for good' }))
// It used to send /attempts/undefined, and nothing happened.
await waitFor(() => expect(api.delete).toHaveBeenCalledWith('/attempts/77'))
})
it('reviews that attempt too', async () => {
mountQuiz()
expect(await screen.findByRole('link', { name: 'Review answers' }))
.toHaveAttribute('href', '/results/77')
})
})
describe('a session left part way', () => {
beforeEach(() => {
vi.clearAllMocks()

View file

@ -1,7 +1,10 @@
/* Custom test builder AMBOSS-style facet rows that open a picker panel,
instead of a cramped sidebar of nested scrolling checkbox lists. */
.custom-test { max-width: 1120px; margin: 0 auto; padding-bottom: 100px; }
/* No bottom padding: the save bar below is sticky but still in flow, and it
is the last thing on the page so padding under it is dead space between
the bar and the footer, not room for it to sit over. */
.custom-test { max-width: 1120px; margin: 0 auto; }
.custom-test-top {
display: grid; grid-template-columns: 1fr auto; align-items: end;

View file

@ -1,7 +1,10 @@
/* Full-page question editor room to read the stem and pick categories,
instead of a cramped modal. Mobile-first: the aside drops below the form. */
.qe-page { max-width: 1180px; margin: 0 auto; padding-bottom: 90px; }
/* No bottom padding: the save bar below is sticky but still in flow, and it
is the last thing on the page so padding under it is dead space between
the bar and the footer, not room for it to sit over. */
.qe-page { max-width: 1180px; margin: 0 auto; }
/* The way out stays reachable. This page is long enough that scrolling to the
stem loses the back link entirely, and the save bar is pinned at the bottom
@ -120,7 +123,6 @@
.qe-aside { position: static; }
}
@media (max-width: 640px) {
.qe-page { padding-bottom: 110px; }
.qe-top-actions { width: 100%; }
.qe-top-actions .btn { flex: 1; }
.qe-save { flex: 1; margin-left: 0; }