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:
parent
606093d311
commit
0250fdc268
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue