ui(admin/system): improve mass-import card (loading state, i18n, font size) #569

Open
marcel wants to merge 5 commits from feat/issue-533-mass-import-ux into main
Owner

Closes #533

Summary

Three discrete UX fixes to the mass-import card at /admin/system, flagged by Leonie Voss after #526 made the card live on prod:

  • Loading state — while RUNNING, renders an animated spinner (animate-spin) alongside the live processed count. Auto-poll at 2 s was already in place.
  • i18n — backend now returns a statusCode string (IMPORT_IDLE / IMPORT_RUNNING / IMPORT_DONE / IMPORT_FAILED_NO_SPREADSHEET / IMPORT_FAILED_INTERNAL) instead of a hard-coded German message. The frontend maps this to Paraglide keys; no raw backend string is ever rendered. A private NoSpreadsheetException distinguishes "no spreadsheet file found" from other I/O failures.
  • Font sizeprocessed count shown at text-base (16 px); section labels at text-xs uppercase tracking-widest. Works for both RUNNING and DONE states.

Commits

SHA What
2ad25f06 feat(import): add statusCode to ImportStatus + NoSpreadsheetException + tests
5dd91fa1 feat(i18n): new failure keys + split DONE label (de / en / es)
d8de391c feat(admin/system): ImportStatusCard component — spinner, text-base count, statusCode i18n

Test plan

  • Backend: MassImportServiceTest (37 tests) + AdminControllerTest (12 tests) — all green locally
  • Frontend: vitest-browser tests in ImportStatusCard.svelte.test.ts — run in CI (aborted locally due to browser startup time)
  • Manually verify /admin/system card in all three states (RUNNING / DONE / FAILED) and both en / es locales

🤖 Generated with Claude Code

Closes #533 ## Summary Three discrete UX fixes to the mass-import card at `/admin/system`, flagged by Leonie Voss after #526 made the card live on prod: - **Loading state** — while `RUNNING`, renders an animated spinner (`animate-spin`) alongside the live `processed` count. Auto-poll at 2 s was already in place. - **i18n** — backend now returns a `statusCode` string (`IMPORT_IDLE` / `IMPORT_RUNNING` / `IMPORT_DONE` / `IMPORT_FAILED_NO_SPREADSHEET` / `IMPORT_FAILED_INTERNAL`) instead of a hard-coded German message. The frontend maps this to Paraglide keys; no raw backend string is ever rendered. A private `NoSpreadsheetException` distinguishes "no spreadsheet file found" from other I/O failures. - **Font size** — `processed` count shown at `text-base` (16 px); section labels at `text-xs uppercase tracking-widest`. Works for both RUNNING and DONE states. ## Commits | SHA | What | |---|---| | `2ad25f06` | `feat(import)`: add `statusCode` to `ImportStatus` + `NoSpreadsheetException` + tests | | `5dd91fa1` | `feat(i18n)`: new failure keys + split DONE label (de / en / es) | | `d8de391c` | `feat(admin/system)`: `ImportStatusCard` component — spinner, text-base count, statusCode i18n | ## Test plan - [ ] Backend: `MassImportServiceTest` (37 tests) + `AdminControllerTest` (12 tests) — all green locally - [ ] Frontend: vitest-browser tests in `ImportStatusCard.svelte.test.ts` — run in CI (aborted locally due to browser startup time) - [ ] Manually verify `/admin/system` card in all three states (RUNNING / DONE / FAILED) and both `en` / `es` locales 🤖 Generated with [Claude Code](https://claude.com/claude-code)
marcel added 3 commits 2026-05-14 12:38:07 +02:00
Adds a statusCode field (IMPORT_IDLE / IMPORT_RUNNING / IMPORT_DONE /
IMPORT_FAILED_NO_SPREADSHEET / IMPORT_FAILED_INTERNAL) to ImportStatus.
The frontend will map these codes to localized strings via Paraglide
instead of rendering the backend's German message verbatim.

NoSpreadsheetException distinguishes a missing spreadsheet from other
I/O failures so the frontend can show a specific error without raw text.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replaces the {message} interpolation (raw German backend string) with
two distinct error keys: IMPORT_FAILED_NO_SPREADSHEET and
IMPORT_FAILED_INTERNAL. Also removes the {count} parameter from the
done message and adds admin_system_import_status_done_label so the
processed count can be rendered separately at text-base size.

All three locales (de / en / es) updated.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
feat(admin/system): extract ImportStatusCard — spinner, text-base count, statusCode i18n
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 2m44s
CI / OCR Service Tests (pull_request) Successful in 20s
CI / Backend Unit Tests (pull_request) Successful in 4m24s
CI / fail2ban Regex (pull_request) Successful in 39s
CI / Compose Bucket Idempotency (pull_request) Successful in 59s
3b2fae8188
Extracts the mass-import block from +page.svelte into ImportStatusCard.svelte.

Changes per the three UX fixes from issue #533:
- RUNNING: animated spinner (animate-spin) + processed count at text-base;
  auto-poll at 2 s was already in place
- DONE: processed count at text-base, label at text-xs uppercase tracking-widest
- FAILED: maps statusCode (IMPORT_FAILED_NO_SPREADSHEET / IMPORT_FAILED_INTERNAL)
  to Paraglide messages — no raw German backend string rendered

Adds vitest-browser tests covering spinner visibility, count display,
and per-statusCode FAILED message selection.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
marcel force-pushed feat/issue-533-mass-import-ux from d8de391c10 to 3b2fae8188 2026-05-14 12:38:07 +02:00 Compare
Author
Owner

