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
30 changes: 29 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,9 @@ See `backend/app/db/schema.sql`. Key tables:
- `user:{id}:categories` — 24h TTL
- `user:{id}:upcoming_bills` — 15 min TTL
- `insights:{id}` — 24h TTL (invalidate on new expense/bill)
- `user:{id}:weekly_digest:{yyyy-mm-dd}` — 1h TTL (invalidate on expense changes)
- Invalidation
- On expense/bill create/update/delete -> delete affected monthly_summary, upcoming_bills, insights
- On expense/bill create/update/delete -> delete affected monthly_summary, upcoming_bills, insights, weekly_digest
- Rate limiting (optional): `rl:{userId}:{endpoint}:{minute}` with short TTL

## API Endpoints
Expand All @@ -66,6 +67,32 @@ OpenAPI: `backend/app/openapi.yaml`
- Bills: CRUD `/bills`, pay/mark `/bills/{id}/pay`
- Reminders: CRUD `/reminders`, trigger `/reminders/run`
- Insights: `/insights/monthly`, `/insights/budget-suggestion`
- Digest: `/digest` (weekly summary), `/digest/weeks` (available weeks)

## Weekly Digest Feature
The Smart Digest provides weekly financial summaries with trends and insights:

### Backend (`/digest`)
- **`GET /digest?week_start=YYYY-MM-DD`** — Returns a comprehensive weekly summary including:
- Period info (week start/end dates)
- Summary totals (income, expenses, net flow, transaction count)
- Week-over-week comparison (percentage change vs. previous week)
- Category breakdown with share percentages
- Daily spending breakdown (all 7 days, zero-filled)
- Top 5 largest transactions
- Auto-generated insights (spending trends, savings, peak days, top categories)
- **`GET /digest/weeks?count=N`** — Lists weeks that have expense data for navigation
- Results are cached in Redis (1h TTL) and auto-invalidated on expense changes
- Service layer in `packages/backend/app/services/digest.py`

### Frontend (`/digest`)
- Full-page digest view accessible via the "Digest" nav link
- Week navigation with prev/next controls
- Summary cards for net flow, income, expenses, and week comparison
- Visual daily spending bar chart
- Top transactions list
- Category breakdown with progress bars
- Numbered insights panel

## MVP UI/UX Plan
- Auth screens: register/login.
Expand Down Expand Up @@ -108,6 +135,7 @@ finmind/
__init__.py
ai.py
cache.py
digest.py
reminders.py
db/
schema.sql
Expand Down
9 changes: 9 additions & 0 deletions app/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import NotFound from "./pages/NotFound";
import { Landing } from "./pages/Landing";
import ProtectedRoute from "./components/auth/ProtectedRoute";
import Account from "./pages/Account";
import Digest from "./pages/Digest";

