fix(cashflow): clarify period comparisons (#436)

## Summary
- Remove misleading primary net cashflow trend arrow
- Add previous-period comparisons for income, expenses, saved, and
invested values
- Move savings-rate comparison below the main percentage

## Tests
- npm test --
resources/js/components/cashflow/net-cashflow-card.test.tsx
resources/js/components/cashflow/savings-rate-card.test.tsx
This commit is contained in:
Víctor Falcón 2026-05-26 10:52:01 +02:00 committed by GitHub
parent 606093d311
commit 0250fdc268
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 262 additions and 32 deletions

View File

@ -0,0 +1,55 @@
import { render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { NetCashflowCard } from './net-cashflow-card';
const summary = (income: number, expense: number, net: number) => ({
income,
expense,
net,
savings_rate: 0,
savings: 0,
investments: 0,
});
vi.mock('@/components/ui/amount-display', () => ({
AmountDisplay: ({ amountInCents }: { amountInCents: number }) => (
<span>{amountInCents}</span>
),
}));
vi.mock('lucide-react', () => ({
TrendingDown: () => <svg data-testid="comparison-trend-down" />,
TrendingUp: () => <svg data-testid="comparison-trend-up" />,
}));
describe('NetCashflowCard', () => {
it('only shows a trend arrow for the previous period comparison', () => {
render(
<NetCashflowCard
current={summary(1200000, 337700, 862300)}
previous={summary(3000000, 261300, 2738700)}
currency="EUR"
/>,
);
expect(screen.getAllByTestId('comparison-trend-down')).toHaveLength(2);
expect(screen.getAllByTestId('comparison-trend-up')).toHaveLength(1);
expect(screen.getByText('-1876400')).toBeTruthy();
expect(screen.getByText('-1800000')).toBeTruthy();
expect(screen.getByText('76400')).toBeTruthy();
});
it('keeps the signed net amount visible when current net cashflow is negative', () => {
render(
<NetCashflowCard
current={summary(50000, 150000, -100000)}
previous={summary(0, 0, 0)}
currency="EUR"
/>,
);
expect(screen.queryByTestId('comparison-trend-down')).toBeNull();
expect(screen.queryByTestId('comparison-trend-up')).toBeNull();
expect(screen.getByText('-100000')).toBeTruthy();
});
});

View File

@ -9,7 +9,7 @@ import {
import { CashflowSummary } from '@/hooks/use-cashflow-data';
import { cn } from '@/lib/utils';
import { __ } from '@/utils/i18n';
import { ArrowDown, ArrowUp, TrendingDown, TrendingUp } from 'lucide-react';
import { TrendingDown, TrendingUp } from 'lucide-react';
interface NetCashflowCardProps {
current: CashflowSummary;
@ -18,6 +18,48 @@ interface NetCashflowCardProps {
currency?: string;
}
interface PeriodComparisonProps {
diff: number;
isFavorable: boolean;
currency: string;
}
function PeriodComparison({
diff,
isFavorable,
currency,
}: PeriodComparisonProps) {
const isIncrease = diff >= 0;
const Icon = isIncrease ? TrendingUp : TrendingDown;
return (
<div className="mt-1 flex items-center gap-1 text-xs">
<Icon
className={cn(
'size-3',
isFavorable
? 'text-green-600 dark:text-green-400'
: 'text-red-600 dark:text-red-400',
)}
/>
<span>
{isIncrease ? '+' : ''}
<AmountDisplay
amountInCents={diff}
currencyCode={currency}
minimumFractionDigits={0}
maximumFractionDigits={0}
className="text-xs"
highlightPositive
/>
</span>
<span className="text-muted-foreground">
{__('vs last period')}
</span>
</div>
);
}
export function NetCashflowCard({
current,
previous,
@ -39,9 +81,10 @@ export function NetCashflowCard({
);
}
const isPositive = current.net >= 0;
const diff = current.net - previous.net;
const diffIsPositive = diff >= 0;
const incomeDiff = current.income - previous.income;
const expenseDiff = current.expense - previous.expense;
const hasPreviousData = previous.income > 0 || previous.expense > 0;
return (
@ -55,23 +98,8 @@ export function NetCashflowCard({
<CardContent>
<div className="flex items-baseline gap-2">
<div className={cn('flex items-center gap-1')}>
{isPositive ? (
<ArrowUp
className={cn(
'size-4',
'text-green-600 dark:text-green-400',
)}
/>
) : (
<ArrowDown
className={cn(
'size-4',
'text-red-600 dark:text-red-400',
)}
/>
)}
<AmountDisplay
amountInCents={Math.abs(current.net)}
amountInCents={current.net}
currencyCode={currency}
size="2xl"
weight="bold"
@ -127,6 +155,13 @@ export function NetCashflowCard({
weight="medium"
highlightPositive
/>
{hasPreviousData && (
<PeriodComparison
diff={incomeDiff}
isFavorable={incomeDiff >= 0}
currency={currency}
/>
)}
</div>
<div>
<p className="text-xs text-muted-foreground">
@ -139,6 +174,13 @@ export function NetCashflowCard({
maximumFractionDigits={0}
weight="medium"
/>
{hasPreviousData && (
<PeriodComparison
diff={expenseDiff}
isFavorable={expenseDiff <= 0}
currency={currency}
/>
)}
</div>
</div>
</CardContent>

View File

@ -0,0 +1,66 @@
import { render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { SavingsRateCard } from './savings-rate-card';
const summary = (
income: number,
expense: number,
net: number,
savingsRate: number,
savings: number,
investments: number,
) => ({
income,
expense,
net,
savings_rate: savingsRate,
savings,
investments,
});
vi.mock('@/components/ui/amount-display', () => ({
AmountDisplay: ({ amountInCents }: { amountInCents: number }) => (
<span>{amountInCents}</span>
),
}));
vi.mock('lucide-react', () => ({
TrendingDown: () => <svg data-testid="comparison-trend-down" />,
TrendingUp: () => <svg data-testid="comparison-trend-up" />,
}));
describe('SavingsRateCard', () => {
it('shows previous period comparisons below rate, saved, and invested values', () => {
render(
<SavingsRateCard
current={summary(200000, 120000, 80000, 40, 50000, 30000)}
previous={summary(100000, 70000, 30000, 30, 40000, 50000)}
currency="EUR"
/>,
);
expect(screen.getByText('40.0%')).toBeTruthy();
expect(screen.getByText('+10.0%')).toBeTruthy();
expect(screen.getByText('50000')).toBeTruthy();
expect(screen.getByText('30000')).toBeTruthy();
expect(screen.getByText('10000')).toBeTruthy();
expect(screen.getByText('-20000')).toBeTruthy();
expect(screen.getAllByText('vs last period')).toHaveLength(3);
expect(screen.getAllByTestId('comparison-trend-up')).toHaveLength(2);
expect(screen.getAllByTestId('comparison-trend-down')).toHaveLength(1);
});
it('hides comparisons when there is no previous period data', () => {
render(
<SavingsRateCard
current={summary(200000, 120000, 80000, 40, 50000, 30000)}
previous={summary(0, 0, 0, 0, 0, 0)}
currency="EUR"
/>,
);
expect(screen.queryByText('vs last period')).toBeNull();
expect(screen.queryByTestId('comparison-trend-up')).toBeNull();
expect(screen.queryByTestId('comparison-trend-down')).toBeNull();
});
});

View File

@ -18,6 +18,72 @@ interface SavingsRateCardProps {
currency?: string;
}
interface PercentageComparisonProps {
diff: number;
}
function PercentageComparison({ diff }: PercentageComparisonProps) {
const isPositive = diff >= 0;
const Icon = isPositive ? TrendingUp : TrendingDown;
return (
<div className="mt-2 flex items-center gap-1 text-sm">
<Icon
className={cn(
'size-4',
isPositive
? 'text-green-600 dark:text-green-400'
: 'text-red-600 dark:text-red-400',
)}
/>
<span>
{isPositive ? '+' : ''}
{diff.toFixed(1)}%
</span>
<span className="text-muted-foreground">
{__('vs last period')}
</span>
</div>
);
}
interface AmountComparisonProps {
diff: number;
currency: string;
}
function AmountComparison({ diff, currency }: AmountComparisonProps) {
const isPositive = diff >= 0;
const Icon = isPositive ? TrendingUp : TrendingDown;
return (
<div className="mt-1 flex items-center gap-1 text-xs">
<Icon
className={cn(
'size-3',
isPositive
? 'text-green-600 dark:text-green-400'
: 'text-red-600 dark:text-red-400',
)}
/>
<span>
{isPositive ? '+' : ''}
<AmountDisplay
amountInCents={diff}
currencyCode={currency}
minimumFractionDigits={0}
maximumFractionDigits={0}
className="text-xs"
highlightPositive
/>
</span>
<span className="text-muted-foreground">
{__('vs last period')}
</span>
</div>
);
}
export function SavingsRateCard({
current,
previous,
@ -40,7 +106,8 @@ export function SavingsRateCard({
}
const diff = current.savings_rate - previous.savings_rate;
const isPositive = diff >= 0;
const savingsDiff = current.savings - previous.savings;
const investmentsDiff = current.investments - previous.investments;
const hasPreviousData = previous.income > 0;
// Determine color based on savings rate
@ -73,20 +140,8 @@ export function SavingsRateCard({
>
{current.savings_rate.toFixed(1)}%
</span>
{hasPreviousData && (
<div className={cn('flex items-center gap-1 text-sm')}>
{isPositive ? (
<TrendingUp className="size-4 text-green-600 dark:text-green-400" />
) : (
<TrendingDown className="size-4 text-red-600 dark:text-red-400" />
)}
<span>
{isPositive ? '+' : ''}
{diff.toFixed(1)}%
</span>
</div>
)}
</div>
{hasPreviousData && <PercentageComparison diff={diff} />}
<div className="mt-3 grid grid-cols-2 gap-4 border-t pt-3">
<div>
<p className="text-xs text-muted-foreground">
@ -100,6 +155,12 @@ export function SavingsRateCard({
weight="medium"
highlightPositive
/>
{hasPreviousData && (
<AmountComparison
diff={savingsDiff}
currency={currency}
/>
)}
</div>
<div>
<p className="text-xs text-muted-foreground">
@ -113,6 +174,12 @@ export function SavingsRateCard({
weight="medium"
highlightPositive
/>
{hasPreviousData && (
<AmountComparison
diff={investmentsDiff}
currency={currency}
/>
)}
</div>
</div>
</CardContent>