🏛️ Markus Keller — Senior Application Architect

Verdict: Approved

Documentation trigger check

PR trigger Required update Status
New backend package or domain module CLAUDE.md package table + C4 diagram N/A — no new packages
New SvelteKit route CLAUDE.md route table + C4 diagram N/A — ImportStatusCard is a component, not a route
New Flyway migration DB diagrams N/A — no schema changes
New ErrorCode or Permission CLAUDE.md N/A — none added
Architectural decision with lasting consequences ADR in docs/adr/ N/A — statusCode is an implementation detail, not a structural decision

No doc updates required.

Observations

statusCode String alongside State enum — The ImportStatus record now carries both a State enum and a statusCode String. These are parallel representations: State.FAILED maps to two possible statusCodes. This is a deliberate choice to let the frontend distinguish failure sub-types without coupling to Java enum semantics. The mapping is fully contained within MassImportService; no other layer knows about the statusCode values. I find this acceptable — the alternative (sub-classing State or adding a second enum) would be heavier. Well-bounded.

NoSpreadsheetException as a private static inner class — Clean containment. The exception is an implementation detail of the service and doesn't escape. Correct design.

ImportStatus type duplicated in +page.svelte and ImportStatusCard.svelte — Per project conventions, each module defines its own input types. Duplication here is cheaper than coupling. This is correct.

Suggestion (non-blocking)

The message field in ImportStatus is returned in the API response but carries raw exception text and internal directory paths (e.g., "Fehler: " + e.getMessage() which can include /data/import/...). The frontend ignores this field entirely and drives display from statusCode. Consider whether message belongs in the API contract at all — or if it should be annotated @JsonIgnore and kept exclusively for server-side logging. A minimal API surface is preferable.

## 🏛️ Markus Keller — Senior Application Architect **Verdict: ✅ Approved** ### Documentation trigger check | PR trigger | Required update | Status | |---|---|---| | New backend package or domain module | CLAUDE.md package table + C4 diagram | N/A — no new packages | | New SvelteKit route | CLAUDE.md route table + C4 diagram | N/A — `ImportStatusCard` is a component, not a route | | New Flyway migration | DB diagrams | N/A — no schema changes | | New `ErrorCode` or `Permission` | CLAUDE.md | N/A — none added | | Architectural decision with lasting consequences | ADR in `docs/adr/` | N/A — `statusCode` is an implementation detail, not a structural decision | No doc updates required. ✅ ### Observations **`statusCode` String alongside `State` enum** — The `ImportStatus` record now carries both a `State` enum and a `statusCode` String. These are parallel representations: `State.FAILED` maps to two possible statusCodes. This is a deliberate choice to let the frontend distinguish failure sub-types without coupling to Java enum semantics. The mapping is fully contained within `MassImportService`; no other layer knows about the statusCode values. I find this acceptable — the alternative (sub-classing `State` or adding a second enum) would be heavier. Well-bounded. **`NoSpreadsheetException` as a private static inner class** — Clean containment. The exception is an implementation detail of the service and doesn't escape. Correct design. **`ImportStatus` type duplicated in `+page.svelte` and `ImportStatusCard.svelte`** — Per project conventions, each module defines its own input types. Duplication here is cheaper than coupling. This is correct. ### Suggestion (non-blocking) The `message` field in `ImportStatus` is returned in the API response but carries raw exception text and internal directory paths (e.g., `"Fehler: " + e.getMessage()` which can include `/data/import/...`). The frontend ignores this field entirely and drives display from `statusCode`. Consider whether `message` belongs in the API contract at all — or if it should be annotated `@JsonIgnore` and kept exclusively for server-side logging. A minimal API surface is preferable.
Author
Owner

👨‍💻 Felix Brandt — Senior Fullstack Developer

Verdict: ⚠️ Approved with concerns

Blockers

None.

Suggestions

1. Business logic in template markup (ImportStatusCard.svelte, line ~64)

The inline ternary selecting the failure message is business logic that belongs in $derived, not in the template:

<!-- current — logic in markup -->
{importStatus.statusCode === 'IMPORT_FAILED_NO_SPREADSHEET'
    ? m.admin_system_import_failed_no_spreadsheet()
    : m.admin_system_import_failed_internal()}

Preferred:

<script lang="ts">
const failureMessage = $derived(
    importStatus?.statusCode === 'IMPORT_FAILED_NO_SPREADSHEET'
        ? m.admin_system_import_failed_no_spreadsheet()
        : m.admin_system_import_failed_internal()
);
</script>

<!-- template reads the flag -->
<p class="...">{failureMessage}</p>

2. Template length (ImportStatusCard.svelte)

The template runs from line 21 to line 80 — 60 lines of markup. The 40-line splitting signal is passed. Each branch (RUNNING / DONE / FAILED / IDLE) is a distinct visual region. That said, given this is an admin-only utility card with four compact states, I'd leave the split decision to the author — the cognitive overhead of four separate component files may outweigh the benefit here. Flagging as a discussion point, not a mandate.

3. Dead message field in frontend types

Both +page.svelte:13 and ImportStatusCard.svelte:6 declare message: string in their local ImportStatus type, but neither component ever accesses importStatus.message. The backend still populates it with German strings ("Fehler: " + e.getMessage()). Two questions:

  • Should message be removed from both frontend type definitions?
  • If kept intentionally (e.g., for future debugging panels), add a comment explaining why.

What's well done

  • makeStatus() factory with overrides in the test file — idiomatic, readable.
  • $props() destructuring in Svelte 5 style — correct.
  • NoSpreadsheetException as a private static inner class — the exception catches the specific case cleanly without leaking beyond the service.
  • runImportAsync test updated to match the new 5-arg ImportStatus constructor — no signature drift.
  • The spinner carries role="status" and aria-label — accessibility-aware from the start.
## 👨‍💻 Felix Brandt — Senior Fullstack Developer **Verdict: ⚠️ Approved with concerns** ### Blockers None. ### Suggestions **1. Business logic in template markup** (`ImportStatusCard.svelte`, line ~64) The inline ternary selecting the failure message is business logic that belongs in `$derived`, not in the template: ```svelte <!-- current — logic in markup --> {importStatus.statusCode === 'IMPORT_FAILED_NO_SPREADSHEET' ? m.admin_system_import_failed_no_spreadsheet() : m.admin_system_import_failed_internal()} ``` Preferred: ```svelte <script lang="ts"> const failureMessage = $derived( importStatus?.statusCode === 'IMPORT_FAILED_NO_SPREADSHEET' ? m.admin_system_import_failed_no_spreadsheet() : m.admin_system_import_failed_internal() ); </script> <!-- template reads the flag --> <p class="...">{failureMessage}</p> ``` **2. Template length** (`ImportStatusCard.svelte`) The template runs from line 21 to line 80 — 60 lines of markup. The 40-line splitting signal is passed. Each branch (RUNNING / DONE / FAILED / IDLE) is a distinct visual region. That said, given this is an admin-only utility card with four compact states, I'd leave the split decision to the author — the cognitive overhead of four separate component files may outweigh the benefit here. Flagging as a discussion point, not a mandate. **3. Dead `message` field in frontend types** Both `+page.svelte:13` and `ImportStatusCard.svelte:6` declare `message: string` in their local `ImportStatus` type, but neither component ever accesses `importStatus.message`. The backend still populates it with German strings (`"Fehler: " + e.getMessage()`). Two questions: - Should `message` be removed from both frontend type definitions? - If kept intentionally (e.g., for future debugging panels), add a comment explaining why. ### What's well done - `makeStatus()` factory with overrides in the test file — idiomatic, readable. - `$props()` destructuring in Svelte 5 style — correct. - `NoSpreadsheetException` as a private static inner class — the exception catches the specific case cleanly without leaking beyond the service. - `runImportAsync` test updated to match the new 5-arg `ImportStatus` constructor — no signature drift. - The spinner carries `role="status"` and `aria-label` — accessibility-aware from the start.
Author
Owner

🔧 Tobias Wendt — DevOps & Platform Engineer

Verdict: Approved

No CI/CD workflow, Docker Compose, or infrastructure changes in this PR. Nothing to flag from the platform side.

What I verified

  • No deprecated action versions introduced or touched.
  • No new Docker services or named volumes.
  • No hardcoded secrets in workflow YAML files.
  • No bind mounts or exposed internal ports added.

One note

The test plan says the vitest-browser component tests were "aborted locally due to browser startup time" and are expected to run in CI instead. This is a valid pattern — browser tests often can't run on dev machines without a full Playwright install. But before merging, confirm that:

  1. The CI Playwright/Chromium environment is available for vitest-browser tests.
  2. The tests actually execute (not silently skipped) and all 4 assertions pass.

A quiet pass in CI is not the same as a confirmed pass. Check the CI run output for ImportStatusCard.svelte.test.ts explicitly.

LGTM otherwise.

## 🔧 Tobias Wendt — DevOps & Platform Engineer **Verdict: ✅ Approved** No CI/CD workflow, Docker Compose, or infrastructure changes in this PR. Nothing to flag from the platform side. ### What I verified - No deprecated action versions introduced or touched. - No new Docker services or named volumes. - No hardcoded secrets in workflow YAML files. - No bind mounts or exposed internal ports added. ### One note The test plan says the vitest-browser component tests were "aborted locally due to browser startup time" and are expected to run in CI instead. This is a valid pattern — browser tests often can't run on dev machines without a full Playwright install. But before merging, confirm that: 1. The CI Playwright/Chromium environment is available for vitest-browser tests. 2. The tests actually execute (not silently skipped) and all 4 assertions pass. A quiet pass in CI is not the same as a confirmed pass. Check the CI run output for `ImportStatusCard.svelte.test.ts` explicitly. LGTM otherwise.
Author
Owner

📋 Elicit — Senior Requirements Engineer

Verdict: ⚠️ Approved with concerns

Requirements coverage from issue #533

Requirement Status
Spinner during RUNNING state with live processed count Addressed
i18n — no raw backend strings ever rendered Addressed at UI layer
Font size — text-base for count, section labels at text-xs uppercase tracking-widest Addressed

Concerns

1. The message field partially undermines the i18n requirement at the API contract level

The PR description states: "no raw backend string is ever rendered." This is true for the current UI. However, the API response still includes message: "Fehler: ..." — German text with potentially internal path details. Any future frontend consumer (a second admin view, an external monitoring integration, an API script) that reads message instead of statusCode would silently break the i18n guarantee. The requirement is satisfied at the rendering layer but not enforced at the contract boundary.

Recommendation: Either exclude message from the serialized response (@JsonIgnore on the field), or explicitly document in the API (OpenAPI description) that message is a developer/log field not intended for display.

2. Unspecified behavior for importStatus = null on initial load

Before the first 2-second polling interval completes, importStatus is null. The component renders nothing in this state (just the card heading and description, no status area). Is this the intended user experience? No acceptance criterion in the issue covers this. A "Loading…" indicator or skeleton would better signal to the admin that status is being fetched. Worth a follow-up issue if intentional deferral.