const queryClient = new QueryClient({
defaultOptions: {
Expand Down Expand Up @@ -83,6 +84,14 @@ const App = () => (
</ProtectedRoute>
}
/>
<Route
path="digest"
element={
<ProtectedRoute>
<Digest />
</ProtectedRoute>
}
/>
<Route
path="account"
element={
Expand Down
131 changes: 131 additions & 0 deletions app/src/__tests__/Digest.integration.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import { render, screen, waitFor } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import Digest from '../pages/Digest';
import * as digestApi from '../api/digest';

// Mock the API
jest.mock('../api/digest');

const mockDigest: digestApi.WeeklyDigest = {
period: {
week_start: '2026-03-16',
week_end: '2026-03-22',
},
summary: {
total_income: 3000,
total_expenses: 850,
net_flow: 2150,
transaction_count: 7,
},
comparison: {
prev_week_expenses: 700,
prev_week_income: 3000,
week_over_week_change_pct: 21.43,
},
category_breakdown: [
{ category_id: 1, category_name: 'Food', amount: 400, share_pct: 47.06 },
{ category_id: 2, category_name: 'Transport', amount: 250, share_pct: 29.41 },
{ category_id: null, category_name: 'Uncategorized', amount: 200, share_pct: 23.53 },
],
daily_spending: [
{ date: '2026-03-16', amount: 120 },
{ date: '2026-03-17', amount: 200 },
{ date: '2026-03-18', amount: 0 },
{ date: '2026-03-19', amount: 150 },
{ date: '2026-03-20', amount: 80 },
{ date: '2026-03-21', amount: 300 },
{ date: '2026-03-22', amount: 0 },
],
top_transactions: [
{ id: 1, amount: 300, description: 'Restaurant dinner', date: '2026-03-21', category_id: 1 },
{ id: 2, amount: 200, description: 'Uber rides', date: '2026-03-17', category_id: 2 },
],
insights: [
'Spending is up 21.4% compared to the previous week.',
'You saved 2,150.00 this week (income exceeded expenses).',
'Your biggest spending category was Food at 400.00 (47% of total).',
],
};

const mockWeeks: digestApi.DigestWeek[] = [
{ week_start: '2026-03-16', week_end: '2026-03-22' },
{ week_start: '2026-03-09', week_end: '2026-03-15' },
];

function renderWithProviders(ui: React.ReactElement) {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return render(
<QueryClientProvider client={queryClient}>
<MemoryRouter>
{ui}
</MemoryRouter>
</QueryClientProvider>,
);
}

describe('Digest page', () => {
beforeEach(() => {
jest.clearAllMocks();
(digestApi.getAvailableWeeks as jest.Mock).mockResolvedValue(mockWeeks);
(digestApi.getWeeklyDigest as jest.Mock).mockResolvedValue(mockDigest);
});

it('renders the page title', async () => {
renderWithProviders(<Digest />);
expect(screen.getByText('Weekly Digest')).toBeInTheDocument();
});

it('displays summary cards after loading', async () => {
renderWithProviders(<Digest />);
await waitFor(() => {
expect(screen.getByText('Net Flow')).toBeInTheDocument();
});
expect(screen.getByText('Income')).toBeInTheDocument();
expect(screen.getByText('Expenses')).toBeInTheDocument();
expect(screen.getByText('Prev Week')).toBeInTheDocument();
});

it('displays insights', async () => {
renderWithProviders(<Digest />);
await waitFor(() => {
expect(screen.getByText('Insights')).toBeInTheDocument();
});
expect(screen.getByText(/Spending is up 21.4%/)).toBeInTheDocument();
expect(screen.getByText(/saved/i)).toBeInTheDocument();
});

it('displays category breakdown', async () => {
renderWithProviders(<Digest />);
await waitFor(() => {
expect(screen.getByText('Food')).toBeInTheDocument();
});
expect(screen.getByText('Transport')).toBeInTheDocument();
});

it('displays top transactions', async () => {
renderWithProviders(<Digest />);
await waitFor(() => {
expect(screen.getByText('Restaurant dinner')).toBeInTheDocument();
});
expect(screen.getByText('Uber rides')).toBeInTheDocument();
});

it('shows week navigation controls', async () => {
renderWithProviders(<Digest />);
await waitFor(() => {
expect(screen.getByLabelText('Older week')).toBeInTheDocument();
});
expect(screen.getByLabelText('Newer week')).toBeInTheDocument();
});

it('handles API error gracefully', async () => {
(digestApi.getWeeklyDigest as jest.Mock).mockRejectedValue(new Error('Network error'));
renderWithProviders(<Digest />);
await waitFor(() => {
expect(screen.getByText(/Network error/)).toBeInTheDocument();
});
});
});
52 changes: 52 additions & 0 deletions app/src/api/digest.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { api } from './client';

export type WeeklyDigest = {
period: {
week_start: string;
week_end: string;
};
summary: {
total_income: number;
total_expenses: number;
net_flow: number;
transaction_count: number;
};
comparison: {
prev_week_expenses: number;
prev_week_income: number;
week_over_week_change_pct: number;
};
category_breakdown: Array<{
category_id: number | null;
category_name: string;
amount: number;
share_pct: number;
}>;
daily_spending: Array<{
date: string;
amount: number;
}>;
top_transactions: Array<{
id: number;
amount: number;
description: string;
date: string;
category_id: number | null;
}>;
insights: string[];
};

export type DigestWeek = {
week_start: string;
week_end: string;
};

export async function getWeeklyDigest(weekStart?: string): Promise<WeeklyDigest> {
const query = weekStart ? `?week_start=${encodeURIComponent(weekStart)}` : '';
return api<WeeklyDigest>(`/digest${query}`);
}

export async function getAvailableWeeks(count?: number): Promise<DigestWeek[]> {
const query = count ? `?count=${encodeURIComponent(count)}` : '';
return api<DigestWeek[]>(`/digest/weeks${query}`);
}
1 change: 1 addition & 0 deletions app/src/components/layout/Navbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ const navigation = [
{ name: 'Reminders', href: '/reminders' },
{ name: 'Expenses', href: '/expenses' },
{ name: 'Analytics', href: '/analytics' },
{ name: 'Digest', href: '/digest' },
];

export function Navbar() {
Expand Down
Loading