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 password": "Guardar contraseña",
"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...": "Guardando...",
"Savings": "Ahorros",

View File

@ -2,11 +2,16 @@ 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) => ({
const summary = (
income: number,
expense: number,
net: number,
savingsRate = 0,
) => ({
income,
expense,
net,
savings_rate: 0,
savings_rate: savingsRate,
savings: 0,
investments: 0,
});
@ -26,17 +31,20 @@ describe('NetCashflowCard', () => {
it('only shows a trend arrow for the previous period comparison', () => {
render(
<NetCashflowCard
current={summary(1200000, 337700, 862300)}
current={summary(1200000, 337700, 862300, 71.9)}
previous={summary(3000000, 261300, 2738700)}
currency="EUR"
/>,
);
// net amount + income decreases; rate + expense increases
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('-1800000')).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', () => {

View File

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

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,
CardTitle,
} from '@/components/ui/card';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import { CashflowSummary } from '@/hooks/use-cashflow-data';
import { cn } from '@/lib/utils';
import { __ } from '@/utils/i18n';
import { TrendingDown, TrendingUp } from 'lucide-react';
import { HelpCircle, TrendingDown, TrendingUp } from 'lucide-react';
interface SavingsRateCardProps {
interface SavedInvestedCardProps {
current: CashflowSummary;
previous: CashflowSummary;
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,
previous,
loading,
currency = 'USD',
}: SavingsRateCardProps) {
}: SavedInvestedCardProps) {
if (loading) {
return (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">
{__('Savings Rate')}
{__('Saved & Invested')}
</CardTitle>
</CardHeader>
<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>
</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 investmentsDiff = current.investments - previous.investments;
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 =
current.savings_rate >= 20
rate >= 50
? 'text-green-600 dark:text-green-400'
: current.savings_rate >= 10
: rate >= 25
? 'text-yellow-600 dark:text-yellow-400'
: current.savings_rate >= 0
: rate >= 0
? 'text-orange-600 dark:text-orange-400'
: 'text-red-600 dark:text-red-400';
return (
<Card>
<CardHeader className="pb-2">
<CardHeader className="relative pb-2">
<CardTitle className="text-sm font-medium">
{__('Savings Rate')}
{__('Saved & Invested')}
</CardTitle>
<CardDescription>
{__('Percentage of income saved')}
{__('Share of net cashflow set aside')}
</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>
<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
className={cn(
'text-3xl font-bold tabular-nums',
'text-lg font-medium tabular-nums',
rateColor,
)}
>
{current.savings_rate.toFixed(1)}%
{rate.toFixed(1)}%
</span>
</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>
<p className="text-xs text-muted-foreground">
{__('Saved')}
{__('Saved (cashflow)')}
</p>
<AmountDisplay
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 { NetCashflowCard } from '@/components/cashflow/net-cashflow-card';
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 HeadingSmall from '@/components/heading-small';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
@ -183,7 +183,7 @@ export default function CashflowPage() {
currency={auth.user.currency_code}
/>
<SavingsRateCard
<SavedInvestedCard
current={summary.current}
previous={summary.previous}
loading={isLoading}