3. In-memory state loss on server restart

currentStatus is a private volatile field — it resets to IDLE on restart. If the last import failed, an admin who connects after a restart sees IDLE with no history of the failure. The new sub-codes (IMPORT_FAILED_NO_SPREADSHEET, IMPORT_FAILED_INTERNAL) make this gap more visible since they provide actionable distinctions the admin can no longer see after a restart. This is pre-existing behavior, but the new granularity warrants a follow-up issue to track whether persistence (e.g., a import_status DB row) is needed.

## 📋 Elicit — Senior Requirements Engineer **Verdict: ⚠️ Approved with concerns** ### Requirements coverage from issue #533 | Requirement | Status | |---|---| | Spinner during RUNNING state with live processed count | ✅ Addressed | | i18n — no raw backend strings ever rendered | ✅ Addressed at UI layer | | Font size — `text-base` for count, section labels at `text-xs uppercase tracking-widest` | ✅ Addressed | ### Concerns **1. The `message` field partially undermines the i18n requirement at the API contract level** The PR description states: _"no raw backend string is ever rendered."_ This is true for the current UI. However, the API response still includes `message: "Fehler: ..."` — German text with potentially internal path details. Any future frontend consumer (a second admin view, an external monitoring integration, an API script) that reads `message` instead of `statusCode` would silently break the i18n guarantee. The requirement is satisfied at the rendering layer but not enforced at the contract boundary. Recommendation: Either exclude `message` from the serialized response (`@JsonIgnore` on the field), or explicitly document in the API (OpenAPI description) that `message` is a developer/log field not intended for display. **2. Unspecified behavior for `importStatus = null` on initial load** Before the first 2-second polling interval completes, `importStatus` is `null`. The component renders nothing in this state (just the card heading and description, no status area). Is this the intended user experience? No acceptance criterion in the issue covers this. A "Loading…" indicator or skeleton would better signal to the admin that status is being fetched. Worth a follow-up issue if intentional deferral. **3. In-memory state loss on server restart** `currentStatus` is a `private volatile` field — it resets to `IDLE` on restart. If the last import failed, an admin who connects after a restart sees IDLE with no history of the failure. The new sub-codes (`IMPORT_FAILED_NO_SPREADSHEET`, `IMPORT_FAILED_INTERNAL`) make this gap more visible since they provide actionable distinctions the admin can no longer see after a restart. This is pre-existing behavior, but the new granularity warrants a follow-up issue to track whether persistence (e.g., a `import_status` DB row) is needed.
Author
Owner

🔐 Nora "NullX" Steiner — Application Security Engineer

Verdict: ⚠️ Approved with concerns

Concern — Information Disclosure via message field (CWE-209, Low-Medium)

File: MassImportService.java

The message field of ImportStatus is serialized and returned via GET /api/admin/import-status. For the two failure cases:

// IMPORT_FAILED_INTERNAL — e.getMessage() can contain internal JVM/IO error details
currentStatus = new ImportStatus(State.FAILED, "IMPORT_FAILED_INTERNAL",
    "Fehler: " + e.getMessage(), 0, currentStatus.startedAt());

// IMPORT_FAILED_NO_SPREADSHEET — exposes importDir (internal container path)
new NoSpreadsheetException(
    "Keine Tabellendatei (.ods/.xlsx/.xls) in " + importDir + " gefunden!")

The NoSpreadsheetException message includes importDir — a Spring configuration value that exposes the container's internal directory structure (e.g., /data/import). This message propagates through the exception to importStatus.message and into the API response.

Risk: Low-Medium. The endpoint requires ADMIN permission, limiting exposure to trusted users. However, this data can appear in browser DevTools, exported browser logs, or audit trails shipped to external systems.

Fix option A — sanitize the message field:

// IMPORT_FAILED_NO_SPREADSHEET
currentStatus = new ImportStatus(State.FAILED, "IMPORT_FAILED_NO_SPREADSHEET",
    "No spreadsheet file found in import directory.", 0, currentStatus.startedAt());

// IMPORT_FAILED_INTERNAL  
currentStatus = new ImportStatus(State.FAILED, "IMPORT_FAILED_INTERNAL",
    "Import failed due to an internal error.", 0, currentStatus.startedAt());

Fix option B — exclude from serialization entirely (since the frontend never uses it):

public record ImportStatus(State state, String statusCode,
    @JsonIgnore String message,   // logging only, not API-public
    int processed, LocalDateTime startedAt) {}

The log.error(...) calls already capture the full detail on the server side — nothing is lost.

Observation — Missing 401/403 Test Coverage

AdminControllerTest.importStatus_returns200_withStatusCode_whenAdmin() verifies the happy path, but the authorization boundary is untested:

@Test
void importStatus_returns401_whenUnauthenticated() throws Exception {
    mockMvc.perform(get("/api/admin/import-status"))
        .andExpect(status().isUnauthorized());
}

@Test
@WithMockUser(authorities = "READ_ALL")
void importStatus_returns403_whenUserLacksAdminPermission() throws Exception {
    mockMvc.perform(get("/api/admin/import-status"))
        .andExpect(status().isForbidden());
}

These are not blockers — the existing @RequirePermission infrastructure presumably handles this correctly. But permission boundary tests are cheap insurance against future configuration drift.

