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} /> -