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
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,27 @@ 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`
- Weekly Digest: `/weekly-summary` — smart weekly financial summary (see below)

## Weekly Digest (Smart Summary)

The **Weekly Digest** provides an at-a-glance financial summary for any given week:

- **Totals**: income, expenses, net flow, and transaction count for the week
- **Daily breakdown**: spending by day with visual bar chart
- **Category breakdown**: where your money went, with percentage shares
- **Top expenses**: the 5 largest purchases of the week
- **Upcoming bills**: bills due within the current and following week
- **Week-over-week trends**: percentage change in income and expenses vs. the previous week

### Backend
- `GET /weekly-summary?week_of=YYYY-MM-DD` — returns the digest for the week containing the given date (defaults to current week)
- Authenticated via JWT; results are cached (1 hour) for completed weeks via Redis

### Frontend
- Navigate to `/digest` or click **Weekly Digest** in the navbar
- Use arrow buttons to browse previous weeks
- Cards, charts, and lists render the digest data

## MVP UI/UX Plan
- Auth screens: register/login.
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 WeeklyDigest from "./pages/WeeklyDigest";

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

const mockSummary = {
week: { start: '2026-03-02', end: '2026-03-08' },
totals: { income: 5000, expenses: 1200, net: 3800, transaction_count: 12 },
daily_breakdown: [
{ date: '2026-03-02', income: 0, expenses: 100 },
{ date: '2026-03-03', income: 5000, expenses: 200 },
{ date: '2026-03-04', income: 0, expenses: 300 },
{ date: '2026-03-05', income: 0, expenses: 150 },
{ date: '2026-03-06', income: 0, expenses: 250 },
{ date: '2026-03-07', income: 0, expenses: 100 },
{ date: '2026-03-08', income: 0, expenses: 100 },
],
category_breakdown: [
{ category_id: 1, category_name: 'Food', amount: 600, count: 5, share_pct: 50 },
{ category_id: 2, category_name: 'Transport', amount: 600, count: 7, share_pct: 50 },
],
top_expenses: [
{ id: 1, description: 'Big Purchase', amount: 300, date: '2026-03-04', category_id: 1, currency: 'INR' },
],
upcoming_bills: [
{ id: 1, name: 'Internet', amount: 999, currency: 'INR', next_due_date: '2026-03-10', cadence: 'MONTHLY' },
],
trends: {
expense_change_pct: -15.2,
income_change_pct: 10.0,
previous_week_expenses: 1414,
previous_week_income: 4545,
},
};

vi.mock('../api/weekly-summary', () => ({
getWeeklySummary: vi.fn(() => Promise.resolve(mockSummary)),
}));

function renderPage() {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={qc}>
<BrowserRouter>
<WeeklyDigest />
</BrowserRouter>
</QueryClientProvider>,
);
}

describe('WeeklyDigest page', () => {
beforeEach(() => {
vi.clearAllMocks();
});

it('renders the page title', async () => {
renderPage();
await waitFor(() => {
expect(screen.getByText('Weekly Digest')).toBeTruthy();
});
});

it('shows summary cards with correct data', async () => {
renderPage();
await waitFor(() => {
expect(screen.getByText('Weekly Income')).toBeTruthy();
expect(screen.getByText('Weekly Expenses')).toBeTruthy();
expect(screen.getByText('Net Flow')).toBeTruthy();
expect(screen.getByText('Transactions')).toBeTruthy();
});
});

it('shows top expenses', async () => {
renderPage();
await waitFor(() => {
expect(screen.getByText('Big Purchase')).toBeTruthy();
});
});

it('shows upcoming bills', async () => {
renderPage();
await waitFor(() => {
expect(screen.getByText('Internet')).toBeTruthy();
});
});

it('shows category breakdown', async () => {
renderPage();
await waitFor(() => {
expect(screen.getByText('Food')).toBeTruthy();
expect(screen.getByText('Transport')).toBeTruthy();
});
});

it('shows week navigation buttons', () => {
renderPage();
expect(screen.getByText('This Week')).toBeTruthy();
});
});
25 changes: 25 additions & 0 deletions app/src/api/gdpr.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { baseURL } from './client';
import { getToken } from '../lib/auth';

/**
* Download user data export as a ZIP file.
*/
export async function exportUserData(): Promise<Blob> {
const res = await fetch(`${baseURL}/user/export`, {
headers: { Authorization: `Bearer ${getToken()}` },
});
if (!res.ok) throw new Error('Export failed');
return res.blob();
}

/**
* Permanently delete the authenticated user's account.
*/
export async function deleteAccount(): Promise<{ message: string }> {
const res = await fetch(`${baseURL}/user`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${getToken()}` },
});
if (!res.ok) throw new Error('Account deletion failed');
return res.json();
}
62 changes: 62 additions & 0 deletions app/src/api/weekly-summary.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { api } from './client';

