diff --git a/scripts/browser-smoke.mjs b/scripts/browser-smoke.mjs index d8c02cd..7b3b687 100644 --- a/scripts/browser-smoke.mjs +++ b/scripts/browser-smoke.mjs @@ -277,6 +277,40 @@ async function assertRingCenterFits(page, ringSelector, label) { assert.equal(geometry.valueBottom <= geometry.innerBottom - 2, true, `${label}: Betrag berührt den Ring unten`); } +async function assertOverviewAllocationHero(page, label) { + const geometry = await page.locator('#overview-hero').evaluate((hero) => { + const heroBounds = hero.getBoundingClientRect(); + const ring = hero.querySelector('.overview-allocation-ring'); + const bars = hero.querySelector('.overview-allocation-bars'); + const ringBounds = ring.getBoundingClientRect(); + const barsBounds = bars.getBoundingClientRect(); + const barItems = [...hero.querySelectorAll('.overview-allocation-bar')].map((bar) => bar.getBoundingClientRect()); + return { + barHeights: barItems.map(({ height }) => height), + barsHeight: barsBounds.height, + barsLeft: barsBounds.left, + center: hero.querySelector('[data-testid="overview-allocation-center-value"]')?.textContent ?? '', + heroWidth: heroBounds.width, + incomePath: Boolean(hero.querySelector('.overview-allocation-ring__income textPath')), + interactiveControls: hero.querySelectorAll('button, [role="button"]').length, + ringHeight: ringBounds.height, + ringRight: ringBounds.right, + ringWidth: ringBounds.width, + sectorIds: [...hero.querySelectorAll('.overview-allocation-ring__sector')] + .map((sector) => sector.getAttribute('data-allocation-id')), + }; + }); + assert.equal(Boolean(geometry.center.trim()), true, `${label}: Ringzentrum ist leer`); + assert.equal(geometry.ringWidth / geometry.heroWidth >= 0.4 && geometry.ringWidth / geometry.heroWidth <= 0.5, true, `${label}: Ring nimmt nicht ungefähr die halbe Breite ein`); + assert.equal(approximately(geometry.ringHeight, geometry.barsHeight), true, `${label}: Ring und Balkengruppe sind unterschiedlich hoch`); + assert.equal(geometry.barsLeft >= geometry.ringRight - 0.5, true, `${label}: Balken überlagern den Ring`); + assert.equal(geometry.barHeights.length, 3, `${label}: falsche Balkenanzahl`); + assert.equal(Math.max(...geometry.barHeights) - Math.min(...geometry.barHeights) <= 0.5, true, `${label}: Balken sind unterschiedlich hoch`); + assert.deepEqual(geometry.sectorIds, ['expenses', 'reserves', 'free'], `${label}: falsche Segmentreihenfolge`); + assert.equal(geometry.incomePath, true, `${label}: Einkommen folgt keinem Innenbogen`); + assert.equal(geometry.interactiveControls, 0, `${label}: Overview-Hero ist unerwartet interaktiv`); +} + async function assertLayeredRing(page, ringSelector, label) { const geometry = await page.locator(ringSelector).evaluate((ring) => ({ arcs: [...ring.querySelectorAll('.circular-allocation__arc[data-allocation-id]')].map((arc) => ({ @@ -317,11 +351,10 @@ async function accentSnapshot(page) { const style = (selector) => getComputedStyle(document.querySelector(selector)); const root = getComputedStyle(document.documentElement); return { - expense: style('.overview-screen [data-allocation-id="expenses"]').stroke, - focus: style('.circular-allocation__button').outlineColor, - free: style('.overview-screen [data-allocation-id="free"]').stroke, + expense: style('.overview-allocation-ring__sector[data-allocation-id="expenses"]').fill, + free: style('.overview-allocation-ring__sector[data-allocation-id="free"]').fill, navigation: style('[data-testid="navigation-indicator"]').backgroundColor, - reserve: style('.overview-screen [data-allocation-id="reserves"]').stroke, + reserve: style('.overview-allocation-ring__sector[data-allocation-id="reserves"]').fill, resolved: root.getPropertyValue('--color-system-accent').trim(), }; }); @@ -568,7 +601,7 @@ try { await picker.page.goto(baseUrl, { waitUntil: 'networkidle' }); await picker.page.getByRole('button', { name: 'Google-Tabelle auswählen' }).click(); await picker.page.getByRole('heading', { name: overviewHeading }).waitFor(); - assert.match(await picker.page.locator('body').innerText(), /Frei verfügbar/); + assert.match(await picker.page.locator('#overview-hero').innerText(), /Ausgaben[\s\S]*Rücklagen[\s\S]*Frei/); assert.deepEqual(picker.errors, [], picker.errors.join('\n')); await picker.context.close(); @@ -829,10 +862,9 @@ try { await mobile.page.getByRole('heading', { name: overviewHeading }).waitFor(); await assertGoogleSansFlex(mobile.page, 'Mobile Übersicht'); const overviewScreen = mobile.page.locator('[data-destination="overview"]'); - const overviewRoleGeometry = await financeRoleGeometry(mobile.page, '.overview-screen'); assert.equal(await overviewScreen.getAttribute('data-entrance'), 'first'); const overviewText = await mobile.page.locator('body').innerText(); - const overviewHeroValue = await mobile.page.locator('#overview-hero .financial-hero__value').innerText(); + const overviewHeroValue = await mobile.page.locator('#overview-hero .overview-allocation-bar[data-allocation-id="free"] .overview-allocation-bar__value').innerText(); assert.match(overviewHeroValue, /^141,32\s*€$/, 'Übersichts-Hero zeigt nicht den erwarteten Betrag ohne frei-Zusatz'); assert.match(overviewText, /1\.350,75\s*€/); assert.match(overviewText, /Finanzierung A endet im September 2026/); @@ -843,37 +875,9 @@ try { assert.doesNotMatch(overviewText, /Interner Quellhinweis/); await mobile.page.screenshot({ path: '/tmp/finance-connected-mobile.png', fullPage: true }); - const overviewRing = mobile.page.locator('.overview-screen .circular-allocation'); - const statusTrigger = overviewRing.getByRole('button'); - await assertRingCenterFits(mobile.page, '.overview-screen .circular-allocation', 'Übersichtsring 412px'); - await assertLayeredRing(mobile.page, '.overview-screen .circular-allocation', 'Übersichtsring'); - const ringSegments = await overviewRing.locator('[data-allocation-id]').evaluateAll((elements) => elements.map((element) => ({ - amountCents: Number(element.getAttribute('data-amount-cents')), - id: element.getAttribute('data-allocation-id'), - }))); - assert.deepEqual(ringSegments, [ - { id: 'expenses', amountCents: 215_000 }, - { id: 'reserves', amountCents: 30_000 }, - { id: 'free', amountCents: 14_132 }, - ]); - assert.equal(ringSegments.reduce((sum, segment) => sum + segment.amountCents, 0), Number(await overviewRing.getAttribute('data-total-cents'))); - assert.equal(Number(await overviewRing.getAttribute('data-summary-planned-cents')), 245_000); - assert.match(await overviewRing.getByTestId('allocation-accessible-summary').textContent(), /Ausgaben: 2\.150,00\s*€.*Rücklagen: 300,00\s*€.*Frei: 141,32\s*€/); - const statusBoundsBeforeToggle = await bounds(mobile.page.locator('.overview-screen .financial-hero')); - const followingBoundsBeforeToggle = await bounds(mobile.page.locator('.overview-screen .metric-grid')); - await overviewRing.locator('svg').evaluate((element) => { element.dataset.persistenceProbe = 'same-svg'; }); - await statusTrigger.click(); - assert.equal(await statusTrigger.getAttribute('aria-pressed'), 'true'); - assert.equal(await overviewRing.getAttribute('data-detailed'), 'true'); - assert.equal(await overviewRing.locator('svg').getAttribute('data-persistence-probe'), 'same-svg'); - assert.deepEqual(await bounds(mobile.page.locator('.overview-screen .financial-hero')), statusBoundsBeforeToggle); - assert.deepEqual(await bounds(mobile.page.locator('.overview-screen .metric-grid')), followingBoundsBeforeToggle); - await mobile.page.screenshot({ path: '/tmp/finance-overview-detailed.png', fullPage: true }); - await statusTrigger.press('Enter'); - assert.equal(await statusTrigger.getAttribute('aria-pressed'), 'false'); - await statusTrigger.focus(); - await mobile.page.keyboard.press('Tab'); - await mobile.page.keyboard.press('Shift+Tab'); + await assertOverviewAllocationHero(mobile.page, 'Übersichts-Hero 412px'); + const overviewSummary = await mobile.page.getByTestId('overview-allocation-accessible-summary').textContent(); + assert.match(overviewSummary, /Ausgaben: 2\.150,00\s*€.*Rücklagen: 300,00\s*€.*Frei: 141,32\s*€/); const fallbackAccent = await accentSnapshot(mobile.page); await mobile.page.evaluate(() => { @@ -884,7 +888,6 @@ try { const injectedAccent = await accentSnapshot(mobile.page); assert.notEqual(injectedAccent.navigation, fallbackAccent.navigation, 'Injizierter Akzent änderte den Navigationsindikator nicht'); assert.notEqual(injectedAccent.expense, fallbackAccent.expense, 'Injizierter Akzent änderte die ausgewählte Ringrolle nicht'); - assert.notEqual(injectedAccent.focus, fallbackAccent.focus, 'Injizierter Akzent änderte den Fokusrahmen nicht'); assert.equal(injectedAccent.reserve, fallbackAccent.reserve, 'Systemakzent veränderte die semantische Rücklagenfarbe'); assert.equal(injectedAccent.free, fallbackAccent.free, 'Systemakzent veränderte die semantische Frei-Farbe'); await mobile.page.evaluate(() => { @@ -917,7 +920,6 @@ try { await assertNoVisibleTextBelow12px(mobile.page, '.overview-screen', 'Mobile Übersicht'); await assertAdaptiveNavigation(mobile.page, 412, 'Mobile Übersicht'); - await assertConcentric(mobile.page, '.overview-screen .financial-hero', '.overview-screen .allocation-legend__item', 'Übersichts-Hero'); await assertConcentric(mobile.page, '.overview-screen .metric-grid', '.overview-screen .metric-card', 'Paarmetriken'); await assertConcentric(mobile.page, '.overview-screen .data-list', '.overview-screen .data-list__item', 'Kontenliste'); await assertConcentric(mobile.page, '.pocket-collection', '.pocket-collection .pocket', 'Pockets eingeklappt'); @@ -947,7 +949,7 @@ try { await mobile.page.getByRole('link', { name: 'Demnächst', exact: true }).click(); await mobile.page.getByRole('heading', { name: 'Demnächst' }).waitFor(); - assertSharedFinanceRoles(overviewRoleGeometry, await financeRoleGeometry(mobile.page, '.upcoming-screen'), 'Demnächst'); + const sharedFinanceRoleGeometry = await financeRoleGeometry(mobile.page, '.upcoming-screen'); assert.equal(await mobile.page.locator('[data-destination="upcoming"]').getAttribute('data-entrance'), 'first'); assert.match(await mobile.page.locator('.upcoming-screen').innerText(), /Bis Gehalt verfügbar/); assert.match(await mobile.page.locator('.upcoming-screen').innerText(), /5 Tage vor Gehalt/); @@ -958,7 +960,7 @@ try { const navigationSamples = await navigationTransitionSamples(mobile.page); navigationSamples.forEach((sample, index) => assertStableNavigationGeometry(overviewNavigationGeometry, sample, `Indikatorframe ${index}`)); await mobile.page.getByRole('heading', { name: 'Dein Budget' }).waitFor(); - assertSharedFinanceRoles(overviewRoleGeometry, await financeRoleGeometry(mobile.page, '.budget-screen'), 'Budget'); + assertSharedFinanceRoles(sharedFinanceRoleGeometry, await financeRoleGeometry(mobile.page, '.budget-screen'), 'Budget'); assert.equal(await mobile.page.locator('[data-destination="budget"]').getAttribute('data-entrance'), 'first'); assert.equal(await navigationIndicator.getAttribute('data-persistence-probe'), 'same-node'); const budgetNavigationGeometry = await navigationGeometry(mobile.page); @@ -1026,7 +1028,7 @@ try { await mobile.page.getByRole('link', { name: 'Schulden', exact: true }).click(); await mobile.page.getByRole('heading', { name: 'Dein Weg auf null' }).waitFor(); - assertSharedFinanceRoles(overviewRoleGeometry, await financeRoleGeometry(mobile.page, '.debt-screen'), 'Schulden'); + assertSharedFinanceRoles(sharedFinanceRoleGeometry, await financeRoleGeometry(mobile.page, '.debt-screen'), 'Schulden'); assert.equal(await mobile.page.locator('[data-destination="debt"]').getAttribute('data-entrance'), 'first'); const debtNavigationGeometry = await navigationGeometry(mobile.page); assertStableNavigationGeometry(overviewNavigationGeometry, debtNavigationGeometry, 'Budget → Schulden'); @@ -1127,7 +1129,7 @@ try { await test.page.getByRole('heading', { name: overviewHeading }).waitFor(); await test.page.waitForTimeout(360); await assertGoogleSansFlex(test.page, `Light ${viewport.name}`); - await assertRingCenterFits(test.page, '.overview-screen .circular-allocation', `Übersichtsring ${viewport.name}`); + await assertOverviewAllocationHero(test.page, `Übersichts-Hero ${viewport.name}`); await assertNoOverflow(test.page, `Light Übersicht ${viewport.name}`); await assertNoVisibleTextBelow12px(test.page, '.overview-screen', `Light Übersicht ${viewport.name}`); await assertAdaptiveNavigation(test.page, viewport.width, `Light Übersicht ${viewport.name}`); @@ -1178,14 +1180,11 @@ try { assert.equal(resolvedDarkTheme.resolved, 'dark'); assert.match(resolvedDarkTheme.page, /^#[\dA-F]{6}$/i); await assertGoogleSansFlex(dark.page, `Dark ${viewport.name}`); - await assertRingCenterFits(dark.page, '.overview-screen .circular-allocation', `Dark Übersichtsring ${viewport.name}`); + await assertOverviewAllocationHero(dark.page, `Dark Übersichts-Hero ${viewport.name}`); await assertNoOverflow(dark.page, `Dark Übersicht ${viewport.name}`); - await assertConcentric(dark.page, '.overview-screen .financial-hero', '.overview-screen .allocation-legend__item', `Dark-Mode-Hero ${viewport.name}`); await dark.page.screenshot({ path: `/tmp/finance-dark-${viewport.name}-overview.png`, fullPage: true }); if (viewport.width === 412) { await dark.page.screenshot({ path: '/tmp/finance-connected-dark.png', fullPage: true }); - await dark.page.locator('.overview-screen .circular-allocation__button').click(); - await dark.page.screenshot({ path: '/tmp/finance-dark-412x915-overview-detailed.png', fullPage: true }); } await dark.page.getByRole('link', { name: 'Budget', exact: true }).click(); await dark.page.getByRole('heading', { name: 'Dein Budget' }).waitFor(); @@ -1213,9 +1212,7 @@ try { const reducedScreen = reduced.locator('[data-destination="overview"]'); assert.equal(await reducedScreen.getAttribute('data-entrance'), 'reduced'); assert.equal(await reducedScreen.evaluate((element) => element.getAnimations({ subtree: true }).filter((animation) => animation.animationName === 'screen-entrance').length), 0); - const reducedStatusTrigger = reduced.locator('.overview-screen .circular-allocation__button'); - await reducedStatusTrigger.click(); - assert.equal(await reducedStatusTrigger.getAttribute('aria-pressed'), 'true'); + await assertOverviewAllocationHero(reduced, 'Reduced-Motion Übersichts-Hero'); await reduced.getByRole('link', { name: 'Budget', exact: true }).click(); assert.equal(await reduced.locator('[data-destination="budget"]').getAttribute('data-entrance'), 'reduced'); assert.equal(await reduced.locator('.budget-chart').getAttribute('data-animation-active'), 'false'); @@ -1239,7 +1236,7 @@ try { await forced.goto(baseUrl, { waitUntil: 'networkidle' }); await forced.getByRole('heading', { name: overviewHeading }).waitFor(); assert.equal(await forced.evaluate(() => matchMedia('(forced-colors: active)').matches), true, 'Forced Colors wurde nicht emuliert'); - assert.notEqual(await forced.locator('.financial-hero').evaluate((element) => getComputedStyle(element).borderStyle), 'none', 'Hero verliert in Forced Colors seine Begrenzung'); + assert.notEqual(await forced.locator('.overview-allocation-bar').first().evaluate((element) => getComputedStyle(element).borderStyle), 'none', 'Overview-Balken verliert in Forced Colors seine Begrenzung'); await assertAdaptiveNavigation(forced, 412, 'Forced Colors Übersicht'); await assertNoOverflow(forced, 'Forced Colors Übersicht'); await assertNoVisibleTextBelow12px(forced, '.overview-screen', 'Forced Colors Übersicht'); @@ -1266,6 +1263,23 @@ try { const activeToggle = privacyPage.getByRole('button', { name: 'Beträge anzeigen' }); assert.equal(await activeToggle.getAttribute('aria-pressed'), 'true'); assert.equal(await privacyPage.evaluate(() => document.documentElement.dataset.privacyMode), 'true'); + const privateOverview = await privacyPage.locator('#overview-hero').evaluate((hero) => ({ + barFills: [...hero.querySelectorAll('.overview-allocation-bar')].map((bar) => ({ + bar: bar.getBoundingClientRect().width, + fill: bar.querySelector('.overview-allocation-bar__fill').getBoundingClientRect().width, + })), + centerFilter: getComputedStyle(hero.querySelector('[data-testid="overview-allocation-center-value"]')).filter, + incomeFilter: getComputedStyle(hero.querySelector('.overview-allocation-ring__income')).filter, + maskedValueOverflow: [...hero.querySelectorAll('.overview-allocation-bar__value > .money-value--masked, .overview-allocation-bar__value .money-value__blur')] + .map((value) => getComputedStyle(value).overflow), + sectorIds: [...hero.querySelectorAll('.overview-allocation-ring__sector')] + .map((sector) => sector.getAttribute('data-allocation-id')), + })); + assert.deepEqual(privateOverview.sectorIds, ['private'], 'Privatsphäre-Modus verrät weiterhin die Ringverteilung'); + assert.equal(privateOverview.barFills.every(({ bar, fill }) => approximately(bar, fill)), true, 'Privatsphäre-Modus verrät weiterhin Balkenverhältnisse'); + assert.notEqual(privateOverview.centerFilter, 'none', 'Prozentwert im Ring wird nicht weichgezeichnet'); + assert.notEqual(privateOverview.incomeFilter, 'none', 'Einkommen im Ring wird nicht weichgezeichnet'); + assert.equal(privateOverview.maskedValueOverflow.every((overflow) => overflow === 'visible'), true, 'Balken schneiden den Blur ab'); await privacyPage.reload({ waitUntil: 'networkidle' }); await privacyPage.getByRole('heading', { name: overviewHeading }).waitFor(); diff --git a/scripts/offline-smoke.mjs b/scripts/offline-smoke.mjs index d89fccc..c082c13 100644 --- a/scripts/offline-smoke.mjs +++ b/scripts/offline-smoke.mjs @@ -70,7 +70,7 @@ try { assert.equal(await offlineOverview.getAttribute('data-entrance'), 'visited', 'Service-Worker-Reload spielte den Eingang erneut ab'); assert.equal(await offlineOverview.evaluate((element) => element.getAnimations({ subtree: true }).filter((animation) => animation.animationName === 'screen-entrance').length), 0); const offlineText = await page.locator('body').innerText(); - const offlineHeroValue = await page.locator('#overview-hero .financial-hero__value').innerText(); + const offlineHeroValue = await page.locator('#overview-hero .overview-allocation-bar[data-allocation-id="free"] .overview-allocation-bar__value').innerText(); assert.match(offlineHeroValue, /^141,32\s*€$/, 'Offline-Hero zeigt nicht den erwarteten Betrag ohne frei-Zusatz'); assert.match(offlineText, /Finanzierung A endet im September 2026/); assert.match(offlineText, /Danach voraussichtlich 261,32\s*€ frei/); diff --git a/src/components/OverviewAllocationHero.tsx b/src/components/OverviewAllocationHero.tsx new file mode 100644 index 0000000..fdad2ce --- /dev/null +++ b/src/components/OverviewAllocationHero.tsx @@ -0,0 +1,142 @@ +import type { CSSProperties } from 'react'; +import type { AllocationRingSegment } from '../design/layeredAllocationRing'; +import { describeAllocationRing } from '../design/layeredAllocationRing'; +import { createOverviewAllocationSectors, OVERVIEW_RING_GEOMETRY } from '../design/overviewAllocationRing'; +import { formatCurrencyCents } from '../lib/format'; +import { usePrivacy } from '../privacy/PrivacyProvider'; +import { MoneyValue } from './MoneyValue'; + +type OverviewAllocationHeroProps = { + centerLabel: string; + centerValue: string; + id: string; + incomeCents: number; + segments: readonly AllocationRingSegment[]; +}; + +type BarStyle = CSSProperties & { + '--overview-allocation-color': string; + '--overview-allocation-fill': string; +}; + +const clamp = (value: number, minimum: number, maximum: number) => Math.min(maximum, Math.max(minimum, value)); +const PRIVATE_AMOUNT_PLACEHOLDER = '•••• €'; +const PRIVATE_CENTER_PLACEHOLDER = '••• %'; + +function fillRatio(amountCents: number, incomeCents: number) { + if (incomeCents <= 0 || !Number.isSafeInteger(amountCents) || !Number.isSafeInteger(incomeCents)) return 0; + return clamp(Math.abs(amountCents) / incomeCents, 0, 1); +} + +export function OverviewAllocationHero({ centerLabel, centerValue, id, incomeCents, segments }: OverviewAllocationHeroProps) { + const { privacyMode } = usePrivacy(); + const displayedSegments = privacyMode + ? segments.map((segment) => segment.amountCents < 0 + ? { ...segment, amountCents: 0, color: 'var(--chart-free)', label: 'Frei' } + : segment) + : segments; + const sectors = privacyMode + ? createOverviewAllocationSectors([{ + amountCents: 1, + color: 'var(--color-system-accent)', + id: 'private', + label: 'Aufteilung ausgeblendet', + }], 1) + : createOverviewAllocationSectors(segments, incomeCents); + const hasDeficit = segments.some(({ amountCents }) => amountCents < 0); + const summary = describeAllocationRing([...displayedSegments], incomeCents, privacyMode); + const formattedIncome = formatCurrencyCents(incomeCents); + const visibleIncome = privacyMode ? PRIVATE_AMOUNT_PLACEHOLDER : formattedIncome; + const visibleCenterValue = privacyMode ? PRIVATE_CENTER_PLACEHOLDER : centerValue; + const visibleCenterLabel = privacyMode ? 'Frei' : centerLabel; + const incomeSize = privacyMode ? 'default' : formattedIncome.length >= 19 ? 'long' : formattedIncome.length >= 14 ? 'medium' : 'default'; + const incomePathId = `${id}-income-path`; + + return ( +
+
+
+ + + {summary} +
+ +
+ {displayedSegments.map((segment) => { + const ratio = privacyMode ? 1 : fillRatio(segment.amountCents, incomeCents); + const style: BarStyle = { + '--overview-allocation-color': segment.color, + '--overview-allocation-fill': `${ratio * 100}%`, + }; + return ( +
+
+ ); + })} +
+
+
+ ); +} diff --git a/src/design/overviewAllocationRing.test.ts b/src/design/overviewAllocationRing.test.ts new file mode 100644 index 0000000..c77b204 --- /dev/null +++ b/src/design/overviewAllocationRing.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from 'vitest'; +import type { AllocationRingSegment } from './layeredAllocationRing'; +import { createOverviewAllocationSectors, OVERVIEW_RING_GEOMETRY } from './overviewAllocationRing'; + +const segment = (id: string, amountCents: number): AllocationRingSegment => ({ + amountCents, + color: id, + id, + label: id, +}); + +describe('overview allocation ring geometry', () => { + it('builds separated clockwise sectors in source order', () => { + const sectors = createOverviewAllocationSectors([ + segment('expenses', 215_000), + segment('reserves', 30_000), + segment('free', 14_132), + ], 259_132); + + expect(sectors.map(({ id }) => id)).toEqual(['expenses', 'reserves', 'free']); + expect(sectors.reduce((sum, { share }) => sum + share, 0)).toBeCloseTo(1, 12); + expect(sectors.every(({ gapAngle, path }) => gapAngle > 0 && path.endsWith('Z'))).toBe(true); + sectors.forEach((sector, index) => { + const next = sectors[(index + 1) % sectors.length]; + const nextStart = index === sectors.length - 1 ? next.startAngle + 360 : next.startAngle; + expect(nextStart - sector.endAngle).toBeGreaterThan(0); + }); + }); + + it('keeps tiny positive sectors visible while reducing their local gap', () => { + const sectors = createOverviewAllocationSectors([segment('major', 9_999), segment('tiny', 1)], 10_000); + const tiny = sectors.find(({ id }) => id === 'tiny'); + + expect(tiny).toBeDefined(); + expect((tiny?.endAngle ?? 0) - (tiny?.startAngle ?? 0)).toBeGreaterThan(0); + expect(tiny?.gapAngle).toBeLessThan(OVERVIEW_RING_GEOMETRY.gapAngle); + }); + + it('omits zero and negative values without reordering the survivors', () => { + const sectors = createOverviewAllocationSectors([ + segment('expenses', 100), + segment('zero', 0), + segment('deficit', -20), + segment('reserves', 20), + ], 100); + + expect(sectors.map(({ id }) => id)).toEqual(['expenses', 'reserves']); + expect(sectors.reduce((sum, { share }) => sum + share, 0)).toBeCloseTo(1, 12); + }); + + it('renders one positive allocation as a complete donut', () => { + const [sector] = createOverviewAllocationSectors([segment('free', 100)], 100); + + expect(sector.share).toBe(1); + expect(sector.gapAngle).toBe(0); + expect(sector.path.match(/A /g)).toHaveLength(4); + }); + + it('returns no geometry for non-positive or invalid income', () => { + expect(createOverviewAllocationSectors([segment('free', 100)], 0)).toEqual([]); + expect(createOverviewAllocationSectors([segment('free', 100)], -100)).toEqual([]); + expect(createOverviewAllocationSectors([segment('free', 100)], Number.NaN)).toEqual([]); + }); +}); diff --git a/src/design/overviewAllocationRing.ts b/src/design/overviewAllocationRing.ts new file mode 100644 index 0000000..322cad5 --- /dev/null +++ b/src/design/overviewAllocationRing.ts @@ -0,0 +1,124 @@ +import type { AllocationRingSegment } from './layeredAllocationRing'; + +export type OverviewAllocationSector = AllocationRingSegment & { + endAngle: number; + gapAngle: number; + path: string; + share: number; + startAngle: number; +}; + +export const OVERVIEW_RING_GEOMETRY = Object.freeze({ + center: 80, + cornerRadius: 4, + gapAngle: 2.4, + innerRadius: 48, + outerRadius: 76, + startAngle: -90, + viewBoxSize: 160, +}); + +const precision = (value: number) => Number(value.toFixed(3)); +const degreesToRadians = (degrees: number) => (degrees * Math.PI) / 180; + +function polarPoint(angle: number, radius: number) { + const radians = degreesToRadians(angle); + return { + x: precision(OVERVIEW_RING_GEOMETRY.center + radius * Math.cos(radians)), + y: precision(OVERVIEW_RING_GEOMETRY.center + radius * Math.sin(radians)), + }; +} + +function fullRingPath() { + const { center, innerRadius, outerRadius } = OVERVIEW_RING_GEOMETRY; + return [ + `M ${center} ${center - outerRadius}`, + `A ${outerRadius} ${outerRadius} 0 1 1 ${center} ${center + outerRadius}`, + `A ${outerRadius} ${outerRadius} 0 1 1 ${center} ${center - outerRadius}`, + `M ${center} ${center - innerRadius}`, + `A ${innerRadius} ${innerRadius} 0 1 0 ${center} ${center + innerRadius}`, + `A ${innerRadius} ${innerRadius} 0 1 0 ${center} ${center - innerRadius}`, + 'Z', + ].join(' '); +} + +/** Builds a clockwise annular sector with four modestly rounded corners. */ +function roundedSectorPath(startAngle: number, endAngle: number) { + const { cornerRadius, innerRadius, outerRadius } = OVERVIEW_RING_GEOMETRY; + const span = endAngle - startAngle; + const outerCornerAngle = Math.min((cornerRadius / outerRadius) * (180 / Math.PI), span / 4); + const innerCornerAngle = Math.min((cornerRadius / innerRadius) * (180 / Math.PI), span / 4); + const largeArc = span > 180 ? 1 : 0; + + const outerStart = polarPoint(startAngle + outerCornerAngle, outerRadius); + const outerEnd = polarPoint(endAngle - outerCornerAngle, outerRadius); + const outerEndControl = polarPoint(endAngle, outerRadius); + const outerEndEdge = polarPoint(endAngle, outerRadius - cornerRadius); + const innerEndEdge = polarPoint(endAngle, innerRadius + cornerRadius); + const innerEndControl = polarPoint(endAngle, innerRadius); + const innerEnd = polarPoint(endAngle - innerCornerAngle, innerRadius); + const innerStart = polarPoint(startAngle + innerCornerAngle, innerRadius); + const innerStartControl = polarPoint(startAngle, innerRadius); + const innerStartEdge = polarPoint(startAngle, innerRadius + cornerRadius); + const outerStartEdge = polarPoint(startAngle, outerRadius - cornerRadius); + const outerStartControl = polarPoint(startAngle, outerRadius); + + return [ + `M ${outerStart.x} ${outerStart.y}`, + `A ${outerRadius} ${outerRadius} 0 ${largeArc} 1 ${outerEnd.x} ${outerEnd.y}`, + `Q ${outerEndControl.x} ${outerEndControl.y} ${outerEndEdge.x} ${outerEndEdge.y}`, + `L ${innerEndEdge.x} ${innerEndEdge.y}`, + `Q ${innerEndControl.x} ${innerEndControl.y} ${innerEnd.x} ${innerEnd.y}`, + `A ${innerRadius} ${innerRadius} 0 ${largeArc} 0 ${innerStart.x} ${innerStart.y}`, + `Q ${innerStartControl.x} ${innerStartControl.y} ${innerStartEdge.x} ${innerStartEdge.y}`, + `L ${outerStartEdge.x} ${outerStartEdge.y}`, + `Q ${outerStartControl.x} ${outerStartControl.y} ${outerStart.x} ${outerStart.y}`, + 'Z', + ].join(' '); +} + +export function createOverviewAllocationSectors( + segments: readonly AllocationRingSegment[], + totalCents: number, +): OverviewAllocationSector[] { + if (!Number.isSafeInteger(totalCents) || totalCents <= 0) return []; + + const visible = segments.filter(({ amountCents }) => Number.isSafeInteger(amountCents) && amountCents > 0); + const sourceTotal = visible.reduce((sum, { amountCents }) => sum + amountCents, 0); + if (!Number.isSafeInteger(sourceTotal) || sourceTotal <= 0) return []; + + if (visible.length === 1) { + return [{ + ...visible[0], + endAngle: OVERVIEW_RING_GEOMETRY.startAngle + 360, + gapAngle: 0, + path: fullRingPath(), + share: 1, + startAngle: OVERVIEW_RING_GEOMETRY.startAngle, + }]; + } + + let consumedShare = 0; + let nominalStart = OVERVIEW_RING_GEOMETRY.startAngle; + + return visible.map((segment, index) => { + const share = index === visible.length - 1 + ? 1 - consumedShare + : segment.amountCents / sourceTotal; + consumedShare += share; + const nominalSpan = share * 360; + const gapAngle = Math.min(OVERVIEW_RING_GEOMETRY.gapAngle, nominalSpan * 0.2); + const startAngle = nominalStart + gapAngle / 2; + const endAngle = nominalStart + nominalSpan - gapAngle / 2; + nominalStart += nominalSpan; + + return { + ...segment, + endAngle: precision(endAngle), + gapAngle: precision(gapAngle), + path: roundedSectorPath(startAngle, endAngle), + share, + startAngle: precision(startAngle), + }; + }); +} diff --git a/src/screens/OverviewScreen.tsx b/src/screens/OverviewScreen.tsx index 107f3e5..90e27a3 100644 --- a/src/screens/OverviewScreen.tsx +++ b/src/screens/OverviewScreen.tsx @@ -1,14 +1,12 @@ import { useState } from 'react'; -import { AllocationLegend } from '../components/AllocationLegend'; import { AppButton } from '../components/AppButton'; import { DataList, DataListItem } from '../components/DataList'; -import { FinancialHero } from '../components/FinancialHero'; import { Icon } from '../components/Icon'; import { InlineNotice } from '../components/InlineNotice'; -import { LayeredAllocationRing } from '../components/LayeredAllocationRing'; import { MetricCard } from '../components/MetricCard'; import { MetricGrid } from '../components/MetricGrid'; import { MoneyValue } from '../components/MoneyValue'; +import { OverviewAllocationHero } from '../components/OverviewAllocationHero'; import { ScreenEntrance } from '../components/ScreenEntrance'; import { ScreenHeader } from '../components/ScreenHeader'; import { SurfaceSection } from '../components/SurfaceSection'; @@ -23,7 +21,6 @@ const POCKET_PREVIEW_LIMIT = 6; export function OverviewScreen() { const data = useFinanceViewModel(); const greeting = useTimeOfDayGreeting(); - const [allocationDetailed, setAllocationDetailed] = useState(false); const [showAllAccounts, setShowAllAccounts] = useState(false); const [showAllPockets, setShowAllPockets] = useState(false); const freeMoneyCents = data.totals.freeMoneyCents; @@ -68,47 +65,12 @@ export function OverviewScreen() { - setAllocationDetailed((detailed) => !detailed)} - size="small" - variant="tonal" - > - {allocationDetailed ? 'Kompakte Aufteilung' : 'Aufteilung umschalten'} - - )} - className="financial-hero--allocation" - footer={( - ({ - color: segment.color, - id: segment.id, - label: segment.label, - value: , - }))} /> - )} + von Einkommen im Monat} - tone={budgetHasDeficit ? 'attention' : 'positive'} - value={} - visual={( - - )} + incomeCents={allocation.incomeCents} + segments={segments} /> diff --git a/src/styles/primitives.css b/src/styles/primitives.css index 6c5eb28..113707b 100644 --- a/src/styles/primitives.css +++ b/src/styles/primitives.css @@ -1552,6 +1552,185 @@ button:disabled { white-space: nowrap; } +.overview-allocation-hero { + width: 100%; + min-width: 0; + color: var(--color-on-surface); + container: overview-allocation-hero / inline-size; +} + +.overview-allocation-hero__layout { + display: grid; + grid-template-columns: minmax(0, 0.95fr) minmax(0, 1.05fr); + align-items: stretch; + gap: clamp(8px, 2cqi, 20px); +} + +.overview-allocation-ring { + position: relative; + width: 100%; + max-width: 380px; + min-width: 0; + aspect-ratio: 1; + margin: 0; + container-type: inline-size; + justify-self: start; +} + +.overview-allocation-ring__svg { + display: block; + width: 100%; + height: 100%; + overflow: visible; +} + +.overview-allocation-ring__sector { + shape-rendering: geometricPrecision; +} + +.overview-allocation-ring__income { + fill: var(--color-on-surface-variant); + font-family: inherit; + font-size: clamp(12px, 4cqi, 13px); + font-variation-settings: 'ROND' var(--type-label-rond), 'wdth' 94; + font-weight: var(--type-label-weight); + letter-spacing: -0.015em; +} + +.overview-allocation-ring__income[data-income-size='medium'] { + font-size: clamp(12px, 3.5cqi, 13px); +} + +.overview-allocation-ring__income[data-income-size='long'] { + font-size: 12px; +} + +.overview-allocation-ring__income--masked, +.overview-allocation-ring__center-value--masked { + filter: blur(calc(7em / 32)); + opacity: 0.78; + user-select: none; +} + +.overview-allocation-ring__center { + position: absolute; + top: 28%; + left: 25%; + display: flex; + width: 50%; + height: 38%; + align-items: center; + justify-content: center; + color: var(--chart-free); + text-align: center; + flex-direction: column; + pointer-events: none; +} + +.overview-allocation-ring--deficit .overview-allocation-ring__center { + color: var(--chart-deficit); +} + +.overview-allocation-ring__center strong { + font-size: clamp(18px, 11cqi, 42px); + font-variant-numeric: tabular-nums; + font-variation-settings: 'ROND' var(--type-metric-rond), 'wdth' 98; + font-weight: 720; + letter-spacing: -0.04em; + line-height: 1; + white-space: nowrap; +} + +.overview-allocation-ring__center small { + margin-top: clamp(1px, 1cqi, 4px); + color: inherit; + font-size: clamp(10px, 4.2cqi, 16px); + font-weight: var(--type-label-weight); + line-height: 1.1; +} + +.overview-allocation-bars { + display: grid; + min-width: 0; + height: 100%; + grid-template-rows: repeat(3, minmax(0, 1fr)); + gap: clamp(4px, 1cqi, 8px); +} + +.overview-allocation-bar { + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; + border-radius: 18px; + background: color-mix(in srgb, var(--color-surface-bright) 72%, var(--color-container)); + color: var(--color-on-surface); + container-type: inline-size; +} + +.overview-allocation-bar__fill { + position: absolute; + inset: 0 auto 0 0; + width: var(--overview-allocation-fill); + border-radius: inherit; + background: color-mix(in srgb, var(--overview-allocation-color) 28%, var(--color-surface-bright)); + pointer-events: none; +} + +.overview-allocation-bar__content { + position: relative; + z-index: 1; + display: flex; + height: 100%; + min-width: 0; + padding: clamp(4px, 3cqi, 16px) clamp(7px, 5cqi, 18px); + justify-content: center; + flex-direction: column; +} + +.overview-allocation-bar__label { + overflow: hidden; + font-size: clamp(12px, 7cqi, 16px); + font-variation-settings: 'ROND' var(--type-component-rond), 'wdth' 96; + font-weight: var(--type-component-weight); + line-height: 1.15; + text-overflow: ellipsis; + white-space: nowrap; +} + +.overview-allocation-bar__value { + display: block; + min-width: 0; + margin-top: clamp(0px, 1cqi, 3px); + font-size: clamp(13px, 9cqi, 22px); + line-height: 1.05; + container-type: inline-size; +} + +.overview-allocation-bar__value > .money-value, +.overview-allocation-bar__value > .money-value .money-value__blur { + display: block; + max-width: 100%; + overflow: hidden; + line-height: inherit; + text-overflow: ellipsis; + white-space: nowrap; +} + +.overview-allocation-bar__value > .money-value wbr { + display: none; +} + +.overview-allocation-bar__value > .money-value--masked, +.overview-allocation-bar__value > .money-value--masked .money-value__blur { + overflow: visible; +} + +.overview-allocation-bar__value > .money-value--masked .money-value__blur { + padding: 0.28em; + margin: -0.28em; +} + .surface-section { min-width: 0; } @@ -1748,6 +1927,12 @@ dialog.adaptive-dialog::backdrop { background: color-mix(in srgb, var(--color-sc } @media (forced-colors: active) { + .overview-allocation-ring__income--masked, + .overview-allocation-ring__center-value--masked { + filter: none; + opacity: 0.6; + } + .money-value__blur { filter: none; opacity: 0.6; diff --git a/src/styles/responsive.css b/src/styles/responsive.css index b2583ec..e91feb1 100644 --- a/src/styles/responsive.css +++ b/src/styles/responsive.css @@ -267,6 +267,7 @@ @media (forced-colors: active) { .financial-hero, + .overview-allocation-bar, .metric-card, .surface-section--tonal, .data-list__item, diff --git a/tests/visual/__screenshots__/chromium/1440-dark-overview.png b/tests/visual/__screenshots__/chromium/1440-dark-overview.png index 4fbcfcb..0257a76 100644 Binary files a/tests/visual/__screenshots__/chromium/1440-dark-overview.png and b/tests/visual/__screenshots__/chromium/1440-dark-overview.png differ diff --git a/tests/visual/__screenshots__/chromium/1440-light-overview.png b/tests/visual/__screenshots__/chromium/1440-light-overview.png index 620171b..6f57fc1 100644 Binary files a/tests/visual/__screenshots__/chromium/1440-light-overview.png and b/tests/visual/__screenshots__/chromium/1440-light-overview.png differ diff --git a/tests/visual/__screenshots__/chromium/412-dark-edge-dense-overview-expanded.png b/tests/visual/__screenshots__/chromium/412-dark-edge-dense-overview-expanded.png index bbc6c8e..b7da58d 100644 Binary files a/tests/visual/__screenshots__/chromium/412-dark-edge-dense-overview-expanded.png and b/tests/visual/__screenshots__/chromium/412-dark-edge-dense-overview-expanded.png differ diff --git a/tests/visual/__screenshots__/chromium/412-dark-edge-empty-overview.png b/tests/visual/__screenshots__/chromium/412-dark-edge-empty-overview.png index 2df9b38..d7ee750 100644 Binary files a/tests/visual/__screenshots__/chromium/412-dark-edge-empty-overview.png and b/tests/visual/__screenshots__/chromium/412-dark-edge-empty-overview.png differ diff --git a/tests/visual/__screenshots__/chromium/412-dark-edge-extreme-overview.png b/tests/visual/__screenshots__/chromium/412-dark-edge-extreme-overview.png index 13b8bc9..646edce 100644 Binary files a/tests/visual/__screenshots__/chromium/412-dark-edge-extreme-overview.png and b/tests/visual/__screenshots__/chromium/412-dark-edge-extreme-overview.png differ diff --git a/tests/visual/__screenshots__/chromium/412-dark-info-dialog.png b/tests/visual/__screenshots__/chromium/412-dark-info-dialog.png index f12c28e..30d4c1f 100644 Binary files a/tests/visual/__screenshots__/chromium/412-dark-info-dialog.png and b/tests/visual/__screenshots__/chromium/412-dark-info-dialog.png differ diff --git a/tests/visual/__screenshots__/chromium/412-dark-overview.png b/tests/visual/__screenshots__/chromium/412-dark-overview.png index e45c667..1ff2e96 100644 Binary files a/tests/visual/__screenshots__/chromium/412-dark-overview.png and b/tests/visual/__screenshots__/chromium/412-dark-overview.png differ diff --git a/tests/visual/__screenshots__/chromium/412-light-disconnect-confirmation.png b/tests/visual/__screenshots__/chromium/412-light-disconnect-confirmation.png index 4f26de0..2cf5e1b 100644 Binary files a/tests/visual/__screenshots__/chromium/412-light-disconnect-confirmation.png and b/tests/visual/__screenshots__/chromium/412-light-disconnect-confirmation.png differ diff --git a/tests/visual/__screenshots__/chromium/412-light-edge-dense-overview-expanded.png b/tests/visual/__screenshots__/chromium/412-light-edge-dense-overview-expanded.png index 0bb99ba..706a8bb 100644 Binary files a/tests/visual/__screenshots__/chromium/412-light-edge-dense-overview-expanded.png and b/tests/visual/__screenshots__/chromium/412-light-edge-dense-overview-expanded.png differ diff --git a/tests/visual/__screenshots__/chromium/412-light-edge-empty-overview.png b/tests/visual/__screenshots__/chromium/412-light-edge-empty-overview.png index 28aac5c..696fe3e 100644 Binary files a/tests/visual/__screenshots__/chromium/412-light-edge-empty-overview.png and b/tests/visual/__screenshots__/chromium/412-light-edge-empty-overview.png differ diff --git a/tests/visual/__screenshots__/chromium/412-light-edge-extreme-overview.png b/tests/visual/__screenshots__/chromium/412-light-edge-extreme-overview.png index 2d6885c..faee52a 100644 Binary files a/tests/visual/__screenshots__/chromium/412-light-edge-extreme-overview.png and b/tests/visual/__screenshots__/chromium/412-light-edge-extreme-overview.png differ diff --git a/tests/visual/__screenshots__/chromium/412-light-info-dialog.png b/tests/visual/__screenshots__/chromium/412-light-info-dialog.png index 733a7b4..fd8fa07 100644 Binary files a/tests/visual/__screenshots__/chromium/412-light-info-dialog.png and b/tests/visual/__screenshots__/chromium/412-light-info-dialog.png differ diff --git a/tests/visual/__screenshots__/chromium/412-light-overview-default.png b/tests/visual/__screenshots__/chromium/412-light-overview-default.png index 03f285c..6717547 100644 Binary files a/tests/visual/__screenshots__/chromium/412-light-overview-default.png and b/tests/visual/__screenshots__/chromium/412-light-overview-default.png differ diff --git a/tests/visual/__screenshots__/chromium/412-light-overview-detailed.png b/tests/visual/__screenshots__/chromium/412-light-overview-detailed.png deleted file mode 100644 index 183cf12..0000000 Binary files a/tests/visual/__screenshots__/chromium/412-light-overview-detailed.png and /dev/null differ diff --git a/tests/visual/__screenshots__/chromium/768-light-overview.png b/tests/visual/__screenshots__/chromium/768-light-overview.png index 0563279..30c1d20 100644 Binary files a/tests/visual/__screenshots__/chromium/768-light-overview.png and b/tests/visual/__screenshots__/chromium/768-light-overview.png differ diff --git a/tests/visual/finance-ui.spec.ts b/tests/visual/finance-ui.spec.ts index ce12ff3..b5854b2 100644 --- a/tests/visual/finance-ui.spec.ts +++ b/tests/visual/finance-ui.spec.ts @@ -160,6 +160,7 @@ async function expectMoneyValuesInsideContainers(page: Page, minimumCount = 1) { '.metric-card__value', '.financial-hero', '.metric-card', + '.overview-allocation-bar', '.allocation-legend__item', '.data-list__item', '.data-list__footer', @@ -203,7 +204,7 @@ async function expectMoneyValuesInsideContainers(page: Page, minimumCount = 1) { async function expectPrimaryMoneyValuesOnOneLine(page: Page, minimumCount = 1) { const valueMetrics = await page - .locator('.financial-hero__value > .money-value, .metric-card__value > .money-value') + .locator('.financial-hero__value > .money-value, .metric-card__value > .money-value, .overview-allocation-bar__value > .money-value') .evaluateAll((values) => values.map((value) => { const style = getComputedStyle(value); return { @@ -221,8 +222,8 @@ async function expectPrimaryMoneyValuesOnOneLine(page: Page, minimumCount = 1) { expect(offenders).toEqual([]); } -async function expectCompactHeroRingBesideCopy(page: Page, destination: 'overview' | 'budget') { - const geometry = await page.locator(`.${destination}-screen .financial-hero`).evaluate((hero) => { +async function expectCompactBudgetHeroRingBesideCopy(page: Page) { + const geometry = await page.locator('.budget-screen .financial-hero').evaluate((hero) => { const content = hero.querySelector('.financial-hero__content')?.getBoundingClientRect(); const ringElement = hero.querySelector('.circular-allocation'); const ring = ringElement?.getBoundingClientRect(); @@ -252,9 +253,45 @@ async function expectCompactHeroRingBesideCopy(page: Page, destination: 'overvie })) : []); } +async function expectOverviewAllocationLayout(page: Page) { + const geometry = await page.locator('#overview-hero').evaluate((hero) => { + const heroRect = hero.getBoundingClientRect(); + const ring = hero.querySelector('.overview-allocation-ring')?.getBoundingClientRect(); + const bars = hero.querySelector('.overview-allocation-bars')?.getBoundingClientRect(); + const barItems = [...hero.querySelectorAll('.overview-allocation-bar')].map((bar) => { + const rect = bar.getBoundingClientRect(); + return { height: rect.height, top: rect.top }; + }); + return { + backgroundColor: getComputedStyle(hero).backgroundColor, + barItems, + barsHeight: bars?.height ?? 0, + barsLeft: bars?.left ?? 0, + heroWidth: heroRect.width, + incomePath: Boolean(hero.querySelector('.overview-allocation-ring__income textPath')), + interactiveControls: hero.querySelectorAll('button, [role="button"]').length, + ringHeight: ring?.height ?? 0, + ringRight: ring?.right ?? 0, + ringWidth: ring?.width ?? 0, + sectorIds: [...hero.querySelectorAll('.overview-allocation-ring__sector')] + .map((sector) => sector.getAttribute('data-allocation-id')), + }; + }); + + expect(geometry.backgroundColor).toBe('rgba(0, 0, 0, 0)'); + expect(geometry.ringWidth / geometry.heroWidth).toBeGreaterThanOrEqual(0.4); + expect(geometry.ringWidth / geometry.heroWidth).toBeLessThanOrEqual(0.5); + expect(Math.abs(geometry.ringHeight - geometry.barsHeight)).toBeLessThanOrEqual(0.5); + expect(geometry.barsLeft).toBeGreaterThanOrEqual(geometry.ringRight - 0.5); + expect(geometry.barItems).toHaveLength(3); + expect(Math.max(...geometry.barItems.map(({ height }) => height)) - Math.min(...geometry.barItems.map(({ height }) => height))).toBeLessThanOrEqual(0.5); + expect(geometry.sectorIds.length).toBeGreaterThan(0); + expect(geometry.incomePath).toBe(true); + expect(geometry.interactiveControls).toBe(0); +} + for (const scenario of [ { name: 'overview-default', destination: 'overview' as const }, - { name: 'overview-detailed', destination: 'overview' as const, interact: async (page: Page) => page.locator('.overview-screen .circular-allocation__button').click() }, { name: 'budget-categories', destination: 'budget' as const }, { name: 'budget-necessity', destination: 'budget' as const, interact: async (page: Page) => page.getByRole('tab', { name: 'Notwendigkeit' }).click() }, { name: 'debt-collapsed', destination: 'debt' as const }, @@ -265,9 +302,8 @@ for (const scenario of [ await preparePage(page, context, 'connected', 'light', scenario.destination === 'overview', destinationPaths[scenario.destination]); await openDestination(page, scenario.destination); await scenario.interact?.(page); - if (scenario.destination === 'overview' || scenario.destination === 'budget') { - await expectCompactHeroRingBesideCopy(page, scenario.destination); - } + if (scenario.destination === 'overview') await expectOverviewAllocationLayout(page); + if (scenario.destination === 'budget') await expectCompactBudgetHeroRingBesideCopy(page); await capture(page, `412-light-${scenario.name}.png`); }); } @@ -309,6 +345,72 @@ test('overview greeting follows the local device time', async ({ page, context } await expect(page.getByRole('heading', { name: 'Guten Tag' })).toBeVisible(); }); +test('overview allocation masks income, values, and derived geometry in privacy mode', async ({ page, context }) => { + await page.setViewportSize({ width: 412, height: 915 }); + await preparePage(page, context, 'connected', 'light', true); + + await page.getByLabel('Beträge ausblenden').click(); + await expect(page.getByLabel('Beträge anzeigen')).toHaveAttribute('aria-pressed', 'true'); + await expect(page.locator('#overview-hero .overview-allocation-bar .money-value--masked')).toHaveCount(3); + await expect(page.locator('#overview-hero .overview-allocation-ring__income')).not.toContainText(/\d/); + await expect(page.locator('#overview-hero [data-testid="overview-allocation-center-value"]')).not.toContainText(/\d/); + await expect(page.locator('#overview-hero .overview-allocation-ring__sector')).toHaveCount(1); + await expect(page.locator('#overview-hero .overview-allocation-ring__sector')).toHaveAttribute('data-allocation-id', 'private'); + await expect(page.locator('#overview-hero .overview-allocation-ring__sector').first()).not.toHaveAttribute('data-share', /.+/); + await expect(page.locator('#overview-hero .overview-allocation-bar').first()).not.toHaveAttribute('data-fill-ratio', /.+/); + await expect(page.locator('#overview-hero [data-testid="overview-allocation-accessible-summary"]')).toContainText('Monatseinkommen Betrag ausgeblendet'); + const privacyGeometry = await page.locator('#overview-hero').evaluate((hero) => ({ + barFills: [...hero.querySelectorAll('.overview-allocation-bar')].map((bar) => { + const barBounds = bar.getBoundingClientRect(); + const fillBounds = bar.querySelector('.overview-allocation-bar__fill')?.getBoundingClientRect(); + return { barWidth: barBounds.width, fillWidth: fillBounds?.width ?? 0 }; + }), + centerFilter: getComputedStyle(hero.querySelector('[data-testid="overview-allocation-center-value"]') as Element).filter, + incomeFilter: getComputedStyle(hero.querySelector('.overview-allocation-ring__income') as Element).filter, + moneyOverflow: [...hero.querySelectorAll('.overview-allocation-bar__value > .money-value--masked, .overview-allocation-bar__value .money-value__blur')] + .map((value) => getComputedStyle(value).overflow), + })); + expect(privacyGeometry.barFills.every(({ barWidth, fillWidth }) => Math.abs(barWidth - fillWidth) <= 0.5)).toBe(true); + expect(privacyGeometry.centerFilter).not.toBe('none'); + expect(privacyGeometry.incomeFilter).not.toBe('none'); + expect(privacyGeometry.moneyOverflow.every((overflow) => overflow === 'visible')).toBe(true); + const result = await new AxeBuilder({ page }) + .include('#overview-hero') + .withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22aa']) + .analyze(); + expect(result.violations, `Overview mit ausgeblendeten Beträgen: ${JSON.stringify(result.violations, null, 2)}`).toEqual([]); +}); + +test('overview allocation hides the deficit state in privacy mode', async ({ page, context }) => { + await page.setViewportSize({ width: 320, height: 800 }); + await preparePage(page, context, 'connected', 'light', true, '/', extremeOverdrawnFinanceData); + + await page.getByLabel('Beträge ausblenden').click(); + const hero = page.locator('#overview-hero'); + const freeBar = hero.locator('.overview-allocation-bar[data-allocation-id="free"]'); + + await expect(hero.locator('.overview-allocation-ring')).not.toHaveClass(/overview-allocation-ring--deficit/); + await expect(freeBar).not.toHaveClass(/overview-allocation-bar--deficit/); + await expect(freeBar.locator('.overview-allocation-bar__label')).toHaveText('Frei'); + await expect(freeBar).not.toContainText('Fehlbetrag'); + await expect(hero.locator('.overview-allocation-ring__center small')).toHaveText('Frei'); + await expect(hero.locator('[data-testid="overview-allocation-center-value"]')).toHaveText('••• %'); + await expect(hero.locator('.overview-allocation-ring__income')).toContainText('von •••• €'); + await expect(hero.getByTestId('overview-allocation-accessible-summary')).toContainText('Frei: Betrag ausgeblendet'); + await expect(hero.getByTestId('overview-allocation-accessible-summary')).not.toContainText('Fehlbetrag'); + + const privateStyles = await hero.evaluate((element) => ({ + freeColor: (element.querySelector('.overview-allocation-bar[data-allocation-id="free"]') as HTMLElement) + .style.getPropertyValue('--overview-allocation-color'), + incomeFontSize: Number.parseFloat(getComputedStyle(element.querySelector('.overview-allocation-ring__income') as Element).fontSize), + visibleNegativeSigns: [...element.querySelectorAll('[aria-hidden="true"]')] + .some((node) => /[-−]/.test(node.textContent ?? '')), + })); + expect(privateStyles.freeColor).toBe('var(--chart-free)'); + expect(privateStyles.incomeFontSize).toBeGreaterThanOrEqual(12); + expect(privateStyles.visibleNegativeSigns).toBe(false); +}); + test('upcoming payments refresh after the local day changes', async ({ page, context }) => { const financeData = financeDataWithExampleSubscription(); await page.clock.install({ time: new Date('2026-08-12T21:59:50.000Z') }); @@ -447,7 +549,7 @@ test('412 light PIN setup, expressive entry, reload lock, unlock, and disable', element.getAnimations().flatMap((animation) => ( (animation.effect as KeyframeEffect).getKeyframes().map((keyframe) => keyframe.transform) )) - ))).toContain('scale(2)'); + ))).toContain('scale(1.5)'); const centered = await activeShape.evaluate((element) => { const indicator = element.getBoundingClientRect(); const group = element.parentElement!.getBoundingClientRect(); @@ -667,6 +769,7 @@ for (const viewport of [{ width: 768, height: 1024 }, { width: 1440, height: 100 await page.setViewportSize(viewport); await preparePage(page, context, 'connected', theme, destination === 'overview', destinationPaths[destination]); await openDestination(page, destination); + if (destination === 'overview') await expectOverviewAllocationLayout(page); await capture(page, `${viewport.width}-${theme}-${destination}.png`); }); } @@ -707,13 +810,19 @@ test('320 extreme values, negative balances, and an overdrawn budget stay exact await preparePage(page, context, 'connected', 'light', true, '/', extremeOverdrawnFinanceData); await page.getByRole('heading', { name: overviewHeading }).waitFor(); - await expect(page.getByRole('heading', { name: 'Budgetsaldo' })).toBeVisible(); + await expectOverviewAllocationLayout(page); + const incomeLabelTypography = await page.locator('#overview-hero .overview-allocation-ring__income').evaluate((element) => ({ + size: element.getAttribute('data-income-size'), + fontSize: Number.parseFloat(getComputedStyle(element).fontSize), + })); + expect(incomeLabelTypography.size).toBe('medium'); + expect(incomeLabelTypography.fontSize).toBeGreaterThanOrEqual(12); + await expect(page.locator('#overview-hero .overview-allocation-bar[data-allocation-id="free"]')).toContainText('Fehlbetrag'); await expect(page.getByText('Budget liegt über dem Einkommen')).toBeVisible(); await expect(page.getByText('Negative Kontostände berücksichtigt')).toBeVisible(); await expect(page.getByText('-111.111.101,11 €', { exact: true }).first()).toBeVisible(); - await expect(page.locator('#overview-hero')).toHaveClass(/financial-hero--attention/); - await expect(page.locator('#overview-hero [data-testid="allocation-center-value"]')).toHaveText('190,0 %'); - await expect(page.locator('#overview-hero [data-testid="allocation-accessible-summary"]')).toContainText('Fehlbetrag: -111.111.101,11 €'); + await expect(page.locator('#overview-hero [data-testid="overview-allocation-center-value"]')).toHaveText('190,0 %'); + await expect(page.locator('#overview-hero [data-testid="overview-allocation-accessible-summary"]')).toContainText('Fehlbetrag: -111.111.101,11 €'); await expectNoHorizontalOverflow(page); await expectMoneyValuesInsideContainers(page); await expectPrimaryMoneyValuesOnOneLine(page); @@ -756,7 +865,7 @@ test('412 maximum safe cent values still fit hero and metric slots on one line', await page.setViewportSize({ width: 412, height: 915 }); await preparePage(page, context, 'connected', 'light', true, '/', maximumSafeFinanceData); - const overviewHeroValue = page.locator('#overview-hero .financial-hero__value'); + const overviewHeroValue = page.locator('#overview-hero .overview-allocation-bar[data-allocation-id="free"] .overview-allocation-bar__value'); await expect(overviewHeroValue).toHaveText('90.071.992.547.409,91 €'); await expect(overviewHeroValue).not.toContainText(/frei/i); await expectNoHorizontalOverflow(page); @@ -776,10 +885,9 @@ test('320 negative income without budget items remains an empty deficit state', await page.setViewportSize({ width: 320, height: 800 }); await preparePage(page, context, 'connected', 'light', true, '/', negativeEmptyBudgetData); - await expect(page.getByRole('heading', { name: 'Budgetsaldo' })).toBeVisible(); - await expect(page.locator('#overview-hero')).toHaveClass(/financial-hero--attention/); - await expect(page.locator('#overview-hero [data-testid="allocation-center-value"]')).toHaveText('–'); - await expect(page.locator('#overview-hero [data-testid="allocation-accessible-summary"]')).toContainText('Fehlbetrag: -123,45 €'); + await expect(page.locator('#overview-hero .overview-allocation-bar[data-allocation-id="free"]')).toContainText('Fehlbetrag'); + await expect(page.locator('#overview-hero [data-testid="overview-allocation-center-value"]')).toHaveText('–'); + await expect(page.locator('#overview-hero [data-testid="overview-allocation-accessible-summary"]')).toContainText('Fehlbetrag: -123,45 €'); await expect(page.getByText('Monatseinkommen ist negativ')).toBeVisible(); await expect(page.getByText('Frei verfügbar', { exact: true })).toHaveCount(0); @@ -824,7 +932,7 @@ test('320 dense overview progressively exposes every account and pocket', async const accountButton = page.getByRole('button', { name: 'Alle 12 zeigen' }); const pocketButton = page.getByRole('button', { name: 'Alle 18 zeigen' }); const totalBefore = await page.locator('#account-list .data-list__footer').textContent(); - const heroBefore = await page.locator('#overview-hero .financial-hero__value').textContent(); + const heroBefore = await page.locator('#overview-hero .overview-allocation-bar[data-allocation-id="free"] .overview-allocation-bar__value').textContent(); await expect(page.locator('#account-list [role="listitem"]')).toHaveCount(5); await expect(page.locator('#pocket-list .pocket')).toHaveCount(6); await expect(accountButton).toHaveAttribute('aria-expanded', 'false'); @@ -856,7 +964,7 @@ test('320 dense overview progressively exposes every account and pocket', async await expect(page.getByRole('button', { name: 'Alle 18 zeigen' })).toBeFocused(); await expect(page.getByRole('button', { name: 'Alle 18 zeigen' })).toHaveAttribute('aria-expanded', 'false'); await expect(page.locator('#account-list .data-list__footer')).toHaveText(totalBefore ?? ''); - await expect(page.locator('#overview-hero .financial-hero__value')).toHaveText(heroBefore ?? ''); + await expect(page.locator('#overview-hero .overview-allocation-bar[data-allocation-id="free"] .overview-allocation-bar__value')).toHaveText(heroBefore ?? ''); }); test('320 all-zero pockets keep their empty explanation and remain expandable', async ({ page, context }) => {