release: promote beta to main — source attribution & per-source filter for cost breakdown#1362
Closed
steilerDev wants to merge 8 commits intomainfrom
Closed
release: promote beta to main — source attribution & per-source filter for cost breakdown#1362steilerDev wants to merge 8 commits intomainfrom
steilerDev wants to merge 8 commits intomainfrom
Conversation
…cost breakdown overview (#1355) * test(budget): add unit & integration tests for source attribution badges and per-source filter (#1354) - New: client/src/lib/budgetSourceColors.test.ts — 15 tests for getSourceColorIndex (always [1,9], deterministic, never 0) and getSourceBadgeStyleKey (null→sourceUnassigned, consistency with colorIndex) - New: client/src/components/BudgetSourceChip/BudgetSourceChip.test.tsx — 18 tests for rendering, 24-char truncation, aria-pressed, onToggle callback, disabled state, CSS custom properties (--chip-dot/bg/text), aria-label, keyboard interaction - Extended: client/src/components/Badge/Badge.test.tsx — 2 tests for new title prop (forwards to span, absent when omitted) - Extended: client/src/components/CostBreakdownTable/CostBreakdownTable.test.tsx — updated all prop usages (selectedSourceIds/onSourceToggle/onClearSources replacing budgetSources=[]), added budgetSourceId to fixtures, added 20+ tests for source badge rendering, chip strip, filter active state, clear button, empty state - Extended: client/src/pages/BudgetOverviewPage/BudgetOverviewPage.test.tsx — added budgetSources:[] to breakdown fixtures - Extended: server/src/services/budgetBreakdownService.test.ts — 3 helpers + 2 describe blocks (~20 tests) for budgetSourceId attribution on WI/HI lines and budgetSources aggregate (id/name/totalAmount, projectedMin/Max by confidence, multi-line accumulation, multi-source, empty cases) - Extended: server/src/routes/budgetOverview.breakdown.test.ts — 2 helpers + 2 tests for budgetSources array in HTTP response and empty array when no sources assigned Fixes #1354 Co-Authored-By: Claude qa-integration-tester (Sonnet 4.5) <noreply@anthropic.com> * feat(budget): add source attribution badges and per-source filter to cost breakdown overview - Display a deterministic-color source badge next to each line's confidence/quoted/invoiced badge in the cost breakdown table - Mobile collapses the source badge to a color dot with full source name in aria-label/title - Available Funds row redesigned as a multi-select chip toolbar showing per-source allocated cost and remaining balance - Multi-select OR semantics filter the breakdown rows; subtotals and grand totals recompute against the filtered set - URL state ?sources=<id>,<id> persists the filter across reloads - Empty state when no lines match the active filter - Keyboard: Tab through chips, Space/Enter toggles, Escape clears filter and refocuses the Available Funds expand button - Backend: GET /api/budget/breakdown extended with budgetSourceId per line and a budgetSources aggregate map (id, name, totalAmount, projectedMin, projectedMax) - Tokens: new --color-source-N-{bg,text,dot} family (10 slots, light + dark) - New shared component: BudgetSourceChip - New helper: client/src/lib/budgetSourceColors.ts (deterministic id->slot mapping) Fixes #1354 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 ux-designer (Sonnet 4.5) <noreply@anthropic.com> Co-Authored-By: Claude dev-team-lead (Sonnet 4.6) <noreply@anthropic.com> * test(budget): fix multi-element text assertions in CostBreakdownTable tests Source names now appear in both the chip filter strip and the expanded sub-rows. Replace getByText assertions with getAllByText length checks to disambiguate. Fixes #1354 Co-Authored-By: Claude dev-team-lead (Sonnet 4.6) <noreply@anthropic.com> Co-Authored-By: Claude qa-integration-tester (Sonnet 4.5) <noreply@anthropic.com> * test(budget): use queryByText for collapse assertion (chip strip is also gated by expand) The Available Funds chip filter strip is rendered inside the {availFundsExpanded && ...} block, so on collapse both the chip strip and the sub-rows unmount. Replace getAllByText.toHaveLength(1) with queryByText.not.toBeInTheDocument(). Fixes #1354 Co-Authored-By: Claude qa-integration-tester (Sonnet 4.5) <noreply@anthropic.com> * fix(budget): defensive budgetSources fallback and E2E selector fixes - CostBreakdownTable defensively coalesces breakdown.budgetSources to [] so existing fixtures and forward-compatible payloads do not crash the cost breakdown rendering. - E2E sourceDetailRow page-object now scopes to tr[class*="rowSourceDetail"] to avoid matching the chip-toolbar row that also contains the source name. - Dark-mode badge color test uses toBeAttached() instead of toBeVisible() so it works on mobile where the badge label is CSS-hidden. Fixes #1354 Co-Authored-By: Claude frontend-developer (Haiku 4.5) <noreply@anthropic.com> Co-Authored-By: Claude e2e-test-engineer (Sonnet 4.5) <noreply@anthropic.com> * test(budget): fix E2E badge visibility and selected-class race conditions - Source badge tests on Level 3 rows use toBeAttached() instead of toBeVisible() because mobile hides the badge label via CSS while keeping the element in the DOM for screen-reader access. - Selected-source detail row class assertion uses toHaveClass() so it auto-retries through the chip-click → URL-state → React-render round-trip. Fixes #1354 Co-Authored-By: Claude e2e-test-engineer (Sonnet 4.5) <noreply@anthropic.com> --------- Co-authored-by: Frank Steiler <frank@steiler.de> Co-authored-by: Claude qa-integration-tester (Sonnet 4.5) <noreply@anthropic.com>
…with Cost/Payback/Net columns (#1357) * feat(budget): rework per-source filter to use source rows as toggles with Cost/Payback/Net columns - Source detail rows under Available Funds become the filter affordance: each <tr role="button" aria-pressed> toggles its source on click, Space, or Enter; Escape selects all - All sources start selected by default; URL state stores deselections via ?deselectedSources=<id1>,<id2> - Deselected rows are visually dimmed (text muted, dot opacity 0.4, no left-border accent); aria-pressed conveys state - Items, areas, and any container with no surviving budget lines render null (cascade beyond just lines) - Available Funds total recomputes to the sum of currently-selected sources; "(X of Y selected)" caption shown when filter is active - New per-source columns: Cost (perspective-resolved sum), Payback (entity-level pro-rata, computed client-side), Net = totalAmount + payback - cost - Remove BudgetSourceChip component, chip filter strip, and obsolete English/German i18n keys - Live region moved outside table wrapper so announcements survive empty-state toggles Closes #1356 This supersedes the chip-toolbar UX shipped in #1354/PR #1355 per user feedback. Co-Authored-By: Claude dev-team-lead (Sonnet 4.6) <noreply@anthropic.com> Co-Authored-By: Claude frontend-developer (Haiku 4.5) <noreply@anthropic.com> Co-Authored-By: Claude translator (Sonnet 4.6) <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 ux-designer (Sonnet 4.6) <noreply@anthropic.com> Co-Authored-By: Claude product-architect (Opus 4.6) <noreply@anthropic.com> Co-Authored-By: Claude product-owner (Opus 4.6) <noreply@anthropic.com> * fix(budget): resolve i18n key collision and stale test assertions - Rename i18n key overview.costBreakdown.availableFunds (object) to availableFundsFilter to avoid colliding with the same-named string ("Available funds"). JSON last-write-wins meant the label was being overwritten by the caption object, breaking 3 unit tests and the rendered Available Funds row label. - Scope getByText('€200,000.00') to the Available Funds row via within() to disambiguate from the source detail row's Net column showing the same currency value. - Relax the "1 of 2" caption regex to "<digit> of <digit>" — the fixture includes an unassigned line so the total is N+1 named sources. - Replace the className-comparison dark-mode smoke check with an aria-pressed attribute assertion since deselected rows are styled via attribute selectors, not class toggles. Fixes #1356 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> Co-Authored-By: Claude e2e-test-engineer (Sonnet 4.5) <noreply@anthropic.com> Co-Authored-By: Claude translator (Sonnet 4.5) <noreply@anthropic.com> * test(budget): fix two stale CostBreakdownTable scenarios for #1356 - Remove the second fireEvent.click in the work-item cascade test — the 'No Area' container is also cascade-hidden, so its expand button is never rendered. Asserting 'Sourced Work Item' is absent after the WI section expands is sufficient. - Update the "expand shows sub-rows with name and Net value" test to set projectedMin/projectedMax to 0 on the source summaries so the Net column equals totalAmount; previously the default 5000/8000 values produced a non-zero Cost making Net != totalAmount. Fixes #1356 Co-Authored-By: Claude qa-integration-tester (Sonnet 4.5) <noreply@anthropic.com> * test(budget): use toHaveText auto-retry for live region + bump mobile row padding - Replace `textContent()` + `toMatch` with `toHaveText` regex assertion on the filter live region. The previous synchronous read could land before React re-rendered the announcement after the chip-row click. - Bump mobile row vertical padding from spacing-3 (12px) to spacing-4 (16px) so the source detail row's bounding box meets the 44px touch target on mobile viewports. Fixes #1356 Co-Authored-By: Claude e2e-test-engineer (Sonnet 4.5) <noreply@anthropic.com> Co-Authored-By: Claude frontend-developer (Haiku 4.5) <noreply@anthropic.com> * fix(budget): include Unassigned in selected count for caption + live region Both the "(X of Y selected)" caption and the role="status" live region computed selected count without considering the Unassigned pseudo-source. When the user deselects the Unassigned chip, the previous expression (budgetSources.length - deselectedSourceIds.size) drifted off-by-one because deselectedSourceIds may contain the literal 'unassigned' key that isn't in budgetSources. Both expressions now compute selected as named-selected + (hasUnassignedLines && !deselectedSourceIds.has('unassigned') ? 1 : 0) matching the existing total formula budgetSources.length + (hasUnassignedLines ? 1 : 0). Fixes #1356 Co-Authored-By: Claude frontend-developer (Haiku 4.5) <noreply@anthropic.com> * test(budget): update caption test to use locale-agnostic regex The off-by-one fix correctly counts the Unassigned pseudo-source as a virtual source. The fixture (2 named + 1 unassigned = 3 virtual) now shows "(2 of 3 selected)" after deselecting one source — the previous "/1\D+\d+/" regex no longer matches. Use toHaveText with a generic "<digit> of <digit>" regex so the assertion is locale-agnostic and robust to count semantics. Fixes #1356 Co-Authored-By: Claude e2e-test-engineer (Sonnet 4.5) <noreply@anthropic.com> --------- Co-authored-by: Frank Steiler <frank@steiler.de> Co-authored-by: Claude dev-team-lead (Sonnet 4.6) <noreply@anthropic.com>
* fix(budget): make Cost Breakdown table aggregates filter-aware When a source filter was active (?deselectedSources=...), only Level 3 budget lines were filtered while every aggregate row (Level 0/1/2 + Sum + Remaining Budget) kept showing the server's project-wide values. This made child rows inconsistent with their parents: Cost, Payback, and Net columns would not add up. - Add resolveLineCost(line, perspective) pure helper and de-duplicate the four-way copy of the line-cost formula (filteredRawProjected, unassignedAllocatedCost, computePerSourcePayback, BudgetLineRow). - Add computeFilteredAggregates that walks the breakdown tree and emits per-item, per-area, and per-section cost + payback aggregates derived from visible lines only. Returns null when the filter is inactive (no perf change on the happy path). - Add filteredAdjustedTotalPayback memo that sums perSourcePayback over selected sources (and Unassigned bucket) with resolvedTotalExcess applied. - Extend BreakdownContext with filteredAggregates so deeply nested rows pick up filtered values without prop drilling. - WorkItemAreaSection, HouseholdItemAreaSection, WorkItemRow, HouseholdItemRow, WI/HI Level 0 section headers, Sum Payback/Net, and Remaining Budget Net all read filtered aggregates when the filter is active and fall back to server values otherwise. The "—" payback guard at every level switches from the server-stored max payback to the filtered effective max. - costDisplay === 'actual' items: when filter active, sum visible-line costs; when filter inactive, retain previous item.actualCost behavior. The "invoiced" badge stays in both cases. Closes #1358 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> Co-Authored-By: Claude product-owner (Sonnet 4.5) <noreply@anthropic.com> Co-Authored-By: Claude dev-team-lead (Sonnet 4.6) <noreply@anthropic.com> * test(budget): use cell-selector textContent checks in #1358 aggregate-consistency tests within(row).getByText('-€5,000.00') matched multiple elements because formatCurrency renders negative amounts as nested spans whose normalized textContent equals the full string AND the parent <td> textContent also matches. When the same filtered value appears at multiple tree levels (section = area = item), the ambiguity multiplies. Replace each positive assertion with a column-targeted cell read: const cell = row.querySelector('td[class*="colBudget"]'); expect(cell!.textContent?.replace(/\s+/g, '')).toBe('-€5,000.00'); 10 tests updated across the new "Source filter — aggregate consistency" describe block. Negative queryByText assertions are unchanged (safe). Fixes #1358 Co-Authored-By: Claude qa-integration-tester (Sonnet 4.5) <noreply@anthropic.com> * test(budget): fix Sum row locator regex in #1358 aggregate-consistency test The accessible name of a <tr> is the concatenated text of all its cells, so the Sum row's name is "Sum -€1,000.00 €1,600.00 €600.00", not just "Sum". The /^Sum$/i regex matched nothing, causing getByRole to fail. Use /^Sum\b/i (start-anchored, word boundary) instead. Fixes #1358 Co-Authored-By: Claude qa-integration-tester (Sonnet 4.5) <noreply@anthropic.com> --------- Co-authored-by: Frank Steiler <frank@steiler.de> Co-authored-by: Claude frontend-developer (Haiku 4.5) <noreply@anthropic.com>
…urces= (#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>
Owner
Author
Detailed ValidationStep-by-step instructions to manually validate the headline changes. Run on the beta image ( 1 — Source attribution (visual)
2 — Available Funds row layout
3 — Per-source filter toggle
4 — URL persistence
5 — Subsidy oversubscription consistency
6 — Deselect everything (former dead-end, fixed in #1360)
7 — Keyboard
8 — Cascade-hide
9 — Dark mode
10 — API smokecurl -s 'http://<host>/api/budget/breakdown?deselectedSources=<known-id>,unassigned' | jq '
{
filteredLineCount: (
[
.workItems.areas[].items[].lines[],
.householdItems.areas[].items[].lines[]
] | length
),
sourcesIncluded: (.budgetSources | map({id, name, subsidyPaybackMin, subsidyPaybackMax}))
}
'Expect:
|
Merged
12 tasks
Owner
Author
|
Superseded by #1363, which includes a small ci.yml improvement (workflow_dispatch trigger) needed to get CI to fire on beta→main promotion PRs. |
steilerDev
pushed a commit
that referenced
this pull request
Apr 27, 2026
…ds and Remaining Budget rows Tests all three filter states for the bug fix (#1362): - All sources selected: filteredAvailableFunds = sum of all source totalAmounts - One source deselected: filteredAvailableFunds excludes deselected source amount - All sources deselected: filteredAvailableFunds = 0 (not unfiltered total) - Remaining Budget Cost and Net columns use filteredAvailableFunds in all states - 'unassigned' source with totalAmount=0 does not affect filteredAvailableFunds - Re-render with empty deselectedSourceIds restores unfiltered values Fixes #1362 Co-Authored-By: Claude qa-integration-tester (Sonnet 4.5) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Release Summary
Adds source attribution and a per-source filter to the Cost Breakdown table on the Budget Overview, plus the supporting backend pipeline. Each cost line now shows which budget source funds it (color-coded badge) and can be filtered out by toggling the corresponding source row. Filtering is server-side (
?deselectedSources=…), so subsidy oversubscription, Sum, and Remaining Budget all stay mathematically consistent with the visible set.This release supersedes the earlier chip-toolbar UX (PR #1354/#1355) with a row-as-toggle UX (#1357), and the originally client-side filter math (#1359) with a server-side filter pipeline (#1361). Followed by two small UX fixes (empty-state dead-end, dark-mode banner contrast).
Changes
Features
?deselectedSources=<id>,<id>,unassigned;Escapeselects all (feat(budget): rework per-source filter to use source rows as toggles with Cost/Payback/Net columns #1357)GET /api/budget/breakdownaccepts?deselectedSources=…, filters lines at the top of the breakdown service, re-runs the subsidy engine against the filtered set, and emits filter-aware aggregates includingsubsidyPaybackMin/Maxper source (feat(budget): move per-source filter to server-side via ?deselectedSources= #1361)Fixes
Tests
budgetSourceColors,Badge(title prop),CostBreakdownTable(badge rendering, chip strip, filter active state, empty state, server-driven render path scenarios)e2e/tests/budget/budget-source-filter.spec.ts(filter toggle, URL persistence, mobile touch targets, dark-mode color, cascade-hide)Docs
budgetSourceIdper line, thebudgetSourcesaggregate, and thedeselectedSourcesquery paramChange Inventory
Backend (
server/,shared/)server/src/services/budgetBreakdownService.ts— acceptdeselectedSources, filter lines at top, per-source projected stays unfiltered, per-source payback derived pro-rata from filtered engine, cascade-prune empty items/areas server-sideserver/src/routes/budgetOverview.ts— parse?deselectedSources=…(comma-separated source IDs +unassigned); unknown IDs silently ignoredserver/src/routes/budgetOverview.breakdown.test.ts— coverage for the new param + response shapeserver/src/services/budgetBreakdownService.test.ts— coverage for filter, source aggregation, and oversubscription mathshared/src/types/budgetBreakdown.ts— addedbudgetSourceIdper line,budgetSources[]aggregate,subsidyPaybackMin/Maxper sourceshared/src/index.ts— re-exportsFrontend (
client/)client/src/components/CostBreakdownTable/CostBreakdownTable.{tsx,module.css,test.tsx}— source badges per line; source detail rows are toggleable; per-source Cost / Payback / Net columns; renders directly from server fields (client-side aggregation removed); empty-state guard relaxed to also requirebudgetSources.length === 0client/src/components/Badge/{Badge.tsx,Badge.module.css,Badge.test.tsx}— addedtitlepropclient/src/pages/BudgetOverviewPage/BudgetOverviewPage.{tsx,module.css,test.tsx}— 50ms debounce + AbortController on filter change, stale-while-revalidate dim, error banner with dismiss + correct dark-mode contrastclient/src/lib/budgetOverviewApi.{ts,test.ts}—fetchBudgetBreakdownacceptsdeselectedSources?: string[]client/src/lib/budgetSourceColors.{ts,test.ts}— new helper: deterministic id → palette slotclient/src/i18n/{en,de}/budget.json— keys:availableFundsFilter,refetchError,dismissError, source filter strings (translated to DE)client/src/styles/tokens.css— new--color-source-N-{bg,text,dot}family (10 slots, light + dark)E2E (
e2e/)e2e/tests/budget/budget-source-filter.spec.ts— new spece2e/pages/BudgetOverviewPage.ts— page-object updates for source detail rows + filter affordanceDocs / Config
wiki/— API Contract updates forbudgetSourceId,budgetSources,deselectedSourcespackage-lock.jsonManual Validation Checklist
totalAmount + Payback − Cost.aria-pressed=\"false\", and lines funded by that source disappear from the table. Sum / Remaining Budget / Available Funds caption (X of Y selected) update accordingly.?deselectedSources=<id>[,<id>]. Reload the page → the same sources stay deselected and the filter is restored on first paint.SpaceorEnterto toggle. PressEscapeto select all.GET /api/budget/breakdown?deselectedSources=<known-id>,unassignedreturns 200; lines from those sources are absent;budgetSources[]still includes ALL configured sources; per-sourcesubsidyPaybackMin/Maxis 0 for deselected sources.Testing
docker pull steilerdev/cornerstone:betadocker pull steilerdev/cornerstone:pr-1362(replace once PR number is assigned)