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