Skip to content

release: promote beta to main — source attribution & per-source filter for cost breakdown#1362

Closed
steilerDev wants to merge 8 commits intomainfrom
beta
Closed

release: promote beta to main — source attribution & per-source filter for cost breakdown#1362
steilerDev wants to merge 8 commits intomainfrom
beta

Conversation

@steilerDev
Copy link
Copy Markdown
Owner

@steilerDev steilerDev commented Apr 27, 2026

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

Fixes

Tests

  • 50+ new unit tests across budgetSourceColors, Badge (title prop), CostBreakdownTable (badge rendering, chip strip, filter active state, empty state, server-driven render path scenarios)
  • Backend service tests updated for always-included system sources after migration 0021
  • New E2E spec: e2e/tests/budget/budget-source-filter.spec.ts (filter toggle, URL persistence, mobile touch targets, dark-mode color, cascade-hide)

Docs

  • Wiki: API Contract documents budgetSourceId per line, the budgetSources aggregate, and the deselectedSources query param

Change Inventory

Backend (server/, shared/)

  • server/src/services/budgetBreakdownService.ts — accept deselectedSources, filter lines at top, per-source projected stays unfiltered, per-source payback derived pro-rata from filtered engine, cascade-prune empty items/areas server-side
  • server/src/routes/budgetOverview.ts — parse ?deselectedSources=… (comma-separated source IDs + unassigned); unknown IDs silently ignored
  • server/src/routes/budgetOverview.breakdown.test.ts — coverage for the new param + response shape
  • server/src/services/budgetBreakdownService.test.ts — coverage for filter, source aggregation, and oversubscription math
  • shared/src/types/budgetBreakdown.ts — added budgetSourceId per line, budgetSources[] aggregate, subsidyPaybackMin/Max per source
  • shared/src/index.ts — re-exports

Frontend (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 require budgetSources.length === 0
  • client/src/components/Badge/{Badge.tsx,Badge.module.css,Badge.test.tsx} — added title prop
  • client/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 contrast
  • client/src/lib/budgetOverviewApi.{ts,test.ts}fetchBudgetBreakdown accepts deselectedSources?: string[]
  • client/src/lib/budgetSourceColors.{ts,test.ts} — new helper: deterministic id → palette slot
  • client/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 spec
  • e2e/pages/BudgetOverviewPage.ts — page-object updates for source detail rows + filter affordance

Docs / Config

  • wiki/ — API Contract updates for budgetSourceId, budgetSources, deselectedSources
  • package-lock.json

Manual Validation Checklist

  • Source badges — Open Budget Overview → Cost Breakdown. Every line shows a colored badge with the source name (or 'Unassigned' if none assigned). Each unique source has a distinct color; the same source has the same color across all rows.
  • Mobile/tablet — On a narrow viewport the badge collapses to a colored dot; the source name is still announced (long-press or screen reader).
  • Available Funds row — Expanding the Available Funds row shows per-source rows with Cost, Payback, and Net columns; the Net column equals totalAmount + Payback − Cost.
  • Toggle filter — Click a source detail row. The row becomes muted (no left-border accent, dimmed text), 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.
  • URL persistence — After deselecting one or more sources, the URL gains ?deselectedSources=<id>[,<id>]. Reload the page → the same sources stay deselected and the filter is restored on first paint.
  • Subsidy math stays consistent — When you deselect a source that has subsidy payback, the Payback column shrinks to reflect the filtered cost set; subsidy oversubscription does not appear larger than the visible cost.
  • Deselect everything — Deselect all sources. The table still renders source rows (so you can re-enable them), Sum is €0.00, and the empty-state copy is NOT shown.
  • Re-enable a source — Click a deselected source row → the source comes back, lines reappear, totals update.
  • Keyboard — Tab to a source row, press Space or Enter to toggle. Press Escape to select all.
  • Cascade pruning — Deselect every source funding a particular work item / area; the empty item and (if all its items disappear) the area are hidden. They reappear when at least one source is reselected.
  • Dark mode — Toggle dark mode. Source badge colors remain readable. The error banner (force one by going offline and toggling) has readable text and a visible focus ring on the dismiss button.
  • API smokeGET /api/budget/breakdown?deselectedSources=<known-id>,unassigned returns 200; lines from those sources are absent; budgetSources[] still includes ALL configured sources; per-source subsidyPaybackMin/Max is 0 for deselected sources.

Testing

  • DockerHub beta image: docker pull steilerdev/cornerstone:beta
  • PR-specific image: docker pull steilerdev/cornerstone:pr-1362 (replace once PR number is assigned)

steilerDev and others added 8 commits April 25, 2026 12:56
…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>
@steilerDev
Copy link
Copy Markdown
Owner Author

Detailed Validation

Step-by-step instructions to manually validate the headline changes. Run on the beta image (docker pull steilerdev/cornerstone:beta) or the PR-specific image once available.

1 — Source attribution (visual)

  1. Open Budget Overview → expand the Cost Breakdown table → drill into one work-item line (Level 3).
  2. Expect: a colored source badge sits next to the existing confidence/quoted/invoiced badges. The badge label is the source name on desktop and a colored dot on mobile (long-press / screen reader still reveals the name).
  3. Confirm the same source has the same color on every line, and two different sources use distinct colors.
  4. Lines without an assigned source show an 'Unassigned' badge.

2 — Available Funds row layout

  1. Expand the Available Funds row (above the Sum row at the bottom of the table).
  2. Expect: each configured source has its own detail row with three columns: Cost, Payback, Net.
  3. Confirm Net = totalAmount + Payback − Cost for at least one source where you can mentally check the math (a small project / single-source setup is easiest).

3 — Per-source filter toggle

  1. Click a source detail row.
  2. Expect:
    • The row visually mutes (text muted, dot opacity 0.4, no left-border accent).
    • aria-pressed=\"false\" (verify with DevTools).
    • All lines funded by that source disappear from the table; subtotals at item / area / section level update; Sum + Remaining Budget update.
    • The (X of Y selected) caption appears in the Available Funds heading.
  3. Click the row again to re-enable it. State returns to original.

4 — URL persistence

  1. Deselect two named sources + 'Unassigned'.
  2. Expect the URL gains ?deselectedSources=<id-a>,<id-b>,unassigned (order in the URL is not significant).
  3. Hard-reload the page.
  4. Expect the filter is restored on first paint — no flicker of full-table-then-filter.

5 — Subsidy oversubscription consistency

  1. Pick a source you know has a subsidy attached. Note the project-wide Payback amount before filtering.
  2. Deselect that source.
  3. Expect: the Sum row's Payback column shrinks; oversubscription (if any) does not exceed the visible Cost. (This is the bug class that feat(budget): move per-source filter to server-side via ?deselectedSources= #1361 fixed — the previous client-side filter let Payback exceed visible Cost.)

6 — Deselect everything (former dead-end, fixed in #1360)

  1. Deselect every source row, including 'Unassigned' if present.
  2. Expect:
    • Source rows STILL render (so you can re-enable them).
    • Sum row reads €0.00 across Cost / Payback / Net.
    • Remaining Budget shows the project-wide availableFunds.
    • The 'no lines match' empty-state copy is NOT shown.
  3. Click any source row → that source is re-enabled, its lines reappear, totals update.

7 — Keyboard

  1. Tab to focus a source detail row.
  2. Space or Enter toggles the source.
  3. Escape selects all sources (clears the filter).
  4. Confirm focus ring is visible at every step.

8 — Cascade-hide

  1. Find a work item whose lines are all funded by the same source. Deselect that source.
  2. Expect the work item disappears entirely. If that work item was the only one in its area, the area also disappears.
  3. Re-enable the source → the item and area reappear in their original position.

9 — Dark mode

  1. Toggle dark mode (system preference or app toggle).
  2. Expect badge colors remain readable. No light-on-light or dark-on-dark.
  3. Force a refetch error (DevTools → throttle to Offline → toggle a source). The error banner appears.
  4. Expect banner text is readable in both modes (it uses --color-text-inverse on --color-bg-inverse).
  5. Tab to the banner's dismiss button.
  6. Expect a visible focus ring (the standard --shadow-focus outline).

10 — API smoke

curl -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:

  • 200 OK.
  • No lines remain that reference any of the deselected source IDs (or null for unassigned).
  • budgetSources[] still includes ALL configured sources (deselection does not remove them).
  • For each deselected source, subsidyPaybackMin and subsidyPaybackMax are 0.
  • For selected sources, subsidyPaybackMin/Max reflect the filter (pro-rata from the filtered engine run).

@steilerDev steilerDev marked this pull request as draft April 27, 2026 19:07
@steilerDev steilerDev marked this pull request as ready for review April 27, 2026 19:07
@steilerDev steilerDev closed this Apr 27, 2026
@steilerDev steilerDev reopened this Apr 27, 2026
@steilerDev
Copy link
Copy Markdown
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 steilerDev closed this Apr 27, 2026
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant