From 5ce439f463bfdac7ebb3aa5c0396d098999fdad6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADctor=20Falc=C3=B3n?= Date: Mon, 1 Jun 2026 11:37:28 +0200 Subject: [PATCH] feat(cashflow): rework summary cards into net + saved/invested (#465) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 --- lang/en.json | 3 + lang/es.json | 5 + .../cashflow/net-cashflow-card.test.tsx | 16 +++- .../components/cashflow/net-cashflow-card.tsx | 75 ++++++++------- .../cashflow/saved-invested-card.test.tsx | 94 +++++++++++++++++++ ...-rate-card.tsx => saved-invested-card.tsx} | 87 +++++++++++++---- .../cashflow/savings-rate-card.test.tsx | 66 ------------- resources/js/pages/cashflow/index.tsx | 4 +- 8 files changed, 227 insertions(+), 123 deletions(-) create mode 100644 lang/en.json create mode 100644 resources/js/components/cashflow/saved-invested-card.test.tsx rename resources/js/components/cashflow/{savings-rate-card.tsx => saved-invested-card.tsx} (64%) delete mode 100644 resources/js/components/cashflow/savings-rate-card.test.tsx diff --git a/lang/en.json b/lang/en.json new file mode 100644 index 00000000..b09818bc --- /dev/null +++ b/lang/en.json @@ -0,0 +1,3 @@ +{ + "Saved (cashflow)": "Saved" +} diff --git a/lang/es.json b/lang/es.json index 642ba713..1f9c2f43 100644 --- a/lang/es.json +++ b/lang/es.json @@ -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", diff --git a/resources/js/components/cashflow/net-cashflow-card.test.tsx b/resources/js/components/cashflow/net-cashflow-card.test.tsx index babf4c32..8d7af1b1 100644 --- a/resources/js/components/cashflow/net-cashflow-card.test.tsx +++ b/resources/js/components/cashflow/net-cashflow-card.test.tsx @@ -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( , ); + // 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', () => { diff --git a/resources/js/components/cashflow/net-cashflow-card.tsx b/resources/js/components/cashflow/net-cashflow-card.tsx index be9dfc28..41561ae5 100644 --- a/resources/js/components/cashflow/net-cashflow-card.tsx +++ b/resources/js/components/cashflow/net-cashflow-card.tsx @@ -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({ {__('Income minus expenses')} -
+
+ + {current.savings_rate.toFixed(1)}% +
{hasPreviousData && ( -
- {diffIsPositive ? ( - - ) : ( - - )} - - {diffIsPositive ? '+' : ''} - - - - {__('vs last period')} - -
+ <> +
+ {rateDiffIsPositive ? ( + + ) : ( + + )} + + {rateDiffIsPositive ? '+' : ''} + {rateDiff.toFixed(1)}% + + + {__('vs last period')} + +
+
+ {diffIsPositive ? ( + + ) : ( + + )} + + {diffIsPositive ? '+' : ''} + + + + {__('vs last period')} + +
+ )}
diff --git a/resources/js/components/cashflow/saved-invested-card.test.tsx b/resources/js/components/cashflow/saved-invested-card.test.tsx new file mode 100644 index 00000000..5b292af3 --- /dev/null +++ b/resources/js/components/cashflow/saved-invested-card.test.tsx @@ -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 }) => ( + {amountInCents} + ), +})); + +vi.mock('lucide-react', () => ({ + HelpCircle: () => , + TrendingDown: () => , + TrendingUp: () => , +})); + +describe('SavedInvestedCard', () => { + it('shows the saved+invested total, its share of net, and previous period comparisons', () => { + render( + , + ); + + // 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( + , + ); + + 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( + , + ); + + 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(); + }); +}); diff --git a/resources/js/components/cashflow/savings-rate-card.tsx b/resources/js/components/cashflow/saved-invested-card.tsx similarity index 64% rename from resources/js/components/cashflow/savings-rate-card.tsx rename to resources/js/components/cashflow/saved-invested-card.tsx index 6b7c366b..80a5de34 100644 --- a/resources/js/components/cashflow/savings-rate-card.tsx +++ b/resources/js/components/cashflow/saved-invested-card.tsx @@ -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 ( - {__('Savings Rate')} + {__('Saved & Invested')} -
+
); } - 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 ( - + - {__('Savings Rate')} + {__('Saved & Invested')} - {__('Percentage of income saved')} + {__('Share of net cashflow set aside')} + + + + + + {__( + '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.', + )} + + -
+
+ - {current.savings_rate.toFixed(1)}% + {rate.toFixed(1)}%
- {hasPreviousData && } + {hasPreviousData && ( + <> + + + + )}

- {__('Saved')} + {__('Saved (cashflow)')}

({ - income, - expense, - net, - savings_rate: savingsRate, - savings, - investments, -}); - -vi.mock('@/components/ui/amount-display', () => ({ - AmountDisplay: ({ amountInCents }: { amountInCents: number }) => ( - {amountInCents} - ), -})); - -vi.mock('lucide-react', () => ({ - TrendingDown: () => , - TrendingUp: () => , -})); - -describe('SavingsRateCard', () => { - it('shows previous period comparisons below rate, saved, and invested values', () => { - render( - , - ); - - 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( - , - ); - - expect(screen.queryByText('vs last period')).toBeNull(); - expect(screen.queryByTestId('comparison-trend-up')).toBeNull(); - expect(screen.queryByTestId('comparison-trend-down')).toBeNull(); - }); -}); diff --git a/resources/js/pages/cashflow/index.tsx b/resources/js/pages/cashflow/index.tsx index e5a77e30..d80b079a 100644 --- a/resources/js/pages/cashflow/index.tsx +++ b/resources/js/pages/cashflow/index.tsx @@ -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} /> -