ui(admin/system): improve mass-import status card (loading state, i18n, font size) #533
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?
Type: UI/UX improvement
Priority: P1-high — must land before the first real prod import campaign
Source: review of #526 by Leonie Voss (UX) — comment #8650
Parent PR: #526 (mass-import bind mount → admin card now actually does something on prod)
Summary
Three discrete UX fixes to the mass-import card at
/admin/system: (1) a real loading state whileRUNNING, (2) i18n via a structured status code instead of a verbatim German backend string, (3) a senior-audience-readable font size for the progress counts.User Story
Context
With #526 merged,
/admin/system's mass-import card actually does something on staging/prod for the first time. Leonie flagged three UX concerns on the card itself that were out of scope for the infra PR but should land before the first real prod import.Required
Real loading state during
RUNNING— the card currently showsstatefrom/api/admin/import-statusas plain text. Whilestate === 'RUNNING', render a spinner or progress indicator alongside theprocessedcount, not just the stale-looking word "RUNNING". Auto-poll every 2–5s while running.i18n the status messages —
ImportStatus.messageis currently a GermanStringbaked intoMassImportService("Import läuft...","Import abgeschlossen. N Dokumente verarbeitet.", etc.). The frontend should not render the backend's localized string verbatim — instead, the backend should return anErrorCode-style enum (e.g.IMPORT_RUNNING,IMPORT_DONE,IMPORT_FAILED_NO_SPREADSHEET), and the frontend maps via Paraglide (messages/{de,en,es}.json). This also helps debugging in non-German locales.Font size for the senior audience — progress counts must be at least
text-base(16px). Currently they'retext-xs/text-smper Leonie's quick check. Use the project's standardtext-basefor the count andtext-xs uppercase tracking-widestonly for the label row.Acceptance criteria
Given an import is in progress (
state === 'RUNNING'),when the operator views
/admin/system,then a spinner or progress indicator is visible alongside the live
processedcount, and the card auto-polls every 2–5s.Given the user has selected
enoresas their UI language,when the import status card renders,
then all status text is localized via Paraglide and no raw German backend string is shown.
Given any state,
when the card renders the
processed/skippedcounts,then the numeric values are rendered at ≥
text-base(16px); only the section label row may usetext-xs.Checklist
de,en,es) have keys for every status code/admin/systempasses in light AND dark modeLinked NFRs
Related
MassImportService.ImportStatus— coordinate with the operator-policy follow-up #534 and the skipped-counter additions from #529 / #530.Definition of Ready
text-base, axe-clean, three breakpoints)🤖 Generated with Claude Code during /implement on #526
👨💻 Felix Brandt — Senior Fullstack Developer
Observations
The i18n is 75% done already.
messages/{de,en,es}.jsonalready hasadmin_system_import_status_idle,admin_system_import_status_running, andadmin_system_import_status_done(with{count}param). The only gap is the FAILED state:admin_system_import_status_faileduses{message}as a Paraglide parameter, so the backend's German string leaks through foren/eslocales. The structural work is adding granular failure status codes so the frontend can drop the{message}interpolation entirely.The polling loop already exists. The frontend already polls
/api/admin/import-statusevery 2s while RUNNING (page.svelte:179–220). Task 1 (loading state) is narrower than it sounds — only the visual spinner is missing.Spinner hook:
isRunningmust be a$derived—const isRunning = $derived(importStatus?.state === 'RUNNING')— not a$state+$effectpair. The existing{#if isRunning}block is the correct insertion point for the spinner markup.Backend
ImportStatusrecord (MassImportService.java:55) carriesString message— a localization concern in the service layer. The fix adds a structuredImportStatusCodeenum field; themessagefield becomes debug-only and must not be rendered by the frontend.After any backend change: run
npm run generate:apibefore writing frontend code — the TypeScript types are auto-generated and the field won't exist in the client until then.Font sizes: The card currently uses
text-xs/text-smfor numeric counts. The fix matches the CLAUDE.md card pattern exactly:text-base font-seriffor the value,text-xs font-bold uppercase tracking-widest text-ink-3only for the label row.Recommendations
Backend: Add an
ImportStatusCodeenum with valuesIDLE,RUNNING,DONE,FAILED_NO_SPREADSHEET,FAILED_GENERIC. Add it as a@Schema(requiredMode = REQUIRED)field on theImportStatusrecord. Mark the existingmessagefield@Schema(requiredMode = NOT_REQUIRED)— it stays for debugging, but the OpenAPI contract no longer guarantees it. Audit everycatchblock inrunImportAsync()to confirm which failure paths map to which code before writing new i18n keys.Frontend status mapping: Create a
getImportStatusMessage(code: ImportStatusCode, processed?: number): stringhelper (pattern:errors.ts→getErrorMessage()). Each code maps to exactly one Paraglide call. The FAILED codes get their own keys (admin_system_import_status_failed_no_spreadsheet,admin_system_import_status_failed_generic) — the{message}interpolation disappears.Spinner markup — accessible pattern:
role="status"announces the state to screen readers.sr-onlygives a verbal label to the spinner. Usetext-brand-navyso it respects the semantic token in both light and dark mode.TDD order:
getByRole('status')is visible — watch it fail.ImportStatusCode→ i18n mapping (including FAILED subcodes).generate:api, implement mapping; go green.text-baseclass — no separate failing test needed if done as part of the same cycle.🏛️ Markus Keller — Application Architect
Observations
Domain boundary violation in the current design. The
message: Stringfield onImportStatus(MassImportService.java:55) is a localization artifact baked into the service layer. Localization is a presentation concern — it should never originate in the domain. This issue fixes a real architectural smell, not just a UX polish item.Two design options for the API change:
Stateenum with granular failure codes (IDLE,RUNNING,DONE,FAILED_NO_SPREADSHEET,FAILED_GENERIC). Thestatefield stays as the single discriminator — the frontend switches on one field, the backend sets one field. Clean, no additional fields, no JSON schema bloat.statusCodefield alongside the existingstate: FAILED— coarse state for simple consumers, fine code for i18n. More flexible, more complex, harder to keep in sync.stateis already the only field driving all frontend branch decisions. Granularizing it costs nothing and removes themessagefield's only remaining reason to exist.Breaking API change — scoped correctly. Renaming
Statevalues or removingmessageis a breaking change to the/api/admin/import-statusresponse. This API has exactly one consumer (the frontend). The change must land atomically: backend + frontend in the same PR. No migration ceremony needed.Coordination with #534. If #534 modifies
MassImportServiceto add operator-policy behaviour, the two PRs will touch the same record and the same service method. The failure status code enum needs to be defined now to avoid a second breaking API change within the same sprint.No new packages, services, or endpoints. This is an in-place change to one record, one enum, and one service's catch blocks. The module boundary (
importing/package ownsMassImportServiceandImportStatus) is preserved.Recommendations
StatetoImportStatusCode. Values:IDLE,RUNNING,DONE,FAILED_NO_SPREADSHEET,FAILED_GENERIC. Annotate the field with@Schema(requiredMode = REQUIRED)in theImportStatusrecord so TypeScript codegen emits it as non-optional.messagefrom the OpenAPI contract (@Schema(requiredMode = NOT_REQUIRED)or omit@Schemaentirely). It can stay on the Java record for debug logging but must not be part of the published API surface.ImportStatusCodeenum in coordination with any failure modes that #534 might introduce — enumerate once, not twice.🔒 Nora "NullX" Steiner — Application Security Engineer
Observations
Both admin import endpoints are correctly secured.
AdminController.javacarries a class-level@RequirePermission(Permission.ADMIN)annotation —GET /api/admin/import-statusandPOST /api/admin/trigger-importare both covered. No auth gap here.Information disclosure via
messagefield (OWASP A05 — low severity, admin-only surface). Themessagefield inImportStatusis set directly from backend logic. In the FAILED case, if the catch block inrunImportAsync()writese.getMessage()or a filesystem path (e.g.,/import/documents/archive.ods) intomessage, that string reaches the frontend verbatim. Since this is an admin-only endpoint, the severity is low — but the pattern is wrong. An admin seeing/opt/familienarchiv/import/subfolder/...in a UI error message gets more internal topology than they need, and it normalizes leaking internals through the UI. The structured status code fix eliminates this vector completely.Specific path to confirm: Audit
runImportAsync()wherecurrentStatusis set on theFAILEDpath. If it's something like:that is the leak. The fix: the
messagefield stays for server-side logging only; the API response carries only theImportStatusCodeenum value.CSRF on the trigger endpoint.
POST /api/admin/trigger-importmust be covered by Spring Security's CSRF protection. VerifySecurityConfigdoes not have a blanketcsrf().disable()that inadvertently covers this endpoint. Since the frontend uses form-based session auth (not token-based), CSRF protection is load-bearing here.The
startedAt: LocalDateTimefield reveals the timestamp of the last import run. Acceptable for an admin-only endpoint — no concern.Recommendations
runImportAsync(). Ife.getMessage()or any path string lands inmessage, document it explicitly and confirm the enum change removes it from the API response.@WebMvcTesttest toAdminControllerTest.javaasserting thatGET /api/admin/import-statusreturns 403 when called withREAD_ALLauthority (notADMIN). CurrentlyAdminControllerTest.javahas no test for the import status endpoint at all — the@RequirePermissionis correct but untested.POST /api/admin/trigger-import. Both tests are one-liners withMockMvc+user(...).authorities(...).🧪 Sara Holt — QA Engineer & Test Automation Specialist
Observations
The checklist is solid. vitest-browser for the spinner, axe-playwright for a11y, visual regression at three breakpoints — all the right layers. A few gaps in how to implement them:
Existing RUNNING state test needs to fail first.
page.svelte.test.ts:138–158tests the RUNNING state renders — but based on the current implementation (no spinner), it asserts the text appears, not that a loading indicator is visible. TDD here means updating that test to assertgetByRole('status')is present before adding the spinner. If the test is updated to pass with the current code (no spinner), the test is wrong.i18n regression test is the most important new test. The current FAILED state renders backend string as
{message}param intoadmin_system_import_status_failed. A dedicated test should: mock the API to return{ state: 'FAILED', statusCode: 'FAILED_NO_SPREADSHEET', ... }with locale set toen, and assert no German text appears in the rendered output. This is the regression guard that proves the fix actually works across locales.axe test is missing entirely. No axe-playwright test exists for
/admin/system. The issue's checklist requires light + dark mode. Dark mode matters independently because the spinner'stext-brand-navycolour needs to maintain contrast on the dark surface token.Visual regression setup: The 320/768/1440 px tests are new work — no existing visual regression test exists for this page. Plan for the Playwright screenshot baseline to be committed on first run.
Backend unit tests for new status codes.
MassImportServiceTest.java(522 lines) hasgetStatus_returnsIdleByDefault()and async failure tests. Each newImportStatusCodevalue needs a corresponding assertion: verify the exact code is set on thecurrentStatusafter the matching failure path executes. These are pure unit tests —@ExtendWith(MockitoExtension.class), no Spring context, runs in milliseconds.Recommendations
Test pyramid plan for this issue:
ImportStatusCodevalue, asserting exact enum set incurrentStatustext-baseon count element/admin/systemin light + dark modeFor the
text-basefont-size assertion: add adata-testid="import-processed-count"attribute and assertelement.classList.contains('text-base')viaexpect.element(getByTestId('import-processed-count')).toHaveClass('text-base'). Computed style checks are fragile in vitest-browser; class-list checks are reliable.The DONE state test at
page.svelte.test.ts:160–180uses theprocessedcount — update it to assert the count is rendered in an element withtext-baseclass. That test already has the mock data; just add the class assertion.Don't disable the FAILED state test while the status code enum is in flight — update it alongside the backend change. A temporarily
@Disabledtest with no linked ticket is a silent regression risk.📋 Elicit — Requirements Engineer
Observations
The issue is well above the Definition of Ready bar. User story explicit, three discrete fixes scoped and independently testable, GWT ACs present, NFR thresholds named (
text-base, axe-clean, three breakpoints). This is one of the tighter issues in the backlog.Gap: Failure mode enumeration is incomplete. The issue lists
IMPORT_FAILED_NO_SPREADSHEETas an example status code, but doesn't enumerate all failure modes the backend can raise.MassImportService.runImportAsync()likely has catch blocks for at minimum: no import directory, no spreadsheet file found, file format not recognized, S3 upload failure, database constraint violation. Each of these needs a decision: own status code, or collapse toFAILED_GENERIC. Without that list, the implementer will make this decision ad-hoc mid-PR.Gap: The
skippedcounter is in scope but absent from ACs. The Related section mentions coordination with #529/#530 (skipped-counter additions). Ifskippedis already on theImportStatusDTO when this PR lands, its font size must be ≥text-baseper NFR. The ACs only mentionprocessed. Either add askippedAC now, or explicitly scope it out as "only if #529/#530 has merged."Gap:
startedAtfield has no requirement attached. The API returnsstartedAt: LocalDateTime. No AC says what to do with it. During RUNNING, this could display elapsed time ("Running for 4:32") — useful operator reassurance. Currently it's returned but apparently ignored. This is a small-scope decision that will cause a mid-implementation question if not resolved now.Coordination risk with #534. Issue #534 (operator-policy follow-up) is noted as a coordination dependency but not marked as a Gitea blocker. Both issues touch
MassImportService. If they land in the wrong order, theImportStatusCodeenum may need a second revision. Mark #534 as "related" or "blocked-by this issue" in Gitea to make the dependency visible.Recommendations
ImportStatusCodevalue. This is a 10-minute audit ofrunImportAsync()that saves a mid-PR question.skippedcount is present, when the card renders, then the value is displayed at ≥text-base" — or add a[scope-out]note: "skippeddisplay size covered by #529/#530."startedAt: display elapsed time during RUNNING, or intentionally leave it unused. Document the decision in the issue so the implementer doesn't have to ask.🎨 Leonie Voss — UX Designer & Accessibility Strategist
Since this issue originates from my review comment on #526, let me be specific about what I found and what the fixes should look like.
Observations
Font size — confirmed violation of the senior-audience NFR. The
processedcount (and anyskippedcount from #529/#530) is currently attext-xsortext-sm(12–14px). The senior operator audience (60+) needs minimum 16px for numeric data. The CLAUDE.md card pattern is explicit:text-xs font-bold uppercase tracking-widest text-ink-3is for section labels only — numeric values gettext-base font-serif(16px Tinos). This is a one-line change per count element with zero design risk.Spinner — needs to be recognizable and calm. The import can run for minutes. The spinner must:
prefers-reduced-motion— show the count incrementing without spinning animation for users who've opted out.role="status"so assistive technology announces the state change without requiring the user to navigate there.Recommended pattern:
motion-reduce:hiddenhides the spinning SVG for reduced-motion users whilerole="status"andsr-onlytext remain for screen readers. The count still updates every 2s.Dark mode:
text-brand-navyuses--palette-navywhich is defined inlayout.cssfor both themes. The spinner will inherit the correct colour automatically if the semantic token is used — no dark-mode-specific spinner code needed.Touch target: The trigger button (
Import starten/Erneut starten) must be ≥ 44px height. Confirm it hasmin-h-[44px]— admin pages are often skipped in the touch-target audit because "admins use desktop." The senior operator may use a tablet.Contrast of
text-ink-3label row: Verifytext-ink-3onbg-surfacemeets 4.5:1 in both light and dark mode. The card pattern in CLAUDE.md uses this for section labels — if the dark-mode surface token is very dark,text-ink-3may fall below the AA threshold. The axe-playwright check will catch this if configured for dark mode too.Open Decisions (omit this section entirely if none)
startedAt. During a long import, showing "Running for 4:32" alongside the live count gives the operator confidence without requiring them to remember when they clicked start. This is a minor enhancement that fits within this issue's scope — but it's a product call, not a design call. If it's in, it renders astext-sm text-ink-3below the count (16px count, 14px elapsed — acceptable hierarchy). If it's out, markstartedAtas intentionally unused in this issue.⚙️ Tobias Wendt — DevOps & Platform Engineer
Observations
No infrastructure changes required. This is a pure service-layer + frontend change. No new Docker services, no config changes, no CI pipeline modifications.
Polling load is negligible. The 2s poll interval to
GET /api/admin/import-statusgenerates ~30 req/min per active browser tab.MassImportService.getStatus()(MassImportService.java:59–61) reads avolatilefield — zero database queries, no I/O. At single-admin load this is unmeasurable. No concern.The issue spec says "2–5s." The frontend currently polls every 2s, which satisfies the spec and gives good live feedback. However, 3s is a reasonable middle ground — 33% fewer requests, still fast enough that the count visibly moves during a real import. Either is fine; 2s is not a problem, just worth noting for completeness.
No new env vars or secrets. The
ImportStatusCodeenum lives in application code — no configuration needed. The i18n keys are committed tomessages/— no deployment artifact changes.CI impact: minimal. The backend change adds cases to
MassImportServiceTest.java(existing file, 522 lines). The frontend change updatespage.svelte.test.ts(existing file, 360 lines) and adds a Playwright axe test. Pipeline runtime change is negligible.Good to note: the issue correctly scopes this as P1-high and plans to land before the first real prod import campaign. The blast radius is a single card on
/admin/system. Even if something goes wrong, the worst case is a non-functional status card — the import itself runs server-side and is unaffected.Recommendations
ImportStatusAPI change,npm run generate:apiis in the PR's "how to test" notes so reviewers know to check the generated types match.vi.useFakeTimers()or similar) to stay consistent.🗳️ Decision Queue — Action Required
2 decisions need your input before implementation starts.
Backend Design
IMPORT_FAILED_NO_SPREADSHEETas an exampleImportStatusCodebut doesn't enumerate all failure paths inrunImportAsync(). Before writing new i18n keys, audit the catch blocks and decide: which failures get their own code vs. collapse toFAILED_GENERIC? Candidates likely include: import directory not found, no spreadsheet file, unrecognized file format, S3 upload failure, database constraint violation. (Raised by: Elicit, Felix)UX / Scope
startedAtelapsed time display. The API already returnsstartedAt: LocalDateTimebut no AC covers it. Options: (A) Show "Running for 4:32" alongside the live count during RUNNING — reassuring for long imports, fits this issue's scope, renders astext-sm text-ink-3below the count. (B) MarkstartedAtas intentionally unused in this issue and scope it to a follow-up. Either is fine — unresolved, it becomes a mid-implementation question. (Raised by: Leonie, Elicit)Failure mode: explore possible Enums, so we get good errors we can act upon
UX: show a counter
Implemented in PR #569 (
feat/issue-533-mass-import-ux).What was done:
Backend —
MassImportService.ImportStatusgains astatusCodeString field. A privateNoSpreadsheetExceptiondistinguishes "no spreadsheet found" (IMPORT_FAILED_NO_SPREADSHEET) from other I/O failures (IMPORT_FAILED_INTERNAL). All 5 state transitions set the correct code. 37 + 12 backend tests green.i18n — Removed
{message}interpolation (raw German string) from all three locale files. Addedadmin_system_import_failed_no_spreadsheet,admin_system_import_failed_internal, andadmin_system_import_status_done_labeltode/en/es.Frontend — Extracted
ImportStatusCard.sveltefrom+page.svelte:processedcount attext-basetext-base, label attext-xs uppercase tracking-wideststatusCodeto Paraglide message — no raw backend string ever rendered🤖 Generated with Claude Code