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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ OpenAPI: `backend/app/openapi.yaml`
- Expenses: CRUD `/expenses`
- Bills: CRUD `/bills`, pay/mark `/bills/{id}/pay`
- Reminders: CRUD `/reminders`, trigger `/reminders/run`
- Insights: `/insights/monthly`, `/insights/budget-suggestion`
- Insights: `/insights/monthly`, `/insights/budget-suggestion`, `/insights/weekly-summary`

## MVP UI/UX Plan
- Auth screens: register/login.
Expand Down
63 changes: 63 additions & 0 deletions app/src/__tests__/Analytics.integration.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,10 @@ jest.mock('@/hooks/use-toast', () => ({
}));

const getBudgetSuggestionMock = jest.fn();
const getWeeklySummaryMock = jest.fn();
jest.mock('@/api/insights', () => ({
getBudgetSuggestion: (...args: unknown[]) => getBudgetSuggestionMock(...args),
getWeeklySummary: (...args: unknown[]) => getWeeklySummaryMock(...args),
}));

describe('Analytics integration', () => {
Expand All @@ -52,14 +54,64 @@ describe('Analytics integration', () => {
method: 'heuristic',
warnings: [],
});
getWeeklySummaryMock.mockResolvedValue({
period: {
week_start: '2026-05-18',
week_end: '2026-05-24',
previous_week_start: '2026-05-11',
previous_week_end: '2026-05-17',
},
currency: 'USD',
summary: {
income: 1500,
expenses: 420,
net_flow: 1080,
transaction_count: 4,
average_daily_expense: 60,
savings_rate_pct: 72,
},
comparison: {
previous_expenses: 500,
expense_change: -80,
expense_change_pct: -16,
trend: 'down',
},
category_breakdown: [
{
category_id: 1,
category_name: 'Groceries',
amount: 220,
share_pct: 52.38,
previous_amount: 240,
change_pct: -8.33,
transaction_count: 2,
},
],
daily_breakdown: [
{ date: '2026-05-18', day: 'Mon', expenses: 0 },
{ date: '2026-05-19', day: 'Tue', expenses: 220 },
{ date: '2026-05-20', day: 'Wed', expenses: 0 },
{ date: '2026-05-21', day: 'Thu', expenses: 200 },
{ date: '2026-05-22', day: 'Fri', expenses: 0 },
{ date: '2026-05-23', day: 'Sat', expenses: 0 },
{ date: '2026-05-24', day: 'Sun', expenses: 0 },
],
largest_expenses: [],
upcoming_bills: [],
highlights: ['Expenses were lower than last week.'],
insights: ['Net flow stayed positive.'],
recommendations: ['Keep the current weekly pattern.'],
});
});

it('loads and renders insights data', async () => {
render(<Analytics />);
await waitFor(() => expect(getBudgetSuggestionMock).toHaveBeenCalled());
expect(screen.getByText(/live spending analytics/i)).toBeInTheDocument();
expect(await screen.findByText(/weekly smart digest/i)).toBeInTheDocument();
expect(screen.getByText(/suggested budget/i)).toBeInTheDocument();
expect(screen.getByText(/tip a/i)).toBeInTheDocument();
expect(screen.getByText(/keep the current weekly pattern/i)).toBeInTheDocument();
});

it('refreshes insights with month/persona/key controls', async () => {
Expand All @@ -68,6 +120,9 @@ describe('Analytics integration', () => {

await userEvent.clear(screen.getByLabelText(/analytics month/i));
await userEvent.type(screen.getByLabelText(/analytics month/i), '2026-01');
await userEvent.clear(screen.getByLabelText(/analytics week start/i));
await userEvent.type(screen.getByLabelText(/analytics week start/i), '2026-05-18');
await userEvent.type(screen.getByLabelText(/analytics currency/i), 'usd');
await userEvent.selectOptions(screen.getByLabelText(/analytics persona/i), 'Debt-focused planner');
await userEvent.type(screen.getByLabelText(/gemini api key/i), 'abc123');
await userEvent.click(screen.getByRole('button', { name: /refresh insights/i }));
Expand All @@ -81,5 +136,13 @@ describe('Analytics integration', () => {
}),
),
);
await waitFor(() =>
expect(getWeeklySummaryMock).toHaveBeenLastCalledWith(
expect.objectContaining({
weekStart: '2026-05-18',
currency: 'USD',
}),
),
);
});
});
65 changes: 65 additions & 0 deletions app/src/api/insights.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,60 @@ export type BudgetSuggestion = {
net_flow?: number;
};

export type WeeklySummary = {
period: {
week_start: string;
week_end: string;
previous_week_start: string;
previous_week_end: string;
};
currency: string;
summary: {
income: number;
expenses: number;
net_flow: number;
transaction_count: number;
average_daily_expense: number;
savings_rate_pct: number;
};
comparison: {
previous_expenses: number;
expense_change: number;
expense_change_pct: number;
trend: 'up' | 'down' | 'flat' | string;
};
category_breakdown: Array<{
category_id: number | null;
category_name: string;
amount: number;
share_pct: number;
previous_amount: number;
change_pct: number;
transaction_count: number;
}>;
daily_breakdown: Array<{
date: string;
day: string;
expenses: number;
}>;
largest_expenses: Array<{
id: number;
description: string;
amount: number;
date: string;
}>;
upcoming_bills: Array<{
id: number;
name: string;
amount: number;
due_date: string;
autopay_enabled: boolean;
}>;
highlights: string[];
insights: string[];
recommendations: string[];
};

export async function getBudgetSuggestion(params?: {
month?: string;
geminiApiKey?: string;
Expand All @@ -32,3 +86,14 @@ export async function getBudgetSuggestion(params?: {
if (params?.persona) headers['X-Insight-Persona'] = params.persona;
return api<BudgetSuggestion>(`/insights/budget-suggestion${monthQuery}`, { headers });
}

export async function getWeeklySummary(params?: {
weekStart?: string;
currency?: string;
}): Promise<WeeklySummary> {
const query = new URLSearchParams();
if (params?.weekStart) query.set('week_start', params.weekStart);
if (params?.currency) query.set('currency', params.currency);
const suffix = query.toString() ? `?${query.toString()}` : '';
return api<WeeklySummary>(`/insights/weekly-summary${suffix}`);
}
161 changes: 153 additions & 8 deletions app/src/pages/Analytics.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,12 @@ import {
FinancialCardTitle,
} from '@/components/ui/financial-card';
import { useToast } from '@/hooks/use-toast';
import { getBudgetSuggestion, type BudgetSuggestion } from '@/api/insights';
import {
getBudgetSuggestion,
getWeeklySummary,
type BudgetSuggestion,
type WeeklySummary,
} from '@/api/insights';
import { formatMoney } from '@/lib/currency';

const PERSONAS = [
Expand All @@ -19,25 +24,47 @@ const PERSONAS = [
'Debt-focused planner',
];

function dateInputValue(date: Date): string {
const local = new Date(date.getTime() - date.getTimezoneOffset() * 60_000);
return local.toISOString().slice(0, 10);
}

function currentWeekStart(): string {
const today = new Date();
const mondayOffset = (today.getDay() + 6) % 7;
today.setDate(today.getDate() - mondayOffset);
return dateInputValue(today);
}

export function Analytics() {
const { toast } = useToast();
const [month, setMonth] = useState(() => new Date().toISOString().slice(0, 7));
const [weekStart, setWeekStart] = useState(currentWeekStart);
const [currency, setCurrency] = useState('');
const [persona, setPersona] = useState(PERSONAS[0]);
const [geminiKey, setGeminiKey] = useState('');
const [loading, setLoading] = useState(true);
const [data, setData] = useState<BudgetSuggestion | null>(null);
const [weeklyData, setWeeklyData] = useState<WeeklySummary | null>(null);
const [error, setError] = useState<string | null>(null);

async function load() {
setLoading(true);
setError(null);
try {
const payload = await getBudgetSuggestion({
month,
persona,
geminiApiKey: geminiKey.trim() || undefined,
});
setData(payload);
const [monthlyPayload, weeklyPayload] = await Promise.all([
getBudgetSuggestion({
month,
persona,
geminiApiKey: geminiKey.trim() || undefined,
}),
getWeeklySummary({
weekStart,
currency: currency.trim() || undefined,
}),
]);
setData(monthlyPayload);
setWeeklyData(weeklyPayload);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Failed to load insights';
setError(message);
Expand Down Expand Up @@ -71,7 +98,7 @@ export function Analytics() {
Live spending analytics with Gemini-powered budget coaching.
</p>
</div>
<div className="grid gap-2 md:grid-cols-4">
<div className="grid gap-2 md:grid-cols-6">
<div>
<Label htmlFor="analytics-month">Month</Label>
<Input
Expand All @@ -82,6 +109,27 @@ export function Analytics() {
onChange={(e) => setMonth(e.target.value)}
/>
</div>
<div>
<Label htmlFor="analytics-week">Week Start</Label>
<Input
id="analytics-week"
aria-label="analytics week start"
type="date"
value={weekStart}
onChange={(e) => setWeekStart(e.target.value)}
/>
</div>
<div>
<Label htmlFor="analytics-currency">Currency</Label>
<Input
id="analytics-currency"
aria-label="analytics currency"
value={currency}
onChange={(e) => setCurrency(e.target.value.toUpperCase())}
placeholder="Default"
maxLength={10}
/>
</div>
<div>
<Label htmlFor="analytics-persona">Persona</Label>
<select
Expand Down Expand Up @@ -122,6 +170,103 @@ export function Analytics() {
<div className="card text-red-600">{error}</div>
) : data ? (
<div className="space-y-6">
<FinancialCard variant="financial">
<FinancialCardHeader>
<FinancialCardTitle>Weekly Smart Digest</FinancialCardTitle>
<FinancialCardDescription>
{weeklyData
? `${weeklyData.period.week_start} to ${weeklyData.period.week_end}`
: 'Current week'}
</FinancialCardDescription>
</FinancialCardHeader>
<FinancialCardContent>
<div className="grid gap-3 md:grid-cols-4">
<div className="rounded-lg border p-3">
<div className="text-sm text-muted-foreground">Weekly Expenses</div>
<div className="font-semibold">
{formatMoney(weeklyData?.summary.expenses || 0, weeklyData?.currency)}
</div>
</div>
<div className="rounded-lg border p-3">
<div className="text-sm text-muted-foreground">Net Flow</div>
<div className="font-semibold">
{formatMoney(weeklyData?.summary.net_flow || 0, weeklyData?.currency)}
</div>
</div>
<div className="rounded-lg border p-3">
<div className="text-sm text-muted-foreground">Week Over Week</div>
<div className="font-semibold">
{weeklyData?.comparison.expense_change_pct.toFixed(2) || '0.00'}%
</div>
</div>
<div className="rounded-lg border p-3">
<div className="text-sm text-muted-foreground">Bills Due</div>
<div className="font-semibold">{weeklyData?.upcoming_bills.length || 0}</div>
</div>
</div>

{weeklyData ? (
<div className="mt-4 grid gap-4 lg:grid-cols-3">
<div className="rounded-lg border p-3">
<div className="text-sm font-semibold">Daily Spend</div>
<div className="mt-3 space-y-2">
{weeklyData.daily_breakdown.map((day) => {
const max = Math.max(
...weeklyData.daily_breakdown.map((item) => item.expenses),
1,
);
return (
<div key={day.date} className="space-y-1">
<div className="flex justify-between text-xs">
<span>{day.day}</span>
<span>{formatMoney(day.expenses, weeklyData.currency)}</span>
</div>
<div className="h-2 rounded-full bg-muted">
<div
className="h-2 rounded-full bg-primary"
style={{ width: `${Math.round((day.expenses / max) * 100)}%` }}
/>
</div>
</div>
);
})}
</div>
</div>

<div className="rounded-lg border p-3">
<div className="text-sm font-semibold">Top Categories</div>
<div className="mt-3 space-y-2">
{weeklyData.category_breakdown.length ? (
weeklyData.category_breakdown.slice(0, 4).map((category) => (
<div key={`${category.category_id}-${category.category_name}`}>
<div className="flex justify-between text-xs">
<span>{category.category_name}</span>
<span>{category.share_pct.toFixed(1)}%</span>
</div>
<div className="text-sm font-medium">
{formatMoney(category.amount, weeklyData.currency)}
</div>
</div>
))
) : (
<div className="text-sm text-muted-foreground">No category spend yet.</div>
)}
</div>
</div>

<div className="rounded-lg border p-3">
<div className="text-sm font-semibold">Recommended Actions</div>
<ul className="mt-3 list-disc pl-5 text-sm space-y-1">
{weeklyData.recommendations.map((item) => (
<li key={item}>{item}</li>
))}
</ul>
</div>
</div>
) : null}
</FinancialCardContent>
</FinancialCard>

<div className="grid gap-4 md:grid-cols-4">
<FinancialCard variant="financial">
<FinancialCardHeader className="pb-2">
Expand Down
Loading