Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 105 additions & 0 deletions app/src/__tests__/SavingsOpportunities.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import React from 'react';
import { render, screen, waitFor } from '@testing-library/react';
import { SavingsOpportunities } from '@/components/ui/SavingsOpportunities';

jest.mock('@/components/ui/financial-card', () => ({
FinancialCard: ({ children, ...props }: React.PropsWithChildren & Record<string, unknown>) => (
<div data-testid={props['data-testid'] as string}>{children}</div>
),
FinancialCardHeader: ({ children }: React.PropsWithChildren) => <div>{children}</div>,
FinancialCardContent: ({ children }: React.PropsWithChildren) => <div>{children}</div>,
FinancialCardTitle: ({ children }: React.PropsWithChildren) => <div>{children}</div>,
FinancialCardDescription: ({ children }: React.PropsWithChildren) => <div>{children}</div>,
}));

jest.mock('@/hooks/use-toast', () => ({
useToast: () => ({ toast: jest.fn() }),
}));

const getSavingsOpportunitiesMock = jest.fn();
jest.mock('@/api/insights', () => ({
getSavingsOpportunities: (...args: unknown[]) => getSavingsOpportunitiesMock(...args),
}));

describe('SavingsOpportunities', () => {
beforeEach(() => {
jest.clearAllMocks();
});

it('shows loading state initially', () => {
getSavingsOpportunitiesMock.mockReturnValue(new Promise(() => {})); // never resolves
render(<SavingsOpportunities month="2026-03" />);
expect(screen.getByTestId('savings-loading')).toBeInTheDocument();
});

it('renders opportunity cards when data is loaded', async () => {
getSavingsOpportunitiesMock.mockResolvedValue({
month: '2026-03',
opportunities: [
{
type: 'month_over_month_increase',
title: 'Dining spending spike',
description: 'Your Dining spending increased 50% this month.',
potential_savings: 50.0,
category: 'Dining',
trend: { current_month: 150, previous_month: 100, change_pct: 50.0 },
},
{
type: 'high_frequency_small_purchases',
title: 'Small purchases add up',
description: 'You made 10 small purchases totalling $50.',
potential_savings: 25.0,
category: 'Coffee',
trend: { transaction_count: 10, total_amount: 50 },
},
],
});

render(<SavingsOpportunities month="2026-03" />);
await waitFor(() => expect(getSavingsOpportunitiesMock).toHaveBeenCalledWith({ month: '2026-03' }));
await waitFor(() => expect(screen.getByTestId('savings-opportunities')).toBeInTheDocument());

const cards = screen.getAllByTestId('savings-opportunity-card');
expect(cards).toHaveLength(2);
expect(screen.getByText(/dining spending spike/i)).toBeInTheDocument();
expect(screen.getByText(/small purchases add up/i)).toBeInTheDocument();
});

it('shows empty state when no opportunities', async () => {
getSavingsOpportunitiesMock.mockResolvedValue({
month: '2026-03',
opportunities: [],
});

render(<SavingsOpportunities month="2026-03" />);
await waitFor(() => expect(screen.getByTestId('savings-empty')).toBeInTheDocument());
expect(screen.getByText(/no savings opportunities found/i)).toBeInTheDocument();
});

it('shows error state on failure', async () => {
getSavingsOpportunitiesMock.mockRejectedValue(new Error('Network error'));

render(<SavingsOpportunities month="2026-03" />);
await waitFor(() => expect(screen.getByTestId('savings-error')).toBeInTheDocument());
expect(screen.getByText(/network error/i)).toBeInTheDocument();
});

it('re-fetches when month prop changes', async () => {
getSavingsOpportunitiesMock.mockResolvedValue({
month: '2026-03',
opportunities: [],
});

const { rerender } = render(<SavingsOpportunities month="2026-03" />);
await waitFor(() => expect(getSavingsOpportunitiesMock).toHaveBeenCalledWith({ month: '2026-03' }));

getSavingsOpportunitiesMock.mockResolvedValue({
month: '2026-04',
opportunities: [],
});

rerender(<SavingsOpportunities month="2026-04" />);
await waitFor(() => expect(getSavingsOpportunitiesMock).toHaveBeenCalledWith({ month: '2026-04' }));
expect(getSavingsOpportunitiesMock).toHaveBeenCalledTimes(2);
});
});
27 changes: 27 additions & 0 deletions app/src/api/insights.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,30 @@ export async function getBudgetSuggestion(params?: {
if (params?.persona) headers['X-Insight-Persona'] = params.persona;
return api<BudgetSuggestion>(`/insights/budget-suggestion${monthQuery}`, { headers });
}

export type SavingsOpportunityTrend = Record<string, number | string>;

export type SavingsOpportunity = {
type: string;
title: string;
description: string;
potential_savings: number;
category: string | null;
trend: SavingsOpportunityTrend;
};

export type SavingsOpportunitiesResponse = {
month: string;
opportunities: SavingsOpportunity[];
};

export async function getSavingsOpportunities(
params?: { month?: string },
): Promise<SavingsOpportunitiesResponse> {
const monthQuery = params?.month
? `?month=${encodeURIComponent(params.month)}`
: '';
return api<SavingsOpportunitiesResponse>(
`/insights/savings-opportunities${monthQuery}`,
);
}
148 changes: 148 additions & 0 deletions app/src/components/ui/SavingsOpportunities.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import { useEffect, useState } from 'react';
import {
FinancialCard,
FinancialCardContent,
FinancialCardDescription,
FinancialCardHeader,
FinancialCardTitle,
} from '@/components/ui/financial-card';
import { formatMoney } from '@/lib/currency';
import {
getSavingsOpportunities,
type SavingsOpportunity,
type SavingsOpportunitiesResponse,
} from '@/api/insights';

const TYPE_LABELS: Record<string, string> = {
month_over_month_increase: 'Spending Spike',
high_frequency_small_purchases: 'Latte Factor',
subscription_duplicate: 'Possible Duplicate',
above_average_spending: 'Above Average',
};

const TYPE_VARIANTS: Record<string, 'warning' | 'destructive' | 'financial'> = {
month_over_month_increase: 'warning',
high_frequency_small_purchases: 'financial',
subscription_duplicate: 'destructive',
above_average_spending: 'warning',
};

function TrendBadge({ label, value }: { label: string; value: string }) {
return (
<span className="inline-flex items-center rounded-md bg-muted px-2 py-0.5 text-xs text-muted-foreground">
{label}: {value}
</span>
);
}

function OpportunityCard({ opportunity }: { opportunity: SavingsOpportunity }) {
const variant = TYPE_VARIANTS[opportunity.type] ?? 'financial';
const badge = TYPE_LABELS[opportunity.type] ?? opportunity.type;
const trend = opportunity.trend;

return (
<FinancialCard variant={variant} data-testid="savings-opportunity-card">
<FinancialCardHeader className="pb-2">
<div className="flex items-center justify-between">
<FinancialCardTitle className="text-sm">{opportunity.title}</FinancialCardTitle>
<span className="rounded-full bg-background/60 px-2 py-0.5 text-xs font-medium">
{badge}
</span>
</div>
<FinancialCardDescription>{opportunity.category ?? 'General'}</FinancialCardDescription>
</FinancialCardHeader>
<FinancialCardContent>
<p className="text-sm mb-3">{opportunity.description}</p>
<div className="flex items-center justify-between">
<div className="font-semibold text-lg">
Save {formatMoney(opportunity.potential_savings)}
</div>
<div className="flex flex-wrap gap-1">
{trend.change_pct != null && (
<TrendBadge label="Change" value={`${trend.change_pct}%`} />
)}
{trend.transaction_count != null && (
<TrendBadge label="Txns" value={String(trend.transaction_count)} />
)}
{trend.occurrences != null && (
<TrendBadge label="Occurrences" value={String(trend.occurrences)} />
)}
{trend.months_in_average != null && (
<TrendBadge label="Avg period" value={`${trend.months_in_average}mo`} />
)}
</div>
</div>
</FinancialCardContent>
</FinancialCard>
);
}

export function SavingsOpportunities({ month }: { month?: string }) {
const [loading, setLoading] = useState(true);
const [data, setData] = useState<SavingsOpportunitiesResponse | null>(null);
const [error, setError] = useState<string | null>(null);

useEffect(() => {
let cancelled = false;
setLoading(true);
setError(null);
getSavingsOpportunities({ month })
.then((res) => {
if (!cancelled) setData(res);
})
.catch((err: unknown) => {
if (!cancelled) {
setError(err instanceof Error ? err.message : 'Failed to load savings opportunities');
}
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [month]);

if (loading) {
return <div className="card" data-testid="savings-loading">Analyzing spending patterns...</div>;
}

if (error) {
return <div className="card text-red-600" data-testid="savings-error">{error}</div>;
}

if (!data || data.opportunities.length === 0) {
return (
<FinancialCard variant="success" data-testid="savings-empty">
<FinancialCardHeader>
<FinancialCardTitle>No Savings Opportunities Found</FinancialCardTitle>
</FinancialCardHeader>
<FinancialCardContent>
<p className="text-sm text-muted-foreground">
Your spending looks healthy this month. Keep it up!
</p>
</FinancialCardContent>
</FinancialCard>
);
}

const totalSavings = data.opportunities.reduce((sum, o) => sum + o.potential_savings, 0);

return (
<div className="space-y-4" data-testid="savings-opportunities">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold">
Savings Opportunities
</h2>
<span className="text-sm text-muted-foreground">
Potential total savings: {formatMoney(totalSavings)}
</span>
</div>
<div className="grid gap-4 md:grid-cols-2">
{data.opportunities.map((opp, idx) => (
<OpportunityCard key={`${opp.type}-${idx}`} opportunity={opp} />
))}
</div>
</div>
);
}
4 changes: 4 additions & 0 deletions app/src/pages/Analytics.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
import { useToast } from '@/hooks/use-toast';
import { getBudgetSuggestion, type BudgetSuggestion } from '@/api/insights';
import { formatMoney } from '@/lib/currency';
import { SavingsOpportunities } from '@/components/ui/SavingsOpportunities';

const PERSONAS = [
'Balanced coach',
Expand Down Expand Up @@ -193,6 +194,9 @@ export function Analytics() {
</FinancialCard>
</div>
) : null}

{/* Savings Opportunity Detection */}
<SavingsOpportunities month={month} />
</div>
);
}
14 changes: 14 additions & 0 deletions packages/backend/app/routes/insights.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
from flask import Blueprint, jsonify, request
from flask_jwt_extended import jwt_required, get_jwt_identity
from ..services.ai import monthly_budget_suggestion
from ..services.savings import detect_savings_opportunities
import logging

bp = Blueprint("insights", __name__)
Expand All @@ -23,3 +24,16 @@ def budget_suggestion():
)
logger.info("Budget suggestion served user=%s month=%s", uid, ym)
return jsonify(suggestion)


@bp.get("/savings-opportunities")
@jwt_required()
def savings_opportunities():
uid = int(get_jwt_identity())
ym = (request.args.get("month") or date.today().strftime("%Y-%m")).strip()
opportunities = detect_savings_opportunities(uid, ym)
logger.info(
"Savings opportunities served user=%s month=%s count=%d",
uid, ym, len(opportunities),
)
return jsonify({"month": ym, "opportunities": opportunities})
Loading