## Outcome The active Sprint 16.2 surfaces are already `/household/imports` and `/household/review`; legacy budget import/review routes redirect there. The current implementation has strong fingerprint-bound confirmation and async invalidation foundations, but it does **not** yet satisfy the requested portal flow: - Imports support only **one CSV and one mapping** at a time. - Review fetches the complete collection client-side and still exposes **“Alle Buchungen”**. - Import/review lists have **no pagination or virtualization**. - Input changes silently remove the preview instead of showing an explicit stale state. - Mapping selection uses synthetic index values such as `mapping-0`, which are unstable if options reorder. - Some user copy still mentions fingerprints, contract versions, classifications, and threshold mechanics. - Responsive CSS uses mixed `900/820/700/600/420` breakpoints rather than acceptance at `390/820/1440`. ## Exact implementation plan ### 1. Extend the canonical household contracts **Modify:** `frontend/src/api/household.ts` Define the four portal slots and stable option metadata: ```ts type HouseholdImportSlot = 'akb' | 'raiffeisen' | 'visa' | 'migros' type HouseholdImportProfile = { source_type: string portal_slot: HouseholdImportSlot display_name: string requires_mapping: boolean } type HouseholdImportFile = { file_slot: HouseholdImportSlot profile: string csv_text: string mapping_id: string source_reference?: string } ``` Extend `HouseholdImportOptions.profiles` to `HouseholdImportProfile[]`. Keep mappings keyed by their actual `mapping_id`; never use array indexes in form state. Extend the consolidated preview with safe per-file summaries: ```ts type HouseholdImportFileSummary = { file_slot: HouseholdImportSlot label: string row_count: number proposal_count: number exception_count: number duplicate_count: number status: 'ready' | 'needs_attention' | 'blocked' status_label: string } type HouseholdImportPreview = { // Existing non-rendered binding fields: preview_fingerprint: string baseline_fingerprint: string file_summaries: HouseholdImportFileSummary[] counts: HouseholdImportCounts items: HouseholdImportPreviewItem[] // exceptions only merchant_clusters: HouseholdImportMerchantCluster[] summary: string confirmable: boolean technically_confirmable: boolean business_ready_for_confirm: boolean errors: Array<{ file_slot?: HouseholdImportSlot message: string // safe user-facing message }> } ``` Add server pagination to review: ```ts type HouseholdReviewPage = { proposal_count: number decision_count: number exception_count: number classification_version: string // retained internally, not rendered categories: HouseholdCategoryOption[] items: HouseholdReviewItem[] // exception states only total: number next_cursor?: string | null } getHouseholdReview({ cursor?: string page_size?: number source?: string special_only?: boolean }) ``` Target request: ```text GET /api/budget/household/review?scope=exceptions&page_size=25&cursor=... ``` Keep preview/confirm endpoints unchanged. Confirmation must resend the exact ordered four-file snapshot plus current decisions and both fingerprints. ### 2. Build fixed four-file portal slots **Create:** `frontend/src/components/household/HouseholdImportFileSlot.vue` One component instance for each fixed slot: AKB, Raiffeisen, VISA, Migros. Each slot must provide: - Source heading and selected/not-selected state. - Local CSV picker. - Profile selector, restricted to profiles for that slot. - Mapping selector using the real `mapping_id`. - **Datei ersetzen** and **Entfernen** actions. - Per-file validation and safe error text. - No filename, path, CSV content, mapping ID, source reference, hash, or technical code rendered. - Native keyboard operation and a minimum 44×44 px target for picker, selectors, replace/remove buttons. Events: ```ts defineEmits<{ replace: [slot: HouseholdImportSlot, file: File] remove: [slot: HouseholdImportSlot] profileChange: [slot: HouseholdImportSlot, sourceType: string] mappingChange: [slot: HouseholdImportSlot, mappingId: string] }>() ``` ### 3. Refactor the import page into one consolidated workflow **Modify:** `frontend/src/pages/HouseholdImportsPage.vue` Replace the single `fileName/csvText/mappingId` state with: ```ts type LocalImportSlotState = { slot: HouseholdImportSlot profile: string mappingId: string csvText: string selected: boolean reading: boolean error: string generation: number } const slots: Record const slotOrder = ['akb', 'raiffeisen', 'visa', 'migros'] const previewState = 'empty' | 'loading' | 'current' | 'stale' ``` Behavior: 1. Render exactly four `HouseholdImportFileSlot` components. 2. Require all four slots to have a valid CSV, profile, and confirmed mapping before enabling preview. 3. Validate both per-file size and `max_request_bytes` for the combined request. 4. Build one deterministic request in `slotOrder`. 5. Call `previewHouseholdImport()` exactly once with all four files. 6. Display one consolidated preview and one preview/confirm action. 7. Confirm using the immutable `previewFiles` snapshot and latest decisions/fingerprints. 8. Replacing/removing a file or changing profile/mapping: - Increment the affected slot generation and global preview generation. - Mark the existing preview **stale** rather than silently presenting it as current. - Show “Eingaben geändert – Vorschau neu erstellen.” - Disable/hide confirm until a fresh consolidated preview succeeds. 9. Ignore late `File.text()` and preview responses when generations no longer match. 10. After confirm, clear all four CSV strings, native file inputs, mappings, decisions, fingerprints, and preview state. Keep raw CSV entirely in memory. Do not place it in query state, Pinia, storage, logs, DOM text, or history. ### 4. Add a compact consolidated preview **Create:** `frontend/src/components/household/HouseholdImportBatchPreview.vue` Render only: - Four compact file cards from `file_summaries`. - Aggregate counts: - Vorschläge bereit - Entscheidungen nötig - Bereits bekannt - Neutrale Transferpaare - A plain-language readiness statement. - Safe per-file errors. - A collapsed **“Ausnahmen prüfen (N)”** disclosure. Do not render: - Fingerprints or hashes. - Contract/classification versions. - Raw status/reason/error codes. - Source references, mapping IDs, row tokens, filenames, CSV rows. - Coverage threshold internals or a full default transaction list. For expanded exceptions, paginate locally in pages of 25 because they all belong to the same consolidated preview. Pagination is preferable to virtualization here because row heights vary across mobile layouts and interactive disclosures. Reset to page one whenever a re-preview returns. ### 5. Convert review into a true exception inbox **Modify:** `frontend/src/pages/HouseholdReviewPage.vue` - Remove the `review | all` tab state and the **“Alle Buchungen”** tab entirely. - Fetch only `decision_needed` and `special_case` items through `scope=exceptions`. - Keep safe proposal/decision aggregate counts in the header; do not render ready proposals in the inbox. - Replace client filtering over the complete response with server-backed source/special-case filters and cursor pagination. - Page size: 25. - Clear selection and invalidate batch preview on page, filter, category, or source change. - Batch operations must only include selected rows on the currently visible page. - Keep the empty state: “Alles geprüft.” - Preserve the fingerprint-bound final preview/confirm boundary. - Rename user-facing fingerprint copy: - From: “Geänderte Kategorien sind im unveränderlichen Bestätigungs-Fingerprint enthalten.” - To: “Die Bestätigung gilt genau für die unten gezeigte Auswahl.” - Remove classification-version output from the drawer. - Replace “Diagnose und Audit” with a compact, plain-language “Weitere Informationen” section, or remove it if it contains no useful safe summary. - Map exceptional actions to human labels only; never show `reason_code`, raw classification/status values, tokens, IDs, hashes, or filenames. ### 6. Extract an accessible review row **Create:** `frontend/src/components/household/HouseholdReviewExceptionRow.vue` Responsibilities: - Date, merchant, formatted amount, category, selection, confirm, overflow menu. - Fixed 44 px minimum targets for checkbox label, select, confirm, menu summary, and menu items. - Keyboard-openable overflow menu. - Escape closes the menu; focus returns to its trigger. - Emits category/selection/confirm/detail actions. - No direct API calls. Keep drawer focus trapping in the page, but ensure opening a row drawer makes only the background page inert and restores focus to the exact trigger. ### 7. Normalize responsive layout **Modify:** `frontend/src/style.css` and scoped styles in both household pages/components. Acceptance layouts: - **1440 px:** four import slots in a 2×2 grid; compact four-card preview; six-column exception table. - **820 px:** import slots in one column or 2×2 only if each remains at least 320 px; review rows use compact four-column layout without horizontal scrolling. - **390 px:** one-column cards; full-width selects/actions; merchant/date/amount/category/action order; no horizontal overflow. - Use breakpoints centered on `820px` and `480px`; test 390 directly. - Preserve global `:focus-visible`. - Apply `min-height: 2.75rem` and, where icon-only, `min-width: 2.75rem`. - Do not rely on the visual checkbox itself being 44 px; its enclosing label must be the target. ### 8. Leave dormant legacy pages out of the refactor No router change is needed. These routes already point to household pages: - `/household/imports` - `/household/review` - Legacy `/planning/budget/...` redirects Do not extend these dormant legacy implementations: - `BudgetImportPage.vue` - `BudgetMonthlyImportPage.vue` - `BudgetImportReviewPage.vue` - `BudgetReviewBacklogPage.vue` - `budgetReviewStore.ts` Retain their compatibility tests only until a separate cleanup removes the dead pages. ## Exact test plan ### API contract tests **Modify:** `frontend/src/api/household.test.ts` Add assertions for: 1. Preview sends exactly four files in canonical AKB → Raiffeisen → VISA → Migros order. 2. Every file sends its own profile and real mapping ID. 3. Confirm resends the exact preview file snapshot, decisions, and fingerprints. 4. Review GET includes `scope=exceptions&page_size=25`. 5. Cursor/source/special filters are correctly encoded. 6. No raw CSV is sent to history/options/review endpoints. ### Import component/page tests **Create:** `frontend/src/pages/HouseholdImportPortalFlow.test.ts` Cover: - Exactly four labeled slots. - Files may be selected in any UI order, but the API request order is canonical. - Preview stays disabled until all four slots are valid. - Each slot has independent profile and mapping state. - Replacing AKB leaves the other three unchanged. - Removing VISA leaves the other three unchanged and disables preview. - One preview call produces one consolidated result/fingerprint. - Confirm sends exactly the bound four-file snapshot. - Any file/profile/mapping change marks preview stale and disables confirm. - Late preview and late `File.text()` responses are discarded. - A stale preview can never confirm. - Combined request-size limit is enforced before the API call. - File names, CSV content, mapping IDs, source references, tokens, codes, and fingerprints never appear in rendered text. - Exceptions are collapsed by default and paginated 25 per page when opened. - Confirm clears all four in-memory CSVs and native inputs. ### Review inbox tests **Create:** `frontend/src/pages/HouseholdReviewExceptionInbox.test.ts` Cover: - Only decision and special-case items render. - No “Alle Buchungen”, proposal-ready row, confidence, classification, fingerprint, contract version, reason code, candidate ID, filename, or token appears. - API receives exception scope and page size. - Next/previous pagination updates cursor correctly. - Filter/page changes clear hidden selections and invalidate final preview. - Category changes invalidate final preview. - Batch preview receives visible selected items only. - Confirm includes latest fingerprints and exact selections. - Empty response renders “Alles geprüft.” - Drawer focus trap, Escape, inert background, and focus restoration remain correct. - All action labels are plain-language summaries. ### Existing regression suite **Modify:** `frontend/src/pages/HouseholdUx.test.ts` Keep route, overview, transaction, privacy, and drawer regressions. Move the growing import/review workflow cases into the focused files above. Update assertions to: - Expect four slots. - Expect no all-bookings tab. - Expect explicit stale messaging. - Expect no visible technical wording. ### Browser acceptance **Add:** - `frontend/playwright.config.ts` - `frontend/tests/e2e/household-portal-flow.spec.ts` - `@playwright/test` script/dependency in `frontend/package.json` and lockfile At `390×844`, `820×1180`, and `1440×1000`, verify: - No horizontal document overflow. - Four source slots and consolidated preview are usable. - Review pagination and drawer remain in viewport. - Every interactive target measures at least 44×44 px. - Tab/Shift+Tab order is logical. - Enter/Space operate tabs, file controls, selects, disclosures, and menu. - Escape closes menu/drawer and restores focus. - Technical/private strings are absent from visible text and accessibility snapshots. ## Integration hazards - Backend review currently returns one unpaged array; cursor pagination is a required backend contract change. - Preview currently lacks safe per-file summaries and safe error messages. - `mapping-${index}` must be eliminated before mappings can be independently changed without cross-file mistakes. - Four files can exceed `max_request_bytes` even when each passes `max_file_bytes`. - Fingerprint generation must canonicalize file order; UI selection order must not alter semantic binding. - Row and cluster tokens must remain unique across all four source files. - A mapping/profile change while a file or preview is loading must invalidate both responses. - Server error messages need an allowlisted user-message boundary; rendering raw API errors could leak codes or source details. - Selection must not persist invisibly across server pages or filters. - Variable-height interactive rows make virtualization fragile; explicit 25-row pagination is the safer accessibility choice. - Existing tests prove silent invalidation and one-file behavior, so they must be deliberately replaced rather than superficially extended. ## Repository status - Read-only analysis completed at base `9786857`. - No files created or modified. - Working tree remained clean.