From 73e9de8831c51e965cebdd29315f1132f821a10c Mon Sep 17 00:00:00 2001 From: Daniel Date: Fri, 11 Sep 2026 18:28:22 +0200 Subject: [PATCH] fix: a session left part-way could not be deleted, and dead space above the footer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN --- frontend/src/components/SessionRail.jsx | 10 +++++-- frontend/src/pages/AnalysisSessionPage.jsx | 7 +++-- .../src/pages/AnalysisSessionPage.test.jsx | 28 ++++++++++++++++++- frontend/src/pages/CustomQuizPage.css | 5 +++- frontend/src/pages/QuestionEditPage.css | 6 ++-- 5 files changed, 48 insertions(+), 8 deletions(-) diff --git a/frontend/src/components/SessionRail.jsx b/frontend/src/components/SessionRail.jsx index 51ef2ca..406cfe3 100644 --- a/frontend/src/components/SessionRail.jsx +++ b/frontend/src/components/SessionRail.jsx @@ -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. */} - + {/* 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. */} + {row.mode === 'learning' ? 'Study mode:' : 'Exam mode:'} {row.title} diff --git a/frontend/src/pages/AnalysisSessionPage.jsx b/frontend/src/pages/AnalysisSessionPage.jsx index 6d86068..8d85fdf 100644 --- a/frontend/src/pages/AnalysisSessionPage.jsx +++ b/frontend/src/pages/AnalysisSessionPage.jsx @@ -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() { Start this session ) : ( <> - Review answers + Review answers {!data.completed_at && ( Resume )} diff --git a/frontend/src/pages/AnalysisSessionPage.test.jsx b/frontend/src/pages/AnalysisSessionPage.test.jsx index 0a9a921..5fbb197 100644 --- a/frontend/src/pages/AnalysisSessionPage.test.jsx +++ b/frontend/src/pages/AnalysisSessionPage.test.jsx @@ -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() diff --git a/frontend/src/pages/CustomQuizPage.css b/frontend/src/pages/CustomQuizPage.css index c4329e0..7c06f3f 100644 --- a/frontend/src/pages/CustomQuizPage.css +++ b/frontend/src/pages/CustomQuizPage.css @@ -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; diff --git a/frontend/src/pages/QuestionEditPage.css b/frontend/src/pages/QuestionEditPage.css index 3096901..485c29f 100644 --- a/frontend/src/pages/QuestionEditPage.css +++ b/frontend/src/pages/QuestionEditPage.css @@ -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; }