## 🔐 Nora "NullX" Steiner — Application Security Engineer **Verdict: ⚠️ Approved with concerns** ### Concern — Information Disclosure via `message` field (CWE-209, Low-Medium) **File**: `MassImportService.java` The `message` field of `ImportStatus` is serialized and returned via `GET /api/admin/import-status`. For the two failure cases: ```java // IMPORT_FAILED_INTERNAL — e.getMessage() can contain internal JVM/IO error details currentStatus = new ImportStatus(State.FAILED, "IMPORT_FAILED_INTERNAL", "Fehler: " + e.getMessage(), 0, currentStatus.startedAt()); // IMPORT_FAILED_NO_SPREADSHEET — exposes importDir (internal container path) new NoSpreadsheetException( "Keine Tabellendatei (.ods/.xlsx/.xls) in " + importDir + " gefunden!") ``` The `NoSpreadsheetException` message includes `importDir` — a Spring configuration value that exposes the container's internal directory structure (e.g., `/data/import`). This message propagates through the exception to `importStatus.message` and into the API response. **Risk**: Low-Medium. The endpoint requires ADMIN permission, limiting exposure to trusted users. However, this data can appear in browser DevTools, exported browser logs, or audit trails shipped to external systems. **Fix option A — sanitize the message field**: ```java // IMPORT_FAILED_NO_SPREADSHEET currentStatus = new ImportStatus(State.FAILED, "IMPORT_FAILED_NO_SPREADSHEET", "No spreadsheet file found in import directory.", 0, currentStatus.startedAt()); // IMPORT_FAILED_INTERNAL currentStatus = new ImportStatus(State.FAILED, "IMPORT_FAILED_INTERNAL", "Import failed due to an internal error.", 0, currentStatus.startedAt()); ``` **Fix option B — exclude from serialization entirely** (since the frontend never uses it): ```java public record ImportStatus(State state, String statusCode, @JsonIgnore String message, // logging only, not API-public int processed, LocalDateTime startedAt) {} ``` The `log.error(...)` calls already capture the full detail on the server side — nothing is lost. ### Observation — Missing 401/403 Test Coverage `AdminControllerTest.importStatus_returns200_withStatusCode_whenAdmin()` verifies the happy path, but the authorization boundary is untested: ```java @Test void importStatus_returns401_whenUnauthenticated() throws Exception { mockMvc.perform(get("/api/admin/import-status")) .andExpect(status().isUnauthorized()); } @Test @WithMockUser(authorities = "READ_ALL") void importStatus_returns403_whenUserLacksAdminPermission() throws Exception { mockMvc.perform(get("/api/admin/import-status")) .andExpect(status().isForbidden()); } ``` These are not blockers — the existing `@RequirePermission` infrastructure presumably handles this correctly. But permission boundary tests are cheap insurance against future configuration drift.
Author
Owner

🧪 Sara Holt — Senior QA Engineer

Verdict: ⚠️ Approved with concerns

Backend — MassImportServiceTest

getStatus_hasStatusCode_IMPORT_IDLE_byDefault() — clear behavioral name, single assertion.
runImportAsync_setsStatusCode_IMPORT_FAILED_NO_SPREADSHEET_whenDirIsEmpty() — covers new exception path with specific statusCode assertion. Good use of @TempDir.
runImportAsync_setsFailedStatus_whenImportDirectoryDoesNotExist() — extended with statusCode check, correctly identifies IMPORT_FAILED_INTERNAL for the IOException path.
runImportAsync_throwsConflict_whenAlreadyRunning — constructor updated to match 5-arg signature. No drift.

Backend — AdminControllerTest

importStatus_returns200_withStatusCode_whenAdmin() — verifies $.statusCode and $.state in the API response JSON. Good.

Gap — authorization boundaries untested:

@Test
void importStatus_returns401_whenUnauthenticated() throws Exception {
    mockMvc.perform(get("/api/admin/import-status"))
        .andExpect(status().isUnauthorized());
}

@Test
@WithMockUser(authorities = "READ_ALL")
void importStatus_returns403_whenUserLacksAdminPermission() throws Exception {
    mockMvc.perform(get("/api/admin/import-status"))
        .andExpect(status().isForbidden());
}

Testing "authorized users get 200" without testing "unauthorized users get 403/401" is half-coverage on the security boundary. Not a blocker — but these tests catch configuration drift.

Frontend — ImportStatusCard.svelte.test.ts

makeStatus() factory with overrides — clean, idiomatic Vitest pattern.
Spinner visibility for RUNNING.
Processed count visible for RUNNING and DONE.
i18n string verified for IMPORT_FAILED_NO_SPREADSHEET.

Gaps:

  1. importStatus = null not tested — the component renders nothing for null; this should be explicit:
it('shows no spinner when importStatus is null', async () => {
    const { queryByTestId } = render(ImportStatusCard, {
        props: { importStatus: null, ontrigger: () => {} }
    });
    // spinner should not exist
    expect(document.querySelector('[data-testid="spinner"]')).toBeNull();
});
  1. IDLE state with non-null importStatus not tested — the idle text (m.admin_system_import_status_idle()) should appear when state is 'IDLE' and importStatus !== null. Currently untested.

  2. IMPORT_FAILED_INTERNAL path not tested — only IMPORT_FAILED_NO_SPREADSHEET is tested. The else branch of the ternary is unverified.

  3. ontrigger callback not tested — neither the "retry" button (DONE/FAILED states) nor the "start" button (IDLE state) is tested for actually calling ontrigger.

  4. Locale assumptionexpect.element(getByText('No spreadsheet file found.')).toBeVisible() relies on Paraglide serving English in the test environment. Is this configured? If Paraglide defaults to German in tests, this assertion silently fails to find the element. Document or add a setup fixture.

Reliability note

