Commit 3176447
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
- qa-integration-tester
- client/src
- components/CostBreakdownTable
- i18n
- de
- en
- lib
- pages/BudgetOverviewPage
- e2e/tests/budget
- server/src
- routes
- services
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
3 | 3 | | |
4 | 4 | | |
5 | 5 | | |
6 | | - | |
7 | | - | |
8 | | - | |
9 | | - | |
10 | | - | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
11 | 19 | | |
12 | 20 | | |
13 | | - | |
14 | | - | |
15 | | - | |
16 | | - | |
17 | | - | |
18 | | - | |
| 21 | + | |
19 | 22 | | |
| 23 | + | |
20 | 24 | | |
21 | 25 | | |
22 | 26 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
3 | 3 | | |
4 | 4 | | |
5 | 5 | | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
6 | 16 | | |
7 | 17 | | |
8 | 18 | | |
| |||
0 commit comments