ux(transcription): show error toast when bulk "Alle als fertig markieren" fails #356
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Context
PR #352 ships
markAllReviewed()indocuments/[id]/+page.sveltewith a silent failure path:No error toast, no user feedback. The
@Transactionalguarantee means block states are unchanged in the database on failure (all-or-nothing), but the user receives no indication the operation failed.Raised by Felix (Developer) and Elicit (Requirements) in PR #352 Decision Queue (Item 4).
This is the same pattern as
reviewToggle, but silent failure is more disorienting for a document-wide bulk action than for a single block toggle.Acceptance criteria
PUT /transcription-blocks/review-allreturns a non-2xx response, a visible error message is shown to the user (toast or inline)Links
👨💻 Markus Keller — Application Architect
Observations
@Transactionalguarantee is correctly noted.ocrErrorMessage/ocrProgressMessagestate variables rendered inline in the same component). There is no shared Toast component insrc/lib/shared/primitives/, which confirms the right pattern here is inline, not a new cross-cutting toast system.markAllReviewedlives in+page.svelteand hands state down toTranscriptionEditView. The callback chain is:+page.svelte:markAllReviewed→ proponMarkAllReviewed→TranscriptionEditView:handleMarkAllReviewed. Error state belongs inTranscriptionEditView(the component that owns the button and themarkingAllReviewedguard state), not threaded up to the page.reviewTogglesilent failure is a separate pre-existing issue. This issue correctly scopes itself to the bulk action only. Don't fix both in the same PR.Recommendations
let markAllError = $state<string | null>(null)reactive variable insideTranscriptionEditView.svelte. Set it in thecatchbranch ofhandleMarkAllReviewed, clear it on the next successful call.ocrErrorMessagein+page.svelte. Arole="alert"div with a dismiss button is sufficient and consistent with what already exists.onMarkAllReviewedprop currently has type() => Promise<void>— leave it as-is. Let the callback throw on failure (whichmarkAllReviewedin+page.sveltecurrently does not), and catch inhandleMarkAllReviewed. The fix belongs in+page.svelte:markAllReviewed: replaceif (!res.ok) return;withif (!res.ok) throw new Error(...)so the error propagates tohandleMarkAllReviewed's try/catch.👨💻 Felix Brandt — Fullstack Developer
Observations
The current
markAllReviewedfunction in+page.svelte(lines 155–165):Three concrete problems:
if (!res.ok) returnswallows the failure — thehandleMarkAllReviewedwrapper inTranscriptionEditViewhas atry/finallythat correctly resetsmarkingAllReviewed, but nocatchto surface the error.messages/{de,en,es}.json.The fix is three changes:
+page.svelte— throw on failure so the error propagates:TranscriptionEditView.svelte— add error state and render it:Then render below the progress bar:
Recommendations
+page.sveltefirst — that is the root cause. ThehandleMarkAllReviewedwrapper already hastry/finallyin place; it just needs acatchbranch.getErrorMessageimport toTranscriptionEditView.svelte— it already exists in$lib/shared/errors.transcription_mark_all_reviewed_errorto all threemessages/*.jsonfiles. Use existing error string patterns as reference.TranscriptionEditViewthat mocksonMarkAllReviewedto reject, then asserts the error text appears and the dismiss button clears it — before writing any production code.👨💻 Tobias Wendt — DevOps & Platform Engineer
Observations
PUT /transcription-blocks/review-allendpoint is already exercised by the backend test suite (TranscriptionBlockControllerTest,TranscriptionServiceTest). No new backend CI jobs are needed.notificationStore/NotificationBellis for server-push notifications (SSE), not transient client-side error feedback. Using it for this would be misusing the wrong system.TranscriptionEditViewwill slot into the existingnpm run testjob without additional CI configuration.Recommendations
TranscriptionEditView.svelte.spec.ts— runnpm run testwith the Vitest browser runner before opening the PR.👨💻 Elicit — Requirements Engineer
Observations
The acceptance criteria are clear and testable. Mapping them to EARS-style requirements for completeness:
PUT /transcription-blocks/review-allreturns a non-2xx response, the system shall display a visible, dismissible error message to the user within the transcription panel.Two gaps I spotted in the current acceptance criteria:
The
reviewTogglesilent failure is explicitly deferred. The note in the issue body is the right call — mixing them would widen scope.Recommendations
Open Decisions
👨💻 Nora "NullX" Steiner — Security Engineer
Observations
@RequirePermission(Permission.WRITE_ALL).if (!res.ok) return) is a UX problem, not a security vulnerability — block states are not modified on failure, so there is no inconsistent-write risk.getErrorMessage(code)in$lib/shared/errors.tswhich correctly mapsErrorCodevalues to i18n strings. This mapping must be used — notbody.messageor any raw backend string.The proposed pattern (Felix's review) correctly routes through
getErrorMessage(). The only risk is if someone takes a shortcut and rendersbody.messagedirectly:fetchcall, consistent with all other write operations in this page.Recommendations
body.code(typed asErrorCode | string), neverbody.message. Pass the code togetErrorMessage(). This is already the established pattern in the codebase.// Never render body.message — route through getErrorMessage() to prevent leaking backend internals.One comment here is worth it because this exact mistake is easy to make under time pressure.👨💻 Sara Holt — QA Engineer & Test Strategist
Observations
Current test coverage gap:
TranscriptionBlockControllerTestandTranscriptionServiceTestboth test thereview-allendpoint thoroughly (401, 403, 200, empty list, idempotent, audit events). Backend is well covered.TranscriptionEditView.svelte.spec.tscovers the error path ofonMarkAllReviewed. The happy path and themarkingAllReviewedspinner state may be tested, but a search across the spec files shows zero tests for the error case.Test plan for this fix (all three layers):
Unit/component (Vitest browser —
TranscriptionEditView.svelte.spec.ts):it('shows error message when markAllReviewed callback rejects')— mockonMarkAllReviewedto returnPromise.reject(new Error('INTERNAL_ERROR')), assertrole="alert"is visible with a non-empty message.it('clears error message when dismiss button is clicked')— after error appears, click dismiss button, assert alert is gone.it('clears error message on next successful markAllReviewed call')— error shown, then mock resolves successfully, assert error is gone.it('block states are unchanged after markAllReviewed failure')— this is guaranteed by the lack of optimistic update, but a regression test is worth having.Server/load function (Vitest Node —
+page.server.spec.tsif it exists):E2E (Playwright — not required for this issue):
Recommendations
vi.fn()returningPromise.reject(...)for the error mock — do not stub fetch directly, as the error is propagated from the page'smarkAllReviewedvia the prop.shows error message when markAllReviewed callback rejects��� nottestMarkAllReviewedError.getByRole('alert')for the error div assertion, which also validates therole="alert"attribute is present (required for screen reader announcement).markingAllReviewedspinner correctly resets in thefinallyblock — add one assertion confirming the button is re-enabled after the error, so that regression is caught.👨💻 Leonie Voss — UI/UX Design Lead
Observations
The current
handleMarkAllReviewedshows a loading spinner during the request (correct), but shows nothing at all if it fails. For the 60+ audience who use this transcription workflow on tablets, silent failure after a bulk action is particularly disorienting — they pressed a button, waited, nothing changed, and no feedback appeared.The existing review-progress area in
TranscriptionEditView.svelte(the sticky header with the "X / Y geprüft" counter and the "Alle als fertig markieren" button) is the natural home for the inline error. It is already visible at the top of the transcription panel, scrolling is not required to see it.Two existing issues I noticed while reviewing the button:
"Alle als fertig markieren"is a hardcoded German string — not routed through Paraglide. This needs fixing separately (pre-existing), but the implementer will touch this element.titleattribute tooltip'Alle Blöcke sind bereits als fertig markiert'(shown when disabled) is also a hardcoded German string.For the error display itself:
role="alert"so screen readers announce the message immediately without user focus.aria-live="polite"as a belt-and-suspenders addition — some screen reader + browser combinations handlerole="alert"inconsistently.aria-label(not just an × character) — auto-dismissing is a WCAG failure for the senior audience.class="min-h-[44px] min-w-[44px]".bg-red-50 text-red-700is acceptable as it already appears in the OCR error pattern at line 492 of+page.svelte. Do not add a new color pattern.Recommendations
Concrete Tailwind classes for the error container:
<div>, still inside the sticky header — no scroll required to see it.Open Decisions
markAllReviewedclear the error (which it should do regardless)?Decision Queue
Two open decisions raised by the personas, grouped by theme.
Theme 1: Scope of pre-existing hardcoded strings
Raised by: Elicit, Leonie, Felix
Both the button label ("Alle als fertig markieren") and its disabled-state tooltip ("Alle Blöcke sind bereits als fertig markiert") are hardcoded German strings in
TranscriptionEditView.svelte. They are pre-existing issues, but the implementer will be editing the same button element as part of this fix.Decision needed: Fix the hardcoded strings as part of this PR (near-zero extra cost since the element is already being touched), or defer to a separate i18n chore ticket?
The recommendation from all three personas: fix in the same PR. The counter-argument for deferring is keeping this PR strictly scoped to the error feedback behavior.
Theme 2: Error persistence — auto-dismiss vs. persistent-until-dismissed
Raised by: Leonie
The error banner could:
markAllReviewedwithout dismissing, the old error persists until the retry either succeeds (clears it) or fails (replaces it). This is handled cleanly bymarkAllError = nullat the start ofhandleMarkAllReviewed.The issue AC says "the error message is dismissible" — it does not prescribe persistence behavior. Option A is the safer default and requires no timer logic. Recommendation from the personas: go with persistent-until-dismissed (Option A).
Decision needed: Confirm Option A (persistent until manually dismissed or until next action clears it) is acceptable, or specify an auto-dismiss timeout.