export type WeeklyTotals = {
income: number;
expenses: number;
net: number;
transaction_count: number;
};

export type DailyBreakdown = {
date: string;
income: number;
expenses: number;
};

export type CategoryBreakdown = {
category_id: number | null;
category_name: string;
amount: number;
count: number;
share_pct: number;
};

export type TopExpense = {
id: number;
description: string;
amount: number;
date: string;
category_id: number | null;
currency: string;
};

export type UpcomingBill = {
id: number;
name: string;
amount: number;
currency: string;
next_due_date: string;
cadence: string;
};

export type WeeklyTrends = {
expense_change_pct: number | null;
income_change_pct: number | null;
previous_week_expenses: number;
previous_week_income: number;
};

export type WeeklySummary = {
week: { start: string; end: string };
totals: WeeklyTotals;
daily_breakdown: DailyBreakdown[];
category_breakdown: CategoryBreakdown[];
top_expenses: TopExpense[];
upcoming_bills: UpcomingBill[];
trends: WeeklyTrends;
};

export async function getWeeklySummary(weekOf?: string): Promise<WeeklySummary> {
const query = weekOf ? `?week_of=${encodeURIComponent(weekOf)}` : '';
return api<WeeklySummary>(`/weekly-summary${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: 'Weekly Digest', href: '/digest' },
];

export function Navbar() {
Expand Down
97 changes: 96 additions & 1 deletion app/src/pages/Account.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,22 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { useToast } from '@/hooks/use-toast';
import { me, updateMe } from '@/api/auth';
import { setCurrency } from '@/lib/auth';
import { setCurrency, clearToken, clearRefreshToken } from '@/lib/auth';
import { exportUserData, deleteAccount } from '@/api/gdpr';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dailog';

const SUPPORTED_CURRENCIES = [
{ code: 'INR', label: 'Indian Rupee (INR)' },
Expand All @@ -19,10 +32,13 @@ const SUPPORTED_CURRENCIES = [

export default function Account() {
const { toast } = useToast();
const navigate = useNavigate();
const [email, setEmail] = useState('');
const [currency, setCurrencyState] = useState('INR');
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [exporting, setExporting] = useState(false);
const [deleting, setDeleting] = useState(false);

useEffect(() => {
const load = async () => {
Expand Down Expand Up @@ -108,6 +124,85 @@ export default function Account() {
</>
)}
</div>

{/* GDPR: Data Export & Account Deletion */}
<div className="card card-interactive space-y-5 fade-in-up">
<h2 className="text-lg font-semibold">Your Data (GDPR)</h2>
<p className="text-sm text-muted-foreground">
Export all your personal data as a downloadable ZIP, or permanently
delete your account and all associated data.
</p>

<div className="flex flex-wrap gap-3">
<Button
variant="outline"
disabled={exporting}
onClick={async () => {
setExporting(true);
try {
const blob = await exportUserData();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'finmind_export.zip';
a.click();
URL.revokeObjectURL(url);
toast({ title: 'Export ready', description: 'Your data has been downloaded.' });
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : 'Export failed';
toast({ title: 'Export failed', description: msg });
} finally {
setExporting(false);
}
}}
>
{exporting ? 'Exporting...' : '📦 Export My Data'}
</Button>

<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="destructive" disabled={deleting}>
{deleting ? 'Deleting...' : '🗑️ Delete Account'}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
<AlertDialogDescription>
This action is <strong>permanent and irreversible</strong>. All
your data — expenses, bills, categories, reminders, and
settings — will be permanently deleted.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={async () => {
setDeleting(true);
try {
await deleteAccount();
clearToken();
clearRefreshToken();
toast({
title: 'Account deleted',
description: 'Your account and all data have been permanently removed.',
});
navigate('/');
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : 'Deletion failed';
toast({ title: 'Deletion failed', description: msg });
} finally {
setDeleting(false);
}
}}
>
Yes, delete everything
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</div>
</div>
);
}
Loading
Loading