Skip to content

Commit b8de6f4

Browse files
authored
release: promote beta to main — source attribution & per-source filter for cost breakdown
release: promote beta to main — source attribution & per-source filter for cost breakdown
2 parents eafc2d3 + c87d775 commit b8de6f4

32 files changed

Lines changed: 6669 additions & 513 deletions

.claude/agent-memory/e2e-test-engineer/MEMORY.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,25 @@
33
> Detailed notes live in topic files. This index links to them.
44
> See: `e2e-pom-patterns.md`, `e2e-parallel-isolation.md`, `story-epic08-e2e.md`, `story-933-dav-vendor-contacts.md`, `milestones-e2e.md`, `story-1248-mass-move.md`
55
6+
## Budget Source Filter E2E (Story #1360, 2026-04-25 — server-side filter)
7+
8+
- **Story #1360** rewrote filter from client-side to server-side. `BudgetSourceSummaryBreakdown` now has `subsidyPaybackMin/Max` NOT `subsidyPayback`.
9+
- URL format: `?deselectedSources=id1,id2` (comma-separated, URL-encoded via `encodeURIComponent(join(','))`).
10+
- `waitForResponse` predicate for filtered: `url.includes('/api/budget/breakdown') && url.includes('deselectedSources=')`.
11+
- `waitForResponse` predicate for unfiltered: `url.includes('/api/budget/breakdown') && !url.includes('deselectedSources=')`.
12+
- **MUST register `waitForResponse` BEFORE the click** that triggers the debounced refetch.
13+
- Route mock glob for breakdown: `'**/api/budget/breakdown**'` (leading `**` + trailing `**`) to match full URLs with `http://localhost:PORT/` prefix AND `?deselectedSources=` query strings. Path-only `${API.budgetBreakdown}**` is unreliable — see Playwright route glob memory note.
14+
- `mountOverviewRoutes` now accepts 4th arg `filteredBreakdownBody?` — returns it when `deselectedSources=` is in URL.
15+
- `makeBreakdownResponse` unassigned source in `budgetSources` now has `id:'unassigned'` — included by default (no opt-in).
16+
- `makeBreakdownSourceAOnly` budgetSources: uses `subsidyPaybackMin: 0, subsidyPaybackMax: 0` (not `subsidyPayback`).
17+
- `breakdownRefetching` CSS class applied to wrapping div during in-flight refetch — testable via `[class*="breakdownRefetching"]`.
18+
- Debounce is 50ms. Debounce debounce coalescence: `filteredRequestCount` listener on `page.on('request')` — works across AbortController cancellations.
19+
- Available Funds expand button `aria-label` = `"Expand available funds sources"` (hardcoded, not i18n).
20+
- Source badge in Level 3 rows: `<span aria-label="Budget source: {name}">`. Unassigned: `aria-label="Budget source: Unassigned"`.
21+
- Source row toggle: `tr[class*="rowSourceDetail"]` with `aria-pressed` attribute. Filter by `getByText(name, {exact:true})`.
22+
- Dark mode color check: create throw-away element to normalize `rgb()` format (see Print E2E Patterns note).
23+
- Prior Story #1354 chips/toolbar pattern is gone — no `role="toolbar"` anymore (tests assert its absence).
24+
625
## Print E2E Patterns (Issue #1310, 2026-04-19)
726

827
- `page.emulateMedia({ media: 'print' })` makes CSS `@media print` rules apply without dispatching window events.

