fix(budget): make Cost Breakdown table aggregates filter-aware#1359
fix(budget): make Cost Breakdown table aggregates filter-aware#1359steilerDev merged 3 commits intobetafrom
Conversation
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>
…-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>
…y 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>
steilerDev
left a comment
There was a problem hiding this comment.
[product-architect]
LGTM — clean architecture compliance. Verified:
- DRY win:
resolveLineCost(line, perspective)correctly de-duplicates the line-cost formula across 4 prior call sites (computePerSourcePayback,totalRawProjected,filteredRawProjected's WI/HI walks). Branch-by-branch logic matches original behavior (invoiced → actualCost; quotation → ±5%; estimate → ±margin). - Single tree walk:
computeFilteredAggregateswalks WI + HI areas exactly once, populating per-item / per-area / per-section maps in O(n) (recurses througharea.childrensymmetrically). No O(n²) lookups in render. - Zero-cost happy path: helper short-circuits to
nullwhen!hasSourceFilter; aggregate rows fall back to serverrawProjected*/subsidyPaybackviafilteredTotals ? … : …ternaries — no compute and no display change when filter inactive (AC-13 covered by Test 10). - Context extension over prop drilling: adding
filteredAggregatestoBreakdownContextmatches the existing pattern (hasSourceFilter,visibleLineIdsalready piped this way). Area sections still receive it via prop because they recurse through children — consistent with existingformatCurrencyFnplumbing. costDisplay='actual'semantics:item.costDisplay === 'actual' && !filteredTotalscorrectly preserves prior behavior when filter inactive and switches to filtered-lineactualCostsum (viaresolveLineCostinvoiced branch) when active. Test 12 covers this exactly.- Pro-rata payback:
processItemreuses the same max-cost weighting ascomputePerSourcePayback, keeping subsidy attribution consistent across both helpers. - Memo deps complete:
[breakdown, perspective, hasSourceFilter, visibleLineIds]— correct.
No schema, API, ADR, or wiki changes required. No findings — frontend-only fix, scoped correctly. Approving (comment-only since orchestrator-launched and cannot self-approve).
steilerDev
left a comment
There was a problem hiding this comment.
[ux-designer]
Logic-only bug fix — no CSS, no design-token, no markup changes. Reviewing against the stated scope:
Token/styling — No hardcoded colors, spacing, radii, or font values introduced. No new tokens added. Clean.
'—' placeholder consistency — Guard updated from area.subsidyPayback > 0 to effectiveMaxPayback > 0 at all four Payback-cell call sites (WorkItemRow, WorkItemAreaSection, HouseholdItemRow, HouseholdItemAreaSection). Character and rendering path are identical to the unfiltered case. Consistent.
Accessibility — No changes to row structure, ARIA attributes, or cascade-hide logic. Existing semantics unchanged.
Positive/negative coloring — renderNet is called from the same call sites; the Sum-row Net now passes filteredAdjustedTotalPayback in place of adjustedTotalPayback (and the Remaining Budget row likewise), so valuePositive/valueNegative classification continues to work correctly against the filtered values.
Sum-row Payback guard — Uses filteredAggregates.wiTotals.subsidyPayback + filteredAggregates.hiTotals.subsidyPayback > 0 to decide between valuePositive span and '—', which is the correct filter-aware equivalent of the old maxTotalPayback > 0 guard.
No findings. Approving.
|
🎉 This PR is included in version 2.4.0-beta.3 🎉 The release is available on GitHub release Your semantic-release bot 📦🚀 |
|
🎉 This PR is included in version 2.4.0 🎉 The release is available on GitHub release Your semantic-release bot 📦🚀 |
Summary
When
?deselectedSources=...was active, only Level 3 budget lines were filtered.Every parent aggregate row (Level 0/1/2 + Sum + Remaining Budget) still showed the
server's project-wide totals — children visibly didn't add up to their parents.
This PR makes every aggregate row recompute against the visible (filter-honoring)
line set:
resolveLineCosthelper de-duplicates the line-cost formula across four call sites.computeFilteredAggregateswalks the breakdown once and emits per-item / per-area / per-section maps; returnsnullwhen the filter is inactive (zero perf change).filteredAdjustedTotalPaybacksums per-source payback over selected sources only.costDisplay === 'actual'items respect the filter: with filter active, displayed cost = sum of visible-lineactualCost; with filter inactive, behavior unchanged.BreakdownContextextended withfilteredAggregatesso deeply nested rows pick it up without prop drilling.Closes #1358
Test plan
describe('Source filter — aggregate consistency (#1358)')pass