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:
parent
26cf79d88b
commit
5ce439f463
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"Saved (cashflow)": "Saved"
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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}
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
Loading…
Reference in New Issue