The test plan explicitly says browser tests were "aborted locally due to browser startup time." Confirm all 4 ImportStatusCard test cases are green in the actual CI run before merging — do not rely on "CI will catch it" as a substitute for a confirmed passing run.

## 🧪 Sara Holt — Senior QA Engineer **Verdict: ⚠️ Approved with concerns** ### Backend — MassImportServiceTest ✅ `getStatus_hasStatusCode_IMPORT_IDLE_byDefault()` — clear behavioral name, single assertion. ✅ `runImportAsync_setsStatusCode_IMPORT_FAILED_NO_SPREADSHEET_whenDirIsEmpty()` — covers new exception path with specific statusCode assertion. Good use of `@TempDir`. ✅ `runImportAsync_setsFailedStatus_whenImportDirectoryDoesNotExist()` — extended with statusCode check, correctly identifies `IMPORT_FAILED_INTERNAL` for the IOException path. ✅ `runImportAsync_throwsConflict_whenAlreadyRunning` — constructor updated to match 5-arg signature. No drift. ### Backend — AdminControllerTest ✅ `importStatus_returns200_withStatusCode_whenAdmin()` — verifies `$.statusCode` and `$.state` in the API response JSON. Good. **Gap — authorization boundaries untested**: ```java @Test void importStatus_returns401_whenUnauthenticated() throws Exception { mockMvc.perform(get("/api/admin/import-status")) .andExpect(status().isUnauthorized()); } @Test @WithMockUser(authorities = "READ_ALL") void importStatus_returns403_whenUserLacksAdminPermission() throws Exception { mockMvc.perform(get("/api/admin/import-status")) .andExpect(status().isForbidden()); } ``` Testing "authorized users get 200" without testing "unauthorized users get 403/401" is half-coverage on the security boundary. Not a blocker — but these tests catch configuration drift. ### Frontend — ImportStatusCard.svelte.test.ts ✅ `makeStatus()` factory with overrides — clean, idiomatic Vitest pattern. ✅ Spinner visibility for RUNNING. ✅ Processed count visible for RUNNING and DONE. ✅ i18n string verified for `IMPORT_FAILED_NO_SPREADSHEET`. **Gaps**: 1. **`importStatus = null` not tested** — the component renders nothing for null; this should be explicit: ```typescript it('shows no spinner when importStatus is null', async () => { const { queryByTestId } = render(ImportStatusCard, { props: { importStatus: null, ontrigger: () => {} } }); // spinner should not exist expect(document.querySelector('[data-testid="spinner"]')).toBeNull(); }); ``` 2. **IDLE state with non-null `importStatus` not tested** — the idle text (`m.admin_system_import_status_idle()`) should appear when state is `'IDLE'` and `importStatus !== null`. Currently untested. 3. **`IMPORT_FAILED_INTERNAL` path not tested** — only `IMPORT_FAILED_NO_SPREADSHEET` is tested. The `else` branch of the ternary is unverified. 4. **`ontrigger` callback not tested** — neither the "retry" button (DONE/FAILED states) nor the "start" button (IDLE state) is tested for actually calling `ontrigger`. 5. **Locale assumption** — `expect.element(getByText('No spreadsheet file found.')).toBeVisible()` relies on Paraglide serving English in the test environment. Is this configured? If Paraglide defaults to German in tests, this assertion silently fails to find the element. Document or add a setup fixture. ### Reliability note The test plan explicitly says browser tests were "aborted locally due to browser startup time." Confirm all 4 `ImportStatusCard` test cases are green in the actual CI run before merging — do not rely on "CI will catch it" as a substitute for a confirmed passing run.
Author
Owner

🎨 Leonie Voss — UX Designer & Accessibility Strategist

Verdict: ⚠️ Approved with concerns

What works well

Spinner has role="status" + aria-label={m.admin_system_import_status_running()} — screen readers will announce the running state correctly.
text-base (16px) for the processed count — meets the senior audience minimum.
Brand-compliant spinner: border-ink-3 base ring, border-t-brand-mint spinning indicator.
Visual hierarchy in DONE state: count (bold, text-base) → label (text-xs uppercase) → status line — scannable at a glance.
Semantic <div> structure for the spinner row (flex items-center gap-3) — count and label are visually grouped.


Concern 1 — animate-spin without prefers-reduced-motion protection [High]

animate-spin runs unconditionally. Users with vestibular disorders can experience nausea from spinning elements. Tailwind's motion-reduce modifier adds this at zero visual cost for other users:

<span
    data-testid="spinner"
    role="status"
    aria-label={m.admin_system_import_status_running()}
    class="inline-block h-5 w-5 animate-spin motion-reduce:animate-none rounded-full border-2 border-ink-3 border-t-brand-mint"
>

This aligns with WCAG 2.1 SC 2.3.3 (Animation from Interactions). Given the 60+ audience that is the primary user of this app, treat this as High priority.


Concern 2 — text-green-600 contrast in DONE state [Medium]

text-green-600 (#16a34a) on bg-green-50 (#f0fdf4) has an approximate contrast ratio of ~3.0:1 — below the WCAG AA minimum of 4.5:1 for normal text.

Affected elements:

  • "Dokumente verarbeitet" label → text-xs font-bold tracking-widest text-green-600
  • "Import abgeschlossen" line → text-xs text-green-600

Fix: Replace text-green-600 with text-green-800 (#166534), which achieves ~6.1:1 on bg-green-50.

<!-- before -->
<p class="text-xs font-bold tracking-widest text-green-600 uppercase">

<!-- after -->
<p class="text-xs font-bold tracking-widest text-green-800 uppercase">

Note: text-green-700 on text-base for the count has better contrast (~4.3:1) but still falls short of AA. Also recommend bumping that to text-green-800.


Concern 3 — Button touch target height [Medium]

The retry/start buttons use px-5 py-2 font-sans text-xs:

  • py-2 → 8px top + 8px bottom = 16px vertical padding
  • text-xs line-height ≈ 20px
  • Estimated total height ≈ 36px — below the WCAG 2.2 minimum of 44px

Fix: Add min-h-[44px] to the button class (or use py-3):

class="min-h-[44px] rounded-sm bg-primary px-5 py-2 font-sans text-xs font-bold tracking-widest text-primary-fg uppercase transition-opacity hover:opacity-80"

Note: This same button style exists in the thumbnail backfill section of the same page. A consistent fix across all admin action buttons would be ideal.


Observation — text-xs secondary labels [Low]

The "Dokumente verarbeitet" label and spinner status text use text-xs = 12px. This is the absolute floor. For the 60+ audience, text-sm (14px) would be preferable. Low priority since these are secondary labels, not body copy — but worth tracking for a future accessibility pass.

## 🎨 Leonie Voss — UX Designer & Accessibility Strategist **Verdict: ⚠️ Approved with concerns** ### What works well ✅ Spinner has `role="status"` + `aria-label={m.admin_system_import_status_running()}` — screen readers will announce the running state correctly. ✅ `text-base` (16px) for the processed count — meets the senior audience minimum. ✅ Brand-compliant spinner: `border-ink-3` base ring, `border-t-brand-mint` spinning indicator. ✅ Visual hierarchy in DONE state: count (bold, `text-base`) → label (`text-xs uppercase`) → status line — scannable at a glance. ✅ Semantic `<div>` structure for the spinner row (`flex items-center gap-3`) — count and label are visually grouped. --- ### Concern 1 — `animate-spin` without `prefers-reduced-motion` protection [High] `animate-spin` runs unconditionally. Users with vestibular disorders can experience nausea from spinning elements. Tailwind's `motion-reduce` modifier adds this at zero visual cost for other users: ```svelte <span data-testid="spinner" role="status" aria-label={m.admin_system_import_status_running()} class="inline-block h-5 w-5 animate-spin motion-reduce:animate-none rounded-full border-2 border-ink-3 border-t-brand-mint" > ``` This aligns with WCAG 2.1 SC 2.3.3 (Animation from Interactions). Given the 60+ audience that is the primary user of this app, treat this as High priority. --- ### Concern 2 — `text-green-600` contrast in DONE state [Medium] `text-green-600` (#16a34a) on `bg-green-50` (#f0fdf4) has an approximate contrast ratio of ~3.0:1 — below the WCAG AA minimum of 4.5:1 for normal text. Affected elements: - `"Dokumente verarbeitet"` label → `text-xs font-bold tracking-widest text-green-600` - `"Import abgeschlossen"` line → `text-xs text-green-600` Fix: Replace `text-green-600` with `text-green-800` (#166534), which achieves ~6.1:1 on `bg-green-50`. ```svelte <!-- before --> <p class="text-xs font-bold tracking-widest text-green-600 uppercase"> <!-- after --> <p class="text-xs font-bold tracking-widest text-green-800 uppercase"> ``` Note: `text-green-700` on `text-base` for the count has better contrast (~4.3:1) but still falls short of AA. Also recommend bumping that to `text-green-800`. --- ### Concern 3 — Button touch target height [Medium] The retry/start buttons use `px-5 py-2 font-sans text-xs`: - `py-2` → 8px top + 8px bottom = 16px vertical padding - `text-xs` line-height ≈ 20px - Estimated total height ≈ 36px — below the WCAG 2.2 minimum of 44px Fix: Add `min-h-[44px]` to the button class (or use `py-3`): ```svelte class="min-h-[44px] rounded-sm bg-primary px-5 py-2 font-sans text-xs font-bold tracking-widest text-primary-fg uppercase transition-opacity hover:opacity-80" ``` Note: This same button style exists in the thumbnail backfill section of the same page. A consistent fix across all admin action buttons would be ideal. --- ### Observation — `text-xs` secondary labels [Low] The "Dokumente verarbeitet" label and spinner status text use `text-xs` = 12px. This is the absolute floor. For the 60+ audience, `text-sm` (14px) would be preferable. Low priority since these are secondary labels, not body copy — but worth tracking for a future accessibility pass.
marcel added 2 commits 2026-05-14 13:25:03 +02:00
- @JsonIgnore on ImportStatus.message — stops internal directory paths and
  raw exception text leaking through the admin import-status endpoint (CWE-209)
- Add importStatus_messageField_notPresentInApiResponse test (red/green verified)
- Add importStatus_returns401/403 auth boundary tests — documents and guards
  the @RequirePermission(ADMIN) protection against configuration drift

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
fix(admin/system): address review concerns in ImportStatusCard
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 2m34s
CI / OCR Service Tests (pull_request) Successful in 16s
CI / Backend Unit Tests (pull_request) Successful in 4m26s
CI / fail2ban Regex (pull_request) Successful in 38s
CI / Compose Bucket Idempotency (pull_request) Successful in 58s
120866bf76
- Remove dead `message` field from both frontend ImportStatus types
  (field is now @JsonIgnore'd on the backend)
- Extract failure message ternary into `$derived` — business logic off
  the template (Felix)
- Add motion-reduce:animate-none to spinner — WCAG 2.1 SC 2.3.3 (Leonie)
- Replace text-green-600 with text-green-800 — WCAG AA contrast 6.1:1
  on bg-green-50 (Leonie)
- Add min-h-[44px] to all three buttons — WCAG 2.2 44px touch target (Leonie)
- Add 6 missing tests: IMPORT_FAILED_INTERNAL path, IDLE state text,
  null importStatus, ontrigger called on DONE/FAILED/IDLE buttons (Sara)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Author
Owner

Review concerns addressed — commits a2e925b7 + 120866bf

Backend (a2e925b7)

Concern Source Fix
message field leaks internal directory paths + raw exception text via API (CWE-209) Nora, Markus, Elicit @JsonIgnore on ImportStatus.message — field is logging-only, excluded from serialized response
importStatus_returns401_whenUnauthenticated missing Nora, Sara Added — guards the existing @RequirePermission(ADMIN) against configuration drift
importStatus_returns403_whenUserLacksAdminPermission missing Nora, Sara Added — documents the permission boundary
importStatus_messageField_notPresentInApiResponse — test was red before fix Red/green verified: field was present before @JsonIgnore, absent after

Frontend (120866bf)

Concern Source Fix
Business logic ternary in template markup Felix Extracted to const failureMessage = $derived(...)
Dead message: string in both ImportStatus type definitions Felix Removed from ImportStatusCard.svelte and +page.svelte — field is now @JsonIgnore'd
animate-spin without prefers-reduced-motion [High] Leonie Added motion-reduce:animate-none — WCAG 2.1 SC 2.3.3
text-green-600 on bg-green-50 — contrast ~3.0:1 [Medium] Leonie Replaced with text-green-800 — contrast ~6.1:1 (WCAG AA )
Button touch target ~36px [Medium] Leonie Added min-h-[44px] to all three buttons — WCAG 2.2
IMPORT_FAILED_INTERNAL path untested Sara Added test
IDLE state text untested Sara Added test
importStatus = null untested Sara Added synchronous null check test
ontrigger callback untested Sara Added 3 tests (DONE / FAILED / IDLE buttons)

Deferred (not code changes)

  • Elicit concern 2 (null state initial UX): showing just the card heading before first poll is the current behaviour — a loading skeleton can be tracked in a follow-up if desired.
  • Elicit concern 3 (in-memory state reset on restart): pre-existing; warrants a follow-up issue if persistence is needed.
  • Felix concern 2 (template length): Felix himself flagged this as a discussion point, not a mandate — left as-is given the compact, admin-only context.

Browser tests are running on CI now (the vitest-browser suite was not run locally due to Playwright startup overhead).

🤖 Generated with Claude Code

## Review concerns addressed — commits `a2e925b7` + `120866bf` ### Backend (`a2e925b7`) | Concern | Source | Fix | |---|---|---| | `message` field leaks internal directory paths + raw exception text via API (CWE-209) | Nora, Markus, Elicit | `@JsonIgnore` on `ImportStatus.message` — field is logging-only, excluded from serialized response | | `importStatus_returns401_whenUnauthenticated` missing | Nora, Sara | Added — guards the existing `@RequirePermission(ADMIN)` against configuration drift | | `importStatus_returns403_whenUserLacksAdminPermission` missing | Nora, Sara | Added — documents the permission boundary | | `importStatus_messageField_notPresentInApiResponse` — test was red before fix | — | Red/green verified: field was present before `@JsonIgnore`, absent after | ### Frontend (`120866bf`) | Concern | Source | Fix | |---|---|---| | Business logic ternary in template markup | Felix | Extracted to `const failureMessage = $derived(...)` | | Dead `message: string` in both `ImportStatus` type definitions | Felix | Removed from `ImportStatusCard.svelte` and `+page.svelte` — field is now `@JsonIgnore`'d | | `animate-spin` without `prefers-reduced-motion` [High] | Leonie | Added `motion-reduce:animate-none` — WCAG 2.1 SC 2.3.3 | | `text-green-600` on `bg-green-50` — contrast ~3.0:1 [Medium] | Leonie | Replaced with `text-green-800` — contrast ~6.1:1 (WCAG AA ✅) | | Button touch target ~36px [Medium] | Leonie | Added `min-h-[44px]` to all three buttons — WCAG 2.2 ✅ | | `IMPORT_FAILED_INTERNAL` path untested | Sara | Added test | | IDLE state text untested | Sara | Added test | | `importStatus = null` untested | Sara | Added synchronous null check test | | `ontrigger` callback untested | Sara | Added 3 tests (DONE / FAILED / IDLE buttons) | ### Deferred (not code changes) - **Elicit concern 2** (null state initial UX): showing just the card heading before first poll is the current behaviour — a loading skeleton can be tracked in a follow-up if desired. - **Elicit concern 3** (in-memory state reset on restart): pre-existing; warrants a follow-up issue if persistence is needed. - **Felix concern 2** (template length): Felix himself flagged this as a discussion point, not a mandate — left as-is given the compact, admin-only context. Browser tests are running on CI now (the vitest-browser suite was not run locally due to Playwright startup overhead). 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 2m34s
CI / OCR Service Tests (pull_request) Successful in 16s
CI / Backend Unit Tests (pull_request) Successful in 4m26s
CI / fail2ban Regex (pull_request) Successful in 38s
CI / Compose Bucket Idempotency (pull_request) Successful in 58s
This pull request can be merged automatically.
You are not authorized to merge this pull request.
View command line instructions

Checkout

From your project repository, check out a new branch and test the changes.
git fetch -u origin feat/issue-533-mass-import-ux:feat/issue-533-mass-import-ux
git checkout feat/issue-533-mass-import-ux
Sign in to join this conversation.
No Reviewers
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: marcel/familienarchiv#569