From 25b8fe9dca60753aaa7d2504db46c471c4339d1b Mon Sep 17 00:00:00 2001 From: Jorel97 Date: Fri, 29 May 2026 20:01:02 -0600 Subject: [PATCH] feat: add weekly financial summary digest --- README.md | 12 +- .../__tests__/Analytics.integration.test.tsx | 44 ++- app/src/api/insights.ts | 67 ++++ app/src/pages/Analytics.tsx | 141 +++++++- packages/backend/app/openapi.yaml | 151 +++++++++ packages/backend/app/routes/insights.py | 20 ++ .../backend/app/services/weekly_summary.py | 305 ++++++++++++++++++ packages/backend/tests/conftest.py | 56 +++- packages/backend/tests/test_weekly_summary.py | 116 +++++++ 9 files changed, 893 insertions(+), 19 deletions(-) create mode 100644 packages/backend/app/services/weekly_summary.py create mode 100644 packages/backend/tests/test_weekly_summary.py diff --git a/README.md b/README.md index 49592bffc..871148f6b 100644 --- a/README.md +++ b/README.md @@ -65,7 +65,17 @@ 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?week_start=YYYY-MM-DD¤cy=USD` + +### Weekly Financial Summary + +`GET /insights/weekly-summary` returns an authenticated smart digest for the +selected ISO week. It normalizes any `week_start` date to Monday, compares +expenses with the previous week, groups spending by category, returns seven +daily buckets, highlights the largest expenses, includes bills due that week, +and emits deterministic insights plus recommendations. Omit `week_start` for +the current week and omit `currency` to include all currencies. ## 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..e444f63f6 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,13 +54,50 @@ describe('Analytics integration', () => { method: 'heuristic', warnings: [], }); + getWeeklySummaryMock.mockResolvedValue({ + period: { + week_start: '2026-05-25', + week_end: '2026-05-31', + previous_week_start: '2026-05-18', + previous_week_end: '2026-05-24', + }, + currency: 'USD', + summary: { + income: 800, + expenses: 200, + net_flow: 600, + transaction_count: 3, + expense_count: 2, + income_count: 1, + average_daily_expense: 28.57, + }, + comparison: { + previous_expenses: 50, + expense_delta: 150, + expense_delta_pct: 300, + expense_trend: 'up', + }, + category_breakdown: [ + { category_id: 1, category_name: 'Dining', amount: 120, share_pct: 60 }, + ], + daily_breakdown: [ + { date: '2026-05-25', income: 800, expenses: 0, net_flow: 800 }, + ], + top_expenses: [], + upcoming_bills: [], + insights: ['Dining was the largest spending category.'], + recommendations: ['Set a temporary cap for Dining next week.'], + method: 'deterministic', + }); }); it('loads and renders insights data', async () => { render(); await waitFor(() => expect(getBudgetSuggestionMock).toHaveBeenCalled()); expect(screen.getByText(/live spending analytics/i)).toBeInTheDocument(); - expect(screen.getByText(/suggested budget/i)).toBeInTheDocument(); + expect(await screen.findByText(/suggested budget/i)).toBeInTheDocument(); + expect(screen.getByText(/weekly digest/i)).toBeInTheDocument(); + expect(screen.getByText(/dining was the largest spending category/i)).toBeInTheDocument(); expect(screen.getByText(/tip a/i)).toBeInTheDocument(); }); @@ -68,6 +107,8 @@ 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/i)); + await userEvent.type(screen.getByLabelText(/analytics week/i), '2026-01-05'); 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 +122,6 @@ describe('Analytics integration', () => { }), ), ); + expect(getWeeklySummaryMock).toHaveBeenLastCalledWith({ weekStart: '2026-01-05' }); }); }); diff --git a/app/src/api/insights.ts b/app/src/api/insights.ts index 031d1e531..b0dcbbb91 100644 --- a/app/src/api/insights.ts +++ b/app/src/api/insights.ts @@ -21,6 +21,62 @@ 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; + expense_count: number; + income_count: number; + average_daily_expense: number; + }; + comparison: { + previous_expenses: number; + expense_delta: number; + expense_delta_pct: number | null; + expense_trend: 'up' | 'down' | 'flat' | string; + }; + category_breakdown: Array<{ + category_id: number | null; + category_name: string; + amount: number; + share_pct: number; + }>; + daily_breakdown: Array<{ + date: string; + income: number; + expenses: number; + net_flow: number; + }>; + top_expenses: Array<{ + id: number; + description: string; + amount: number; + currency: string; + date: string; + category_id: number | null; + }>; + upcoming_bills: Array<{ + id: number; + name: string; + amount: number; + currency: string; + next_due_date: string; + cadence: string; + }>; + insights: string[]; + recommendations: string[]; + method: 'deterministic' | string; +}; + export async function getBudgetSuggestion(params?: { month?: string; geminiApiKey?: string; @@ -32,3 +88,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 search = new URLSearchParams(); + if (params?.weekStart) search.set('week_start', params.weekStart); + if (params?.currency) search.set('currency', params.currency); + const query = search.toString(); + return api(`/insights/weekly-summary${query ? `?${query}` : ''}`); +} diff --git a/app/src/pages/Analytics.tsx b/app/src/pages/Analytics.tsx index 3efc8acc6..d3ade247a 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 = [ @@ -22,22 +27,28 @@ const PERSONAS = [ export function Analytics() { const { toast } = useToast(); const [month, setMonth] = useState(() => new Date().toISOString().slice(0, 7)); + const [weekStart, setWeekStart] = useState(() => new Date().toISOString().slice(0, 10)); 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 [budgetPayload, weeklyPayload] = await Promise.all([ + getBudgetSuggestion({ + month, + persona, + geminiApiKey: geminiKey.trim() || undefined, + }), + getWeeklySummary({ weekStart }), + ]); + setData(budgetPayload); + setWeeklyData(weeklyPayload); } catch (err: unknown) { const message = err instanceof Error ? err.message : 'Failed to load insights'; setError(message); @@ -61,6 +72,10 @@ export function Analytics() { ]; }, [data]); + const trendLabel = weeklyData?.comparison.expense_delta_pct == null + ? 'New baseline' + : `${weeklyData.comparison.expense_delta_pct.toFixed(2)}%`; + return (
@@ -71,7 +86,7 @@ export function Analytics() { Live spending analytics with Gemini-powered budget coaching.

-
+
setMonth(e.target.value)} />
+
+ + setWeekStart(e.target.value)} + /> +