diff --git a/README.md b/README.md index 49592bffc..65d68434a 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/app/src/__tests__/Analytics.integration.test.tsx b/app/src/__tests__/Analytics.integration.test.tsx index a09127e6e..a961f53e4 100644 --- a/app/src/__tests__/Analytics.integration.test.tsx +++ b/app/src/__tests__/Analytics.integration.test.tsx @@ -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', () => { @@ -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(); 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 () => { @@ -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 })); @@ -81,5 +136,13 @@ describe('Analytics integration', () => { }), ), ); + await waitFor(() => + expect(getWeeklySummaryMock).toHaveBeenLastCalledWith( + expect.objectContaining({ + weekStart: '2026-05-18', + currency: 'USD', + }), + ), + ); }); }); diff --git a/app/src/api/insights.ts b/app/src/api/insights.ts index 031d1e531..45edfda60 100644 --- a/app/src/api/insights.ts +++ b/app/src/api/insights.ts @@ -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; @@ -32,3 +86,14 @@ export async function getBudgetSuggestion(params?: { if (params?.persona) headers['X-Insight-Persona'] = params.persona; return api(`/insights/budget-suggestion${monthQuery}`, { headers }); } + +export async function getWeeklySummary(params?: { + weekStart?: string; + currency?: string; +}): Promise { + 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(`/insights/weekly-summary${suffix}`); +} diff --git a/app/src/pages/Analytics.tsx b/app/src/pages/Analytics.tsx index 3efc8acc6..9a4230f81 100644 --- a/app/src/pages/Analytics.tsx +++ b/app/src/pages/Analytics.tsx @@ -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 = [ @@ -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(null); + const [weeklyData, setWeeklyData] = useState(null); const [error, setError] = useState(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); @@ -71,7 +98,7 @@ export function Analytics() { Live spending analytics with Gemini-powered budget coaching.

-
+
setMonth(e.target.value)} />
+
+ + setWeekStart(e.target.value)} + /> +
+
+ + setCurrency(e.target.value.toUpperCase())} + placeholder="Default" + maxLength={10} + /> +