Skip to content

fix(budget): make Cost Breakdown table aggregates filter-aware#1359

Merged
steilerDev merged 3 commits intobetafrom
fix/1358-cost-breakdown-filter-consistency
Apr 26, 2026
Merged

fix(budget): make Cost Breakdown table aggregates filter-aware#1359
steilerDev merged 3 commits intobetafrom
fix/1358-cost-breakdown-filter-consistency

Conversation

@steilerDev
Copy link
Copy Markdown
Owner

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:

  • New resolveLineCost helper de-duplicates the line-cost formula across four call sites.
  • New computeFilteredAggregates walks the breakdown once and emits per-item / per-area / per-section maps; returns null when the filter is inactive (zero perf change).
  • filteredAdjustedTotalPayback sums per-source payback over selected sources only.
  • Every aggregate row switches to filtered values when filter active, falls back to server values otherwise.
  • costDisplay === 'actual' items respect the filter: with filter active, displayed cost = sum of visible-line actualCost; with filter inactive, behavior unchanged.
  • BreakdownContext extended with filteredAggregates so deeply nested rows pick it up without prop drilling.

Closes #1358

Test plan

  • Quality Gates (unit + integration) green
  • 12 new test scenarios in describe('Source filter — aggregate consistency (#1358)') pass
  • AC-13 regression: with filter inactive, all rows still match server values
  • AC-14 internal consistency: visible Area Cost = sum of visible Item Costs at every level

Frank Steiler and others added 3 commits April 25, 2026 20:08
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>
Copy link
Copy Markdown
Owner Author

@steilerDev steilerDev left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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: computeFilteredAggregates walks WI + HI areas exactly once, populating per-item / per-area / per-section maps in O(n) (recurses through area.children symmetrically). No O(n²) lookups in render.
  • Zero-cost happy path: helper short-circuits to null when !hasSourceFilter; aggregate rows fall back to server rawProjected* / subsidyPayback via filteredTotals ? … : … ternaries — no compute and no display change when filter inactive (AC-13 covered by Test 10).
  • Context extension over prop drilling: adding filteredAggregates to BreakdownContext matches the existing pattern (hasSourceFilter, visibleLineIds already piped this way). Area sections still receive it via prop because they recurse through children — consistent with existing formatCurrencyFn plumbing.
  • costDisplay='actual' semantics: item.costDisplay === 'actual' && !filteredTotals correctly preserves prior behavior when filter inactive and switches to filtered-line actualCost sum (via resolveLineCost invoiced branch) when active. Test 12 covers this exactly.
  • Pro-rata payback: processItem reuses the same max-cost weighting as computePerSourcePayback, 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).

Copy link
Copy Markdown
Owner Author

@steilerDev steilerDev left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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 coloringrenderNet 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.

@steilerDev steilerDev merged commit 0e70fab into beta Apr 26, 2026
32 checks passed
@steilerDev steilerDev deleted the fix/1358-cost-breakdown-filter-consistency branch April 26, 2026 13:43
@github-actions
Copy link
Copy Markdown
Contributor

🎉 This PR is included in version 2.4.0-beta.3 🎉

The release is available on GitHub release

Your semantic-release bot 📦🚀

@github-actions
Copy link
Copy Markdown
Contributor

🎉 This PR is included in version 2.4.0 🎉

The release is available on GitHub release

Your semantic-release bot 📦🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant