feat(cashflow): rework summary cards into net + saved/invested (#465)

## What

Reworks the two top summary cards on the **Cashflow** page so they no
longer both show net flow.

### Net Cashflow (card 1)
- Shows the net amount **and** its share of income (%).
- Compares **both** the amount and the percentage with the previous
period.

### Saved & Invested (card 2, replaces "Savings Rate")
- Headline = money moved to savings + investment categories (amount).
- Percentage = that total as a share of **net cashflow** (income −
expenses).
- Compares amount and percentage vs. the previous period.
- Keeps the per-category Saved / Invested breakdown at the bottom.
- Adds a **click-triggered help popover** (shadcn `Popover`, works on
touch + desktop, dismisses on outside click / Escape) explaining the
numbers come from transactions using a **"saving"** or **"investment"**
category type.

### i18n
- The card's "Saved" label uses a dedicated key so it renders
**"Ahorrado"** on the cashflow card while form buttons keep
**"Guardado"**. A minimal `lang/en.json` keeps the English label as
"Saved".
- Spanish strings added for the new card + popover copy.

## Testing
- `vitest` — net-cashflow + saved-invested card specs (amount/percentage
comparisons, click-to-reveal popover)
- Full `pest` suite (excl. Browser) — green; localization key-coverage
test passes
- `pint --test`, `prettier --check`, `eslint` — clean
- production `bun run build` — succeeds
This commit is contained in:
Víctor Falcón 2026-06-01 11:37:28 +02:00 committed by GitHub
parent 26cf79d88b
commit 5ce439f463
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 227 additions and 123 deletions

3
lang/en.json Normal file
View File

@ -0,0 +1,3 @@
{
"Saved (cashflow)": "Saved"
}

View File

@ -1164,6 +1164,11 @@
"Save money for goals": "Ahorra dinero para tus metas", "Save money for goals": "Ahorra dinero para tus metas",
"Save password": "Guardar contraseña", "Save password": "Guardar contraseña",
"Saved": "Guardado", "Saved": "Guardado",
"Saved (cashflow)": "Ahorrado",
"Saved & Invested": "Ahorrado e invertido",
"Share of net cashflow set aside": "Porcentaje del flujo neto destinado",
"Where do these numbers come from?": "¿De dónde salen estos números?",
"These figures come from transactions categorized with a \"saving\" or \"investment\" category type. Make sure you use those category types so all the money set aside is counted here.": "Estas cifras provienen de transacciones categorizadas con un tipo de categoría \"ahorro\" o \"inversión\". Asegúrate de usar esos tipos de categoría para que todo el dinero apartado se contabilice aquí.",
"Saving": "Ahorro", "Saving": "Ahorro",
"Saving...": "Guardando...", "Saving...": "Guardando...",
"Savings": "Ahorros", "Savings": "Ahorros",

View File

@ -2,11 +2,16 @@ import { render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest'; import { describe, expect, it, vi } from 'vitest';
import { NetCashflowCard } from './net-cashflow-card'; import { NetCashflowCard } from './net-cashflow-card';
const summary = (income: number, expense: number, net: number) => ({ const summary = (
income: number,
expense: number,
net: number,
savingsRate = 0,
) => ({
income, income,
expense, expense,
net, net,
savings_rate: 0, savings_rate: savingsRate,
savings: 0, savings: 0,
investments: 0, investments: 0,
}); });
@ -26,17 +31,20 @@ describe('NetCashflowCard', () => {
it('only shows a trend arrow for the previous period comparison', () => { it('only shows a trend arrow for the previous period comparison', () => {
render( render(
<NetCashflowCard <NetCashflowCard
current={summary(1200000, 337700, 862300)} current={summary(1200000, 337700, 862300, 71.9)}
previous={summary(3000000, 261300, 2738700)} previous={summary(3000000, 261300, 2738700)}
currency="EUR" currency="EUR"
/>, />,
); );
// net amount + income decreases; rate + expense increases
expect(screen.getAllByTestId('comparison-trend-down')).toHaveLength(2); expect(screen.getAllByTestId('comparison-trend-down')).toHaveLength(2);
expect(screen.getAllByTestId('comparison-trend-up')).toHaveLength(1); expect(screen.getAllByTestId('comparison-trend-up')).toHaveLength(2);
expect(screen.getByText('-1876400')).toBeTruthy(); expect(screen.getByText('-1876400')).toBeTruthy();
expect(screen.getByText('-1800000')).toBeTruthy(); expect(screen.getByText('-1800000')).toBeTruthy();
expect(screen.getByText('76400')).toBeTruthy(); expect(screen.getByText('76400')).toBeTruthy();
expect(screen.getByText('71.9%')).toBeTruthy();
expect(screen.getByText('+71.9%')).toBeTruthy();
}); });
it('keeps the signed net amount visible when current net cashflow is negative', () => { it('keeps the signed net amount visible when current net cashflow is negative', () => {

View File

@ -82,6 +82,8 @@ export function NetCashflowCard({
} }
const diff = current.net - previous.net; const diff = current.net - previous.net;
const rateDiff = current.savings_rate - previous.savings_rate;
const rateDiffIsPositive = rateDiff >= 0;
const diffIsPositive = diff >= 0; const diffIsPositive = diff >= 0;
const incomeDiff = current.income - previous.income; const incomeDiff = current.income - previous.income;
const expenseDiff = current.expense - previous.expense; const expenseDiff = current.expense - previous.expense;
@ -96,7 +98,7 @@ export function NetCashflowCard({
<CardDescription>{__('Income minus expenses')}</CardDescription> <CardDescription>{__('Income minus expenses')}</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="flex items-baseline gap-2"> <div className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
<div className={cn('flex items-center gap-1')}> <div className={cn('flex items-center gap-1')}>
<AmountDisplay <AmountDisplay
amountInCents={current.net} amountInCents={current.net}
@ -108,39 +110,48 @@ export function NetCashflowCard({
highlightPositive highlightPositive
/> />
</div> </div>
<span className="text-lg font-medium text-muted-foreground tabular-nums">
{current.savings_rate.toFixed(1)}%
</span>
</div> </div>
{hasPreviousData && ( {hasPreviousData && (
<div className={cn('mt-2 flex items-center gap-1 text-sm')}> <>
{diffIsPositive ? ( <div className="mt-2 flex items-center gap-1 text-sm">
<TrendingUp {rateDiffIsPositive ? (
className={cn( <TrendingUp className="size-4 text-green-600 dark:text-green-400" />
'size-4', ) : (
'text-green-600 dark:text-green-400', <TrendingDown className="size-4 text-red-600 dark:text-red-400" />
)} )}
/> <span>
) : ( {rateDiffIsPositive ? '+' : ''}
<TrendingDown {rateDiff.toFixed(1)}%
className={cn( </span>
'size-4', <span className="text-muted-foreground">
'text-red-600 dark:text-red-400', {__('vs last period')}
)} </span>
/> </div>
)} <div className="mt-1 flex items-center gap-1 text-xs">
<span> {diffIsPositive ? (
{diffIsPositive ? '+' : ''} <TrendingUp className="size-3 text-green-600 dark:text-green-400" />
<AmountDisplay ) : (
amountInCents={diff} <TrendingDown className="size-3 text-red-600 dark:text-red-400" />
currencyCode={currency} )}
minimumFractionDigits={0} <span>
maximumFractionDigits={0} {diffIsPositive ? '+' : ''}
className="text-sm" <AmountDisplay
highlightPositive amountInCents={diff}
/> currencyCode={currency}
</span> minimumFractionDigits={0}
<span className="text-muted-foreground"> maximumFractionDigits={0}
{__('vs last period')} className="text-xs"
</span> highlightPositive
</div> />
</span>
<span className="text-muted-foreground">
{__('vs last period')}
</span>
</div>
</>
)} )}
<div className="mt-3 grid grid-cols-2 gap-4 border-t pt-3"> <div className="mt-3 grid grid-cols-2 gap-4 border-t pt-3">
<div> <div>

View File

@ -0,0 +1,94 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { SavedInvestedCard } from './saved-invested-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', () => ({
HelpCircle: () => <svg data-testid="help-icon" />,
TrendingDown: () => <svg data-testid="comparison-trend-down" />,
TrendingUp: () => <svg data-testid="comparison-trend-up" />,
}));
describe('SavedInvestedCard', () => {
it('shows the saved+invested total, its share of net, and previous period comparisons', () => {
render(
<SavedInvestedCard
current={summary(200000, 120000, 80000, 40, 30000, 10000)}
previous={summary(100000, 70000, 30000, 30, 9000, 3000)}
currency="EUR"
/>,
);
// allocated (30000 + 10000) over net 80000 = 50%
expect(screen.getByText('40000')).toBeTruthy();
expect(screen.getByText('50.0%')).toBeTruthy();
// share moved from 40% to 50%
expect(screen.getByText('+10.0%')).toBeTruthy();
expect(screen.getByText('28000')).toBeTruthy();
// saved and invested breakdown
expect(screen.getByText('30000')).toBeTruthy();
expect(screen.getByText('21000')).toBeTruthy();
expect(screen.getByText('10000')).toBeTruthy();
expect(screen.getByText('7000')).toBeTruthy();
expect(screen.getAllByText('vs last period')).toHaveLength(4);
expect(screen.getAllByTestId('comparison-trend-up')).toHaveLength(4);
expect(screen.queryByTestId('comparison-trend-down')).toBeNull();
});
it('reveals where the numbers come from when the help icon is clicked', () => {
render(
<SavedInvestedCard
current={summary(200000, 120000, 80000, 40, 30000, 10000)}
previous={summary(100000, 70000, 30000, 30, 9000, 3000)}
currency="EUR"
/>,
);
expect(screen.queryByText(/transactions categorized/i)).toBeNull();
fireEvent.click(
screen.getByRole('button', {
name: 'Where do these numbers come from?',
}),
);
expect(screen.getByText(/transactions categorized/i)).toBeTruthy();
});
it('hides comparisons when there is no previous period data', () => {
render(
<SavedInvestedCard
current={summary(200000, 120000, 80000, 40, 30000, 10000)}
previous={summary(0, 0, 0, 0, 0, 0)}
currency="EUR"
/>,
);
expect(screen.getByText('50.0%')).toBeTruthy();
expect(screen.getByText('40000')).toBeTruthy();
expect(screen.queryByText('vs last period')).toBeNull();
expect(screen.queryByTestId('comparison-trend-up')).toBeNull();
expect(screen.queryByTestId('comparison-trend-down')).toBeNull();
});
});

View File

@ -6,12 +6,17 @@ import {
CardHeader, CardHeader,
CardTitle, CardTitle,
} from '@/components/ui/card'; } from '@/components/ui/card';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import { CashflowSummary } from '@/hooks/use-cashflow-data'; import { CashflowSummary } from '@/hooks/use-cashflow-data';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { __ } from '@/utils/i18n'; import { __ } from '@/utils/i18n';
import { TrendingDown, TrendingUp } from 'lucide-react'; import { HelpCircle, TrendingDown, TrendingUp } from 'lucide-react';
interface SavingsRateCardProps { interface SavedInvestedCardProps {
current: CashflowSummary; current: CashflowSummary;
previous: CashflowSummary; previous: CashflowSummary;
loading?: boolean; loading?: boolean;
@ -84,68 +89,112 @@ function AmountComparison({ diff, currency }: AmountComparisonProps) {
); );
} }
export function SavingsRateCard({ function allocationRate(summary: CashflowSummary): number {
if (summary.net <= 0) {
return 0;
}
return ((summary.savings + summary.investments) / summary.net) * 100;
}
export function SavedInvestedCard({
current, current,
previous, previous,
loading, loading,
currency = 'USD', currency = 'USD',
}: SavingsRateCardProps) { }: SavedInvestedCardProps) {
if (loading) { if (loading) {
return ( return (
<Card> <Card>
<CardHeader className="pb-2"> <CardHeader className="pb-2">
<CardTitle className="text-sm font-medium"> <CardTitle className="text-sm font-medium">
{__('Savings Rate')} {__('Saved & Invested')}
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="h-12 w-24 animate-pulse rounded bg-gray-200 dark:bg-gray-700" /> <div className="h-12 w-32 animate-pulse rounded bg-gray-200 dark:bg-gray-700" />
</CardContent> </CardContent>
</Card> </Card>
); );
} }
const diff = current.savings_rate - previous.savings_rate; const allocated = current.savings + current.investments;
const previousAllocated = previous.savings + previous.investments;
const allocatedDiff = allocated - previousAllocated;
const rate = allocationRate(current);
const rateDiff = rate - allocationRate(previous);
const savingsDiff = current.savings - previous.savings; const savingsDiff = current.savings - previous.savings;
const investmentsDiff = current.investments - previous.investments; const investmentsDiff = current.investments - previous.investments;
const hasPreviousData = previous.income > 0; const hasPreviousData = previous.income > 0;
// Determine color based on savings rate // Determine color based on how much of the net cashflow was set aside
const rateColor = const rateColor =
current.savings_rate >= 20 rate >= 50
? 'text-green-600 dark:text-green-400' ? 'text-green-600 dark:text-green-400'
: current.savings_rate >= 10 : rate >= 25
? 'text-yellow-600 dark:text-yellow-400' ? 'text-yellow-600 dark:text-yellow-400'
: current.savings_rate >= 0 : rate >= 0
? 'text-orange-600 dark:text-orange-400' ? 'text-orange-600 dark:text-orange-400'
: 'text-red-600 dark:text-red-400'; : 'text-red-600 dark:text-red-400';
return ( return (
<Card> <Card>
<CardHeader className="pb-2"> <CardHeader className="relative pb-2">
<CardTitle className="text-sm font-medium"> <CardTitle className="text-sm font-medium">
{__('Savings Rate')} {__('Saved & Invested')}
</CardTitle> </CardTitle>
<CardDescription> <CardDescription>
{__('Percentage of income saved')} {__('Share of net cashflow set aside')}
</CardDescription> </CardDescription>
<Popover>
<PopoverTrigger
aria-label={__('Where do these numbers come from?')}
className="absolute top-0 right-6 text-muted-foreground transition-colors hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none"
>
<HelpCircle className="size-4" />
</PopoverTrigger>
<PopoverContent align="end" className="text-sm">
{__(
'These figures come from transactions categorized with a "saving" or "investment" category type. Make sure you use those category types so all the money set aside is counted here.',
)}
</PopoverContent>
</Popover>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="flex items-baseline gap-2"> <div className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
<AmountDisplay
amountInCents={allocated}
currencyCode={currency}
size="2xl"
weight="bold"
minimumFractionDigits={0}
maximumFractionDigits={0}
highlightPositive
/>
<span <span
className={cn( className={cn(
'text-3xl font-bold tabular-nums', 'text-lg font-medium tabular-nums',
rateColor, rateColor,
)} )}
> >
{current.savings_rate.toFixed(1)}% {rate.toFixed(1)}%
</span> </span>
</div> </div>
{hasPreviousData && <PercentageComparison diff={diff} />} {hasPreviousData && (
<>
<PercentageComparison diff={rateDiff} />
<AmountComparison
diff={allocatedDiff}
currency={currency}
/>
</>
)}
<div className="mt-3 grid grid-cols-2 gap-4 border-t pt-3"> <div className="mt-3 grid grid-cols-2 gap-4 border-t pt-3">
<div> <div>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{__('Saved')} {__('Saved (cashflow)')}
</p> </p>
<AmountDisplay <AmountDisplay
amountInCents={current.savings} amountInCents={current.savings}

View File

@ -1,66 +0,0 @@
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

@ -1,7 +1,7 @@
import { BreakdownCard } from '@/components/cashflow/breakdown-card'; import { BreakdownCard } from '@/components/cashflow/breakdown-card';
import { NetCashflowCard } from '@/components/cashflow/net-cashflow-card'; import { NetCashflowCard } from '@/components/cashflow/net-cashflow-card';
import { PeriodNavigation } from '@/components/cashflow/period-navigation'; import { PeriodNavigation } from '@/components/cashflow/period-navigation';
import { SavingsRateCard } from '@/components/cashflow/savings-rate-card'; import { SavedInvestedCard } from '@/components/cashflow/saved-invested-card';
import { CashflowTrendChart, SankeyChart } from '@/components/charts'; import { CashflowTrendChart, SankeyChart } from '@/components/charts';
import HeadingSmall from '@/components/heading-small'; import HeadingSmall from '@/components/heading-small';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
@ -183,7 +183,7 @@ export default function CashflowPage() {
currency={auth.user.currency_code} currency={auth.user.currency_code}
/> />
<SavingsRateCard <SavedInvestedCard
current={summary.current} current={summary.current}
previous={summary.previous} previous={summary.previous}
loading={isLoading} loading={isLoading}