.claude/agent-memory/qa-integration-tester/MEMORY.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,32 @@
33
> Detailed notes live in topic files. This index links to them.
44
> See: `budget-categories-story-142.md`, `e2e-pom-patterns.md`, `e2e-parallel-isolation.md`, `story-358-document-linking.md`, `story-360-document-a11y.md`, `story-epic08-e2e.md`, `story-509-manage-page.md`, `story-471-dashboard.md`
55
6+
## Story #1360 — Server-Side Source Filter Tests (2026-04-25)
7+
8+
**CostBreakdownTable.test.tsx**: Replaced the 12-test `describe('Source filter — aggregate consistency (#1358)')` block with 4-test `describe('Server-driven render path (#1360)')`. The 12 old tests tested deleted client-side helpers (`computePerSourcePayback`, `computeFilteredAggregates`, `visibleLineIds`). Removal strategy: Python `content.replace()` on large block — incremental Edit tool calls left orphaned code. The `buildBreakdownWithTwoSources()` helper was replaced by `buildServerFilteredBreakdown()`.
9+
10+
**Route test `insertWorkItemWithSource` has `budgetSourceId: string` (NOT nullable)**: Use `insertWorkItem({ plannedAmount, confidence })` for null-source WIs in route tests — it always sets `budgetSourceId: null`.
11+
12+
**`BudgetSourceSummaryBreakdown` type now requires `subsidyPaybackMin/Max`**: Existing tests that use `{ id, name, totalAmount, projectedMin, projectedMax }` without these fields will have TypeScript errors. New tests must include both fields.
13+
14+
**Debounce + AbortController test patterns**: For Scenario 29 (error path), use real timers + `waitFor({ timeout: 5000 })` instead of fake timers. The `DEBOUNCE_MS=50` effect fires after `isLoading` transitions to false (double-fetch on mount is intentional — debounce effect re-runs when `isLoading` changes). For scenarios with fake timers: use `await act(async () => { jest.advanceTimersByTime(100); await Promise.resolve(); })` to advance timers and flush microtasks together.
15+
16+
## Story #1358 — CostBreakdownTable Filtered Aggregate Tests (2026-04-25)
17+
18+
Added `describe('Source filter — aggregate consistency (#1358)')` block (12 tests, lines ~4003–4782) to `CostBreakdownTable.test.tsx`. Key patterns: (1) Use `within(row).getByText(...)` to avoid multi-match collisions. (2) Get Level 0 header row via `screen.getByRole('button', { name: 'Expand work item budget by area' }).closest('tr')`. (3) Get Level 1 area row via `screen.getByRole('button', { name: 'Expand WI Area' }).closest('tr')`. (4) Get Level 2 item row via `screen.getByRole('link', { name: 'Item Title' }).closest('tr')`. (5) `td.colBudget` selector on rows for cost cell text assertions. (6) Math: `resolveLineCost(line, avg)` for `own_estimate` with `plannedAmount=N` = N (avg of 0.8N and 1.2N). (7) Pro-rata payback share = weight × entityPayback where weight = max-cost / sum-of-max-costs.
19+
20+
## Story #1356 — CostBreakdownTable Per-Source Filter Rework (2026-04-25)
21+
22+
Props changed again: `selectedSourceIds``deselectedSourceIds`, `onClearSources``onSelectAllSources`. Semantics inverted — a source is HIDDEN when its ID is in `deselectedSourceIds`. Source rows changed from chip toolbar (`role="toolbar"`, `Filter: Name` buttons) to `<tr role="button" aria-pressed="true|false" tabIndex={0}>` toggle rows directly in the Available Funds expansion. Tests checking `role="toolbar"` or `Filter: Name` buttons must be removed and replaced with `container.querySelector('tr[role="button"]')` assertions. Replace all old chip count assertions (e.g. `toHaveLength(2)` for "chip + sub-row") with `toBeInTheDocument()` for the single source detail row. The `onSelectAllSources` prop is called on Escape keydown on the source row (not on a toolbar).
23+
24+
## Story #1354 — CostBreakdownTable Props Refactor Pattern (2026-04-25)
25+
26+
`CostBreakdownTable` had `budgetSources={[]}` prop replaced with `selectedSourceIds={new Set()} onSourceToggle={() => {}} onClearSources={() => {}}`. When a component's prop API changes, use `replace_all: true` on Edit tool to update all test usages in one pass (28 occurrences updated at once). Also add new required fixture fields (`budgetSources: []` on BudgetBreakdown, `budgetSourceId: null` on BreakdownBudgetLine) via Python `sed`-style script when the pattern is uniform across many objects.
27+
28+
**Fix Loop Round 1 (2026-04-25)**: Tests at lines ~1844/1859/1884 still passed `budgetSources` as a JSX prop AND were missing required props. Fix: move source data into `breakdown={{ ...buildBreakdownWithWI(), budgetSources: [buildSourceSummary(...)] }}` and add `selectedSourceIds onSourceToggle onClearSources`. Also removed obsolete `buildBudgetSource()` helper (used `BudgetSource` full type — now use `buildSourceSummary()` with `BudgetSourceSummaryBreakdown`). In `BudgetOverviewPage.test.tsx`, Scenario 30 was testing the old `budgetSources` prop flow; updated to populate `breakdown.budgetSources` instead. Added Escape key tests for new `handleToolbarKeyDown` behavior.
29+
30+
**Stale dist warning**: `node_modules/@cornerstone/shared/dist/` must be rebuilt (`tsc -p shared/tsconfig.json --outDir node_modules/@cornerstone/shared/dist`) when shared types change. Without rebuild, `tsc --noEmit` on client shows false positives for `budgetSourceId`, `budgetSources`, `BudgetSourceSummaryBreakdown`. Jest is unaffected (maps to source).
31+
632
## BudgetBar Module-Level Mock Anti-Pattern (2026-04-20)
733

834
**Critical**: Mocking `BudgetBar` at module level (`jest.unstable_mockModule('../../components/BudgetBar/BudgetBar.js', ...)`) breaks ALL existing tests that rely on BudgetBar rendering content (labels, role="img", segment text). BudgetBar renders segment labels (e.g. "Paid (unclaimed)", "Claimed") that existing tests assert on. The fix: test segment keys via observable behavior (aria-label, summaryLabel text) rather than mock capture. For segment structure verification, use `container.querySelectorAll('[class*="summaryRow"]')` to check rows and their label text order.

.claude/agent-memory/translator/MEMORY.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,8 @@ Action labels in German follow the pattern: `{Noun} {Verb}` with capitalised fir
4646
- `de/areas.json` created 2026-04-16 (Story #1237): `noArea` → "Kein Bereich", `pathLabel` → "Bereichspfad"
4747
- `de/budget.json``overview.costBreakdown.area.unassigned` and `sources.lines.unassignedArea` both updated to "Kein Bereich" 2026-04-19 (Issue #1295), aligned with `de/areas.json` and the `noCategory` → "Keine Kategorie" parallel pattern
4848
- `de/budget.json``sources.lines.noCategory` orphan deleted 2026-04-19 (Issue #1313); `sources.lines.invoiceStatus.*`, `sources.lines.underArea`, `sources.lines.typeColumnHeader`, `sources.lines.statusColumnHeader` added 2026-04-19 (Issue #1313)
49+
- `de/budget.json` — Issue #1356 (2026-04-25): `sourceFilter` rework — removed `label`, `allSources`, `clearAriaLabel`, `chipSelected`, `chipNotSelected`, `activeAnnouncement`; added `statusAnnouncement`; added new blocks `sourceRow.*` and `availableFunds.*`
50+
- **Pre-existing gap** (as of 2026-04-25, outside #1356 scope): `sources.lines.typeColumnHeader` and `sources.lines.statusColumnHeader` exist in `en` but not `de` — needs a dedicated spec to fix
4951
- Always check key parity when picking up a new translator spec
5052

5153
## Backup/Restore Terminology (2026-03-22)
@@ -132,3 +134,19 @@ Note: `claimed` here uses "Beantragt" (applied/requested for subsidy) rather tha
132134
- `summaryClaimedLabel` → "Eingereicht" (bar chart summary; consistent with `barChart.claimed` = "Eingereicht")
133135
- `srOnly` screen reader text: "Eingereicht {{claimed}}, Bezahlt {{paid}}, Projiziert {{projectedMin}} bis {{projectedMax}}, von Gesamt {{total}}"
134136
- Obsolete keys removed in this update: `allocated`, `total`, `available`, `planned`
137+
138+
## Source Filter & Source Badge Patterns — Issue #1354 (2026-04-25)
139+
140+
- `overview.costBreakdown.sourceFilter.*`, `sourceImpact.*`, `sourceBadge.*` added to `de/budget.json`
141+
- "Unassigned" (source filter / source badge context) → "Nicht zugewiesen" (glossary `Unassigned` term, not "Kein X" pattern which is used for area/category absence)
142+
- "Budget source: {{name}}" (aria label) → "Budgetquelle: {{name}}" — always use full glossary term "Budgetquelle" in aria labels, short "Quelle" only in UI labels
143+
- `sourceImpact.allocated` → "Zugeordnet"; `sourceImpact.remaining` → "Verbleibend"
144+
- **Note**: `label`, `allSources`, `clearAriaLabel`, `chipSelected`, `chipNotSelected` and `activeAnnouncement` were added in #1354 but removed again in #1356 rework (chip-based filter replaced)
145+
146+
## Source Row & Status Announcement Patterns — Issue #1356 (2026-04-25)
147+
148+
- `sourceFilter.statusAnnouncement` → "{{selected}} von {{total}} Budgetquellen ausgewählt" (uses plural "Budgetquellen" from glossary)
149+
- `sourceRow.selectedAriaLabel` → "{{name}}, ausgewählt – zum Abwählen klicken" (en-dash, infinitive construction)
150+
- `sourceRow.deselectedAriaLabel` → "{{name}}, abgewählt – zum Auswählen klicken"
151+
- `availableFunds.activeFilterCaption` → "({{selected}} von {{total}} ausgewählt)"
152+
- Aria label click-instruction pattern: "– zum [Verb] klicken" (en-dash, infinitive with "zu")

.claude/agent-memory/ux-designer/MEMORY.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -274,3 +274,11 @@ Zero new tokens, zero new CSS, zero new components. Text replacement only. Key r
274274
- German currency trailing `` with space may widen budget table cells — acceptable, no layout fix needed
275275
- SearchPicker default prop strings (`placeholder`, `emptyHint`, etc.) replaced with `t()` — no CSS change
276276
- Language selector UI is Story #917 (ProfilePage) — not this story
277+
278+
## Story #1354 — Source Badges & Filter (Budget Overview)
279+
280+
See [story-1354-source-badges-filter.md](story-1354-source-badges-filter.md). Key: 10-slot `--color-source-N-{bg,text,dot}` token family; `BudgetSourceChip` new shared component; `role="toolbar"` for chip strip; scoped `--chip-*` CSS custom properties pattern for per-slot colors.
281+
282+
## Issue #1356 — Per-Source Filter Rework (CostBreakdownTable)
283+
284+
Spec posted at https://github.com/steilerDev/cornerstone/issues/1356#issuecomment-4319895233. Key: chip toolbar + `BudgetSourceChip` deleted entirely; source detail rows become `<tr role="button" tabIndex={0} aria-pressed>`; filter semantics inverted to `deselectedSourceIds`; URL param renamed `?sources=``?deselectedSources=`; deselected state = muted text + `opacity:0.4` dot + transparent border (non-color signals required); Escape on focused row → select-all; no new tokens; cascade requires emptying parent nodes when all children hidden.
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
---
2+
name: Story #1354 — Source Attribution Badges and Per-Source Filter
3+
description: Visual spec for budget source color palette, source badge in BudgetLineRow, and filter chip strip in Available Funds section
4+
type: project
5+
---
6+
7+
## Key Decisions
8+
9+
**Source color palette**: 10-slot deterministic palette (`colorIndex = sourceId % 10`). Slot 0 = Unassigned (gray). Slots 1–9 match calendar-item hue families plus cyan. No schema change required.
10+
11+
**New token family**: `--color-source-N-bg`, `--color-source-N-text`, `--color-source-N-dot` for N=0–9, with Layer 3 dark-mode overrides. Added to tokens.css Layer 2 / Layer 3.
12+
13+
**Token naming**: `-dot` suffix provides a higher-saturation swatch color for the circle dot indicator (distinct from `-bg` which is low-saturation for badge background).
14+
15+
**Color-blind affordance**: source name label always present alongside dot; name truncated at 20 chars (badge) / 24 chars (chip) with full name in `title` + `aria-label`.
16+
17+
**Badge extension**: Add `source0``source9` and `sourceUnassigned` CSS classes to `Badge.module.css`. Add optional `title` prop to `Badge.tsx`. No new component needed for the badge.
18+
19+
**BudgetSourceChip**: New shared component at `client/src/components/BudgetSourceChip/`. Renders as `<button>`. Uses scoped CSS custom properties (`--chip-bg`, `--chip-text`, `--chip-dot`) set as inline `style` on the element to allow static CSS classes to consume per-slot token values cleanly (avoids 10x `.chipSlotN.chipSelected` rules).
20+
21+
**ARIA pattern for chip strip**: `role="toolbar"` (NOT `role="radiogroup"` — multi-select semantics). Each chip: `role="button"` (native), `aria-pressed="true|false"`. Tab moves through all chips. Escape clears filter and focuses "Available Funds" expand button.
22+
23+
**Filter state**: URL query param `?sources=2,5,8` (comma-separated IDs). `BudgetOverviewPage` owns URL state, passes `selectedSourceIds: Set<number>` + `onSourceToggle` + `onClearFilters` props to `CostBreakdownTable`.
24+
25+
**Available Funds columns**: existing name+total columns gain `allocatedCost` and `remaining` columns. Selected source detail rows get left-border accent using `--chip-dot` scoped property on `<tr>`.
26+
27+
**Empty filter state**: use existing `EmptyState` component in a `<td colSpan={4}>` row. No icon variant (filtered, not "add first item").
28+
29+
**Mobile badge**: dot only (label hidden via CSS). Touch-and-hold shows `title` attribute. Dot uses `-dot` token (higher contrast than `-bg`).
30+
31+
**Mobile chip strip**: `flex-wrap: nowrap; overflow-x: auto`. Chips 44px min touch height on mobile.
32+
33+
**Animations**: row fade-in via `@keyframes fadeIn` + `var(--transition-normal)`. `prefers-reduced-motion: reduce` disables all animations. Chip transitions via `var(--transition-normal)`.
34+
35+
**Why**: Spec posted as comment on issue #1354. Architecture decision (deterministic vs. persisted color) flagged to product-architect.

.github/workflows/ci.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ name: Quality Gates
33
on:
44
pull_request:
55
branches: [main, beta]
6+
workflow_dispatch:
67

78
concurrency:
89
group: ci-${{ github.ref }}

client/src/components/Badge/Badge.module.css

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,63 @@
142142
color: var(--color-user-inactive-text);
143143
}
144144

145+
/* Budget source badge variants (source0–source9, sourceUnassigned) */
146+
.source0 {
147+
background-color: var(--color-source-0-bg);
148+
color: var(--color-source-0-text);
149+
}
150+
151+
.source1 {
152+
background-color: var(--color-source-1-bg);
153+
color: var(--color-source-1-text);
154+
}
155+
156+
.source2 {
157+
background-color: var(--color-source-2-bg);
158+
color: var(--color-source-2-text);
159+
}
160+
161+
.source3 {
162+
background-color: var(--color-source-3-bg);
163+
color: var(--color-source-3-text);
164+
}
165+
166+
.source4 {
167+
background-color: var(--color-source-4-bg);
168+
color: var(--color-source-4-text);
169+
}
170+
171+
.source5 {
172+
background-color: var(--color-source-5-bg);
173+
color: var(--color-source-5-text);
174+
}
175+
176+
.source6 {
177+
background-color: var(--color-source-6-bg);
178+
color: var(--color-source-6-text);
179+
}
180+
181+
.source7 {
182+
background-color: var(--color-source-7-bg);
183+
color: var(--color-source-7-text);
184+
}
185+
186+
.source8 {
187+
background-color: var(--color-source-8-bg);
188+
color: var(--color-source-8-text);
189+
}
190+
191+
.source9 {
192+
background-color: var(--color-source-9-bg);
193+
color: var(--color-source-9-text);
194+
}
195+
196+
.sourceUnassigned {
197+
background-color: var(--color-source-0-bg);
198+
color: var(--color-source-0-text);
199+
font-style: italic;
200+
}
201+
145202
/* Responsive */
146203
@media (max-width: 767px) {
147204
.badge {

client/src/components/Badge/Badge.test.tsx

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,4 +128,20 @@ describe('Badge', () => {
128128
const span = container.querySelector('span');
129129
expect(span?.textContent).toBe('raw_value');
130130
});
131+
132+
// ─── title prop ─────────────────────────────────────────────────────────────
133+
134+
it('forwards title prop to the rendered span', () => {
135+
const { container } = render(
136+
<Badge variants={SIMPLE_VARIANTS} value="foo" title="Full Source Name" />,
137+
);
138+
const span = container.querySelector('span');
139+
expect(span).toHaveAttribute('title', 'Full Source Name');
140+
});
141+
142+
it('does not render title attribute when title prop is not passed', () => {
143+
const { container } = render(<Badge variants={SIMPLE_VARIANTS} value="foo" />);
144+
const span = container.querySelector('span');
145+
expect(span).not.toHaveAttribute('title');
146+
});
131147
});

client/src/components/Badge/Badge.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,16 +11,17 @@ interface BadgeProps {
1111
variants: BadgeVariantMap;
1212
value: string;
1313
ariaLabel?: string;
14+
title?: string;
1415
testId?: string;
1516
className?: string;
1617
}
1718

18-
export function Badge({ variants, value, ariaLabel, testId, className }: BadgeProps) {
19+
export function Badge({ variants, value, ariaLabel, title, testId, className }: BadgeProps) {
1920
const variant = variants[value];
2021
const combinedClass = [styles.badge, variant?.className, className].filter(Boolean).join(' ');
2122

2223
return (
23-
<span className={combinedClass} aria-label={ariaLabel} data-testid={testId}>
24+
<span className={combinedClass} aria-label={ariaLabel} title={title} data-testid={testId}>
2425
{variant?.label ?? value}
2526
</span>
2627
);

0 commit comments

Comments
 (0)