Skip to content

Commit 3176447

Browse files
steilerDevFrank Steilerclaude
authored
feat(budget): move per-source filter to server-side via ?deselectedSources= (#1361)
* feat(budget): move per-source filter to server-side via ?deselectedSources= The client-side filter introduced in #1356/#1358 left subsidy oversubscription stuck at the project-wide value while filtered cost shrank, producing nonsensical math where Payback could exceed visible Cost. This story moves the filter server-side: GET /api/budget/breakdown now accepts a ?deselectedSources=<id1>,<id2>,unassigned query parameter, filters lines at the top of the breakdown service pipeline, re-runs the subsidy engine against the filtered cost set, and emits filter-aware aggregates and adjustments. The client refetches on each chip toggle (50ms debounce + AbortController + stale-while-revalidate) and removes the entire client-side aggregation layer (~530 lines). Backend: - shared/src/types/budgetBreakdown.ts: BudgetSourceSummaryBreakdown adds subsidyPaybackMin and subsidyPaybackMax (pro-rata from filtered engine). - server/src/services/budgetBreakdownService.ts: accepts deselectedSources; filters lines at top; per-source projected stays unfiltered; per-source payback derived pro-rata from filtered engine; cascade-prunes empty items/areas server-side. - server/src/routes/budgetOverview.ts: parses ?deselectedSources=, accepts comma-separated source IDs and the literal "unassigned"; unknown IDs are silently ignored. - wiki/API-Contract.md: documents the new query param + response shape. Frontend: - client/src/lib/budgetOverviewApi.ts: fetchBudgetBreakdown accepts deselectedSources?: string[]. - client/src/pages/BudgetOverviewPage/BudgetOverviewPage.tsx: 50ms debounce, AbortController cancellation, stale-while-revalidate via .breakdownRefetching opacity dim, error banner with dismiss button. - client/src/components/CostBreakdownTable/CostBreakdownTable.tsx: deletes computeFilteredAggregates, computePerSourcePayback, FilteredAggregates, FilteredEntityTotals, areaHasVisibleLines, all filtered memos, all cascade-hide guards, all filteredAggregates ternaries. Renders directly from server fields. Source row Payback column uses resolveProjected(source.subsidyPaybackMin, source.subsidyPaybackMax, perspective). Synthetic 'unassigned' source from the response is rendered through the normal source-row loop. i18n: refetchError + dismissError keys added in EN and DE. Architectural decisions (recorded on issue #1360): - A: budgetSources[] always includes ALL configured sources; per-source projectedMin/Max stays unfiltered (deselected source rows stay informative). - B: Per-source subsidyPaybackMin/Max emitted by server (pro-rata from filtered engine). Deselected sources get 0. - C: Pure server filter, no caching beyond stale-while-revalidate on client. - D: 50ms debounce + AbortController. - Scope: /api/budget/overview filter-awareness deferred (hero card stays project-wide for v1). This explicitly supersedes the #1356 decision that "subsidy adjustments stay project-wide". Closes #1360 Co-Authored-By: Claude backend-developer (Haiku 4.5) <noreply@anthropic.com> Co-Authored-By: Claude frontend-developer (Haiku 4.5) <noreply@anthropic.com> Co-Authored-By: Claude translator (Sonnet 4.5) <noreply@anthropic.com> Co-Authored-By: Claude qa-integration-tester (Sonnet 4.5) <noreply@anthropic.com> Co-Authored-By: Claude e2e-test-engineer (Sonnet 4.5) <noreply@anthropic.com> Co-Authored-By: Claude product-owner (Sonnet 4.5) <noreply@anthropic.com> Co-Authored-By: Claude product-architect (Sonnet 4.5) <noreply@anthropic.com> Co-Authored-By: Claude dev-team-lead (Sonnet 4.6) <noreply@anthropic.com> * test(budget): adapt #1360 backend tests for always-included system sources Migration 0021 seeds a 'discretionary-system' row in budget_sources, and architect decision A on issue #1360 means budgetSources[] now always includes ALL configured sources (including system-seeded ones). Tests that asserted empty arrays or fixed lengths/indices were stale. - Replace toHaveLength(N) + [0]! patterns with .find(s => s.id === ...) lookups across the 'budgetSources aggregate' describe block. - Filter out discretionary-system + unassigned in "empty sources" assertions. - Scenario 9 length assertion relaxed to >= 3. - Scenarios 7b and 10: drop categoryIds filter on subsidies — the lines insert with budgetCategoryId=null, so a category-filtered subsidy correctly returned 0 payback. Use a universal subsidy instead. Fixes #1360 Co-Authored-By: Claude qa-integration-tester (Sonnet 4.5) <noreply@anthropic.com> * test(budget): delete stale client cascade tests + fix backend assertions CI revealed two more groups of stale assertions: - 4 client-side cascade-hide tests in CostBreakdownTable.test.tsx asserted filter behavior that has moved server-side in #1360. Deleted scenarios 31, 8, 32, 33, 35 plus the now-unused buildBreakdownWithMixedSourceLines helper. Equivalent coverage exists in backend integration tests + E2E. - Service test "no user sources" assertion now also filters the synthetic 'unassigned' entry that the server emits when any line has null source. - Service test Scenario 7b: added maximumAmount: 100 to the subsidy program so the 20% subsidy actually overflows its cap. subsidyAdjustments only emits oversubscribed entries; without a cap the array stays empty. - insertSubsidyProgram helper accepts optional maximumAmount. Fixes #1360 Co-Authored-By: Claude qa-integration-tester (Sonnet 4.5) <noreply@anthropic.com> * test(e2e): fix #1360 source-filter scenarios — empty fixture hid source rows The CostBreakdownTable component renders an early-return empty state when both workItems.areas and householdItems.areas are empty. The 'makeFilteredEmptyBreakdown' fixture used by 4 tests caused the table to switch into empty state after refetch — at which point the source detail rows and the Available Funds button no longer existed in the DOM, so subsequent Playwright assertions failed with "element(s) not found". - New makeFilteredBreakdownBankLoanDeselected({includeSourceB?}) fixture: filtered response that keeps at least the unassigned line, so workItems.areas is non-empty and the source rows stay rendered. - Use the new fixture in all four affected tests instead of the empty one (where the test isn't specifically validating the empty state). - Move aria-pressed='false' assertions BEFORE awaiting the refetch promise — the attribute is driven by URL search params (synchronous on click), not by the network round-trip. - "Cascade-hide on mobile": expand Work Items section before the deselection (while hasData=true). The previous order tried to expand AFTER the empty fixture replaced the table. Fixes #1360 Co-Authored-By: Claude e2e-test-engineer (Sonnet 4.5) <noreply@anthropic.com> * fix(budget): keep source rows visible when all sources are deselected (#1360) The CostBreakdownTable's empty-state guard previously triggered whenever both wiAreas and hiAreas were empty — including the case where a user deselects all sources and the server prunes every item. That UX dead-end prevented re-enabling sources because the source rows themselves were no longer rendered. Production fix: the guard now also requires budgetSources.length === 0. When sources are configured (selected or deselected), the full table renders with source detail rows, the Available Funds expand button, the Sum row (€0.00 across the board), and the Remaining Budget row (showing the project-wide availableFunds). The user can re-enable any source by clicking its row. Test: add Scenario 24 to "Server-driven render path (#1360)" verifying the all-deselected case keeps the rest of the table visible with zeroed totals and absent empty-state copy. Fixes #1360 Co-Authored-By: Claude frontend-developer (Haiku 4.5) <noreply@anthropic.com> Co-Authored-By: Claude qa-integration-tester (Sonnet 4.5) <noreply@anthropic.com> * test(e2e): fix #1360 URL-on-mount test — don't expand empty filtered tree The 'restores filter on load' test navigates with ?deselectedSources=src-a and the filtered fixture returns empty areas. The previous helper tried to expand the Work Items section + Main Area + Main Work Item, but those buttons don't exist when areas is empty. Just goto + waitForLoaded; the test goal is verifying the URL param drives the filter state and Source A lines are absent — no expansion needed. Fixes #1360 Co-Authored-By: Claude e2e-test-engineer (Sonnet 4.5) <noreply@anthropic.com> * fix(budget): error banner dark-mode contrast + focus-visible (#1360) - Switch .breakdownErrorBanner color from --color-text-primary to --color-text-inverse so the banner text remains readable on top of --color-bg-inverse in both light and dark mode (the previous pairing resolved to near-white text on light grey in dark mode). - Replace dismiss button focus-visible outline with the standard --shadow-focus pattern used across the app. Fixes #1360 Co-Authored-By: Claude frontend-developer (Haiku 4.5) <noreply@anthropic.com> --------- Co-authored-by: Frank Steiler <frank@steiler.de> Co-authored-by: Claude backend-developer (Haiku 4.5) <noreply@anthropic.com>
1 parent af8f6a3 commit 3176447

18 files changed

Lines changed: 2668 additions & 2017 deletions

File tree

.claude/agent-memory/e2e-test-engineer/MEMORY.md

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,20 +3,24 @@
33
> Detailed notes live in topic files. This index links to them.
44
> See: `e2e-pom-patterns.md`, `e2e-parallel-isolation.md`, `story-epic08-e2e.md`, `story-933-dav-vendor-contacts.md`, `milestones-e2e.md`, `story-1248-mass-move.md`
55
6-
## Budget Source Filter E2E (Story #1354, 2026-04-25)
7-
8-
- BudgetSourceChip `aria-label` = `"Filter: {name} (selected)"` / `"Filter: {name} (not selected)"` — use `new RegExp(name)` in `getByRole('button', { name })`.
9-
- Filter toolbar `aria-label` = `"Filter by source"` (i18n key `overview.costBreakdown.sourceFilter.label`).
10-
- Clear filter button `aria-label` = `"Clear source filter — show all sources"` — use `/Clear source filter/i`.
6+
## Budget Source Filter E2E (Story #1360, 2026-04-25 — server-side filter)
7+
8+
- **Story #1360** rewrote filter from client-side to server-side. `BudgetSourceSummaryBreakdown` now has `subsidyPaybackMin/Max` NOT `subsidyPayback`.
9+
- URL format: `?deselectedSources=id1,id2` (comma-separated, URL-encoded via `encodeURIComponent(join(','))`).
10+
- `waitForResponse` predicate for filtered: `url.includes('/api/budget/breakdown') && url.includes('deselectedSources=')`.
11+
- `waitForResponse` predicate for unfiltered: `url.includes('/api/budget/breakdown') && !url.includes('deselectedSources=')`.
12+
- **MUST register `waitForResponse` BEFORE the click** that triggers the debounced refetch.
13+
- Route mock glob for breakdown: `'**/api/budget/breakdown**'` (leading `**` + trailing `**`) to match full URLs with `http://localhost:PORT/` prefix AND `?deselectedSources=` query strings. Path-only `${API.budgetBreakdown}**` is unreliable — see Playwright route glob memory note.
14+
- `mountOverviewRoutes` now accepts 4th arg `filteredBreakdownBody?` — returns it when `deselectedSources=` is in URL.
15+
- `makeBreakdownResponse` unassigned source in `budgetSources` now has `id:'unassigned'` — included by default (no opt-in).
16+
- `makeBreakdownSourceAOnly` budgetSources: uses `subsidyPaybackMin: 0, subsidyPaybackMax: 0` (not `subsidyPayback`).
17+
- `breakdownRefetching` CSS class applied to wrapping div during in-flight refetch — testable via `[class*="breakdownRefetching"]`.
18+
- Debounce is 50ms. Debounce debounce coalescence: `filteredRequestCount` listener on `page.on('request')` — works across AbortController cancellations.
1119
- Available Funds expand button `aria-label` = `"Expand available funds sources"` (hardcoded, not i18n).
1220
- Source badge in Level 3 rows: `<span aria-label="Budget source: {name}">`. Unassigned: `aria-label="Budget source: Unassigned"`.
13-
- Long name truncation: badge label text ends with ``. Full name in `title` attribute.
14-
- Mobile "badge dot-only" behavior: IMPLEMENTED. `<span class*="sourceBadgeDot">` (aria-hidden) shown at ≤767px; `<span class*="sourceBadgeLabel">` hidden (display:none). Select via `[class*="sourceBadgeDot"]` / `[class*="sourceBadgeLabel"]` (hashed CSS module names). The Badge aria-label stays in DOM inside the hidden label span for screen readers.
15-
- CSS module class for selected source detail row: `rowSourceDetailSelected` — Playwright sees hashed class e.g. `rowSourceDetailSelected_xyz`. Use `.toMatch(/rowSourceDetailSelected/)` regex on class attribute.
16-
- `overflow-x: auto` on `.sourceFilterStrip` — testable via `getComputedStyle(el).overflowX === 'auto'`.
17-
- `budgetSources` array comes from `breakdown.budgetSources` (in the breakdown response), NOT from the `/api/budget-sources` endpoint — mock both if needed.
18-
- Escape clears filter: IMPLEMENTED. `handleToolbarKeyDown` on `<div role="toolbar">` checks `e.key === 'Escape' && selectedSourceIds.size > 0`, calls `onClearSources()` + `availFundsButtonRef.current?.focus()`. No-op when no sources selected. Test via `chip.focus(); keyboard.press('Escape')` then assert `aria-pressed=false`, URL clean, and `availFundsButton.toBeFocused()`.
21+
- Source row toggle: `tr[class*="rowSourceDetail"]` with `aria-pressed` attribute. Filter by `getByText(name, {exact:true})`.
1922
- Dark mode color check: create throw-away element to normalize `rgb()` format (see Print E2E Patterns note).
23+
- Prior Story #1354 chips/toolbar pattern is gone — no `role="toolbar"` anymore (tests assert its absence).
2024

2125
## Print E2E Patterns (Issue #1310, 2026-04-19)
2226

.claude/agent-memory/qa-integration-tester/MEMORY.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,16 @@
33
> Detailed notes live in topic files. This index links to them.
44
> See: `budget-categories-story-142.md`, `e2e-pom-patterns.md`, `e2e-parallel-isolation.md`, `story-358-document-linking.md`, `story-360-document-a11y.md`, `story-epic08-e2e.md`, `story-509-manage-page.md`, `story-471-dashboard.md`
55
6+
## Story #1360 — Server-Side Source Filter Tests (2026-04-25)
7+
8+
**CostBreakdownTable.test.tsx**: Replaced the 12-test `describe('Source filter — aggregate consistency (#1358)')` block with 4-test `describe('Server-driven render path (#1360)')`. The 12 old tests tested deleted client-side helpers (`computePerSourcePayback`, `computeFilteredAggregates`, `visibleLineIds`). Removal strategy: Python `content.replace()` on large block — incremental Edit tool calls left orphaned code. The `buildBreakdownWithTwoSources()` helper was replaced by `buildServerFilteredBreakdown()`.
9+
10+
**Route test `insertWorkItemWithSource` has `budgetSourceId: string` (NOT nullable)**: Use `insertWorkItem({ plannedAmount, confidence })` for null-source WIs in route tests — it always sets `budgetSourceId: null`.
11+
12+
**`BudgetSourceSummaryBreakdown` type now requires `subsidyPaybackMin/Max`**: Existing tests that use `{ id, name, totalAmount, projectedMin, projectedMax }` without these fields will have TypeScript errors. New tests must include both fields.
13+
14+
**Debounce + AbortController test patterns**: For Scenario 29 (error path), use real timers + `waitFor({ timeout: 5000 })` instead of fake timers. The `DEBOUNCE_MS=50` effect fires after `isLoading` transitions to false (double-fetch on mount is intentional — debounce effect re-runs when `isLoading` changes). For scenarios with fake timers: use `await act(async () => { jest.advanceTimersByTime(100); await Promise.resolve(); })` to advance timers and flush microtasks together.
15+
616
## Story #1358 — CostBreakdownTable Filtered Aggregate Tests (2026-04-25)
717

818
Added `describe('Source filter — aggregate consistency (#1358)')` block (12 tests, lines ~4003–4782) to `CostBreakdownTable.test.tsx`. Key patterns: (1) Use `within(row).getByText(...)` to avoid multi-match collisions. (2) Get Level 0 header row via `screen.getByRole('button', { name: 'Expand work item budget by area' }).closest('tr')`. (3) Get Level 1 area row via `screen.getByRole('button', { name: 'Expand WI Area' }).closest('tr')`. (4) Get Level 2 item row via `screen.getByRole('link', { name: 'Item Title' }).closest('tr')`. (5) `td.colBudget` selector on rows for cost cell text assertions. (6) Math: `resolveLineCost(line, avg)` for `own_estimate` with `plannedAmount=N` = N (avg of 0.8N and 1.2N). (7) Pro-rata payback share = weight × entityPayback where weight = max-cost / sum-of-max-costs.

0 commit comments

Comments
 (0)