Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
118 changes: 66 additions & 52 deletions scripts/browser-smoke.mjs

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion scripts/offline-smoke.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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/);
Expand Down
142 changes: 142 additions & 0 deletions src/components/OverviewAllocationHero.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<section aria-label="Einkommensaufteilung" className="overview-allocation-hero" id={id}>
<div className="overview-allocation-hero__layout">
<figure
aria-label={summary}
className={`overview-allocation-ring ${hasDeficit && !privacyMode ? 'overview-allocation-ring--deficit' : ''}`.trim()}
role="img"
>
<svg
aria-hidden="true"
className="overview-allocation-ring__svg"
focusable="false"
viewBox={`0 0 ${OVERVIEW_RING_GEOMETRY.viewBoxSize} ${OVERVIEW_RING_GEOMETRY.viewBoxSize}`}
>
<g className="overview-allocation-ring__sectors">
{sectors.map((sector) => (
<path
className="overview-allocation-ring__sector"
d={sector.path}
data-allocation-id={sector.id}
data-end-angle={privacyMode ? undefined : sector.endAngle}
data-gap-angle={privacyMode ? undefined : sector.gapAngle}
data-share={privacyMode ? undefined : Number(sector.share.toFixed(6))}
data-start-angle={privacyMode ? undefined : sector.startAngle}
fillRule="evenodd"
key={sector.id}
style={{ fill: sector.color }}
/>
))}
</g>
<path
className="overview-allocation-ring__income-path"
d="M 41.477 94.023 A 41 41 0 0 0 118.523 94.023"
fill="none"
id={incomePathId}
/>
<text
className={`overview-allocation-ring__income ${privacyMode ? 'overview-allocation-ring__income--masked' : ''}`.trim()}
data-income-size={incomeSize}
>
<textPath href={`#${incomePathId}`} startOffset="50%" textAnchor="middle">
von {visibleIncome}
</textPath>
</text>
</svg>
<span aria-hidden="true" className="overview-allocation-ring__center">
<strong
className={privacyMode ? 'overview-allocation-ring__center-value--masked' : undefined}
data-testid="overview-allocation-center-value"
>
{visibleCenterValue}
</strong>
<small>{visibleCenterLabel}</small>
</span>
<span className="sr-only" data-testid="overview-allocation-accessible-summary">{summary}</span>
</figure>

<div aria-label="Werte der Einkommensaufteilung" className="overview-allocation-bars" role="list">
{displayedSegments.map((segment) => {
const ratio = privacyMode ? 1 : fillRatio(segment.amountCents, incomeCents);
const style: BarStyle = {
'--overview-allocation-color': segment.color,
'--overview-allocation-fill': `${ratio * 100}%`,
Comment thread
greptile-apps[bot] marked this conversation as resolved.
};
return (
<div
className={`overview-allocation-bar ${segment.amountCents < 0 ? 'overview-allocation-bar--deficit' : ''}`.trim()}
data-allocation-id={segment.id}
data-fill-ratio={privacyMode ? undefined : Number(ratio.toFixed(6))}
key={segment.id}
role="listitem"
style={style}
>
<span aria-hidden="true" className="overview-allocation-bar__fill" />
<span className="overview-allocation-bar__content">
<span className="overview-allocation-bar__label">{segment.label}</span>
<strong className="overview-allocation-bar__value financial-value">
<MoneyValue maskedPlaceholder={privacyMode ? PRIVATE_AMOUNT_PLACEHOLDER : undefined} valueCents={segment.amountCents} />
</strong>
</span>
</div>
);
})}
</div>
</div>
</section>
);
}
64 changes: 64 additions & 0 deletions src/design/overviewAllocationRing.test.ts
Original file line number Diff line number Diff line change
@@ -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([]);
});
});
124 changes: 124 additions & 0 deletions src/design/overviewAllocationRing.ts
Original file line number Diff line number Diff line change
@@ -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),
};
});
}
Loading
Loading