Skip to content
This repository was archived by the owner on Jun 19, 2026. It is now read-only.
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: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ 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`
- Savings Goals: CRUD `/savings/goals`, contributions `/savings/contributions`, milestones `/savings/milestones`

## MVP UI/UX Plan
- Auth screens: register/login.
Expand All @@ -75,6 +76,7 @@ OpenAPI: `backend/app/openapi.yaml`
- AI budget suggestion card.
- Expenses page: add expense (amount, category, notes, date), list & filter.
- Bills page: create bill (name, amount, cadence, due date, channel), toggle WhatsApp/email.
- Savings page: create savings goals with target amounts and deadlines, track contributions, set milestones.
- Settings: profile, categories, reminders default channel, export (premium).

## Monetization Plan
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 { Savings } from "./pages/Savings";

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

// Mock the savings API
vi.mock('../api/savings', () => ({
listSavingsGoals: vi.fn(),
createSavingsGoal: vi.fn(),
updateSavingsGoal: vi.fn(),
deleteSavingsGoal: vi.fn(),
addSavingsContribution: vi.fn(),
listSavingsMilestones: vi.fn(),
createSavingsMilestone: vi.fn(),
updateSavingsMilestone: vi.fn(),
}));

const mockGoals = [

Check failure on line 19 in app/src/__tests__/Savings.integration.test.tsx

View workflow job for this annotation

GitHub Actions / frontend

'mockGoals' is assigned a value but never used
{
id: 1,
title: 'Emergency Fund',
target_amount: 10000,
current_amount: 7250,
deadline: '2025-12-31',
monthly_target: 458,
status: 'on-track' as const,
created_at: '2025-01-01T00:00:00Z',
updated_at: '2025-04-15T00:00:00Z',
},
{
id: 2,
title: 'Vacation Fund',
target_amount: 3000,
current_amount: 1850,
deadline: '2025-06-30',
monthly_target: 383,
status: 'behind' as const,
created_at: '2025-01-01T00:00:00Z',
updated_at: '2025-04-15T00:00:00Z',
},
];

const mockMilestones: Record<number, any[]> = {

Check failure on line 44 in app/src/__tests__/Savings.integration.test.tsx

View workflow job for this annotation

GitHub Actions / frontend

'mockMilestones' is assigned a value but never used
1: [
{ id: 1, goal_id: 1, title: '$2,500 saved', target_amount: 2500, achieved: true, achieved_at: '2025-02-15T00:00:00Z', created_at: '2025-01-01T00:00:00Z' },
{ id: 2, goal_id: 1, title: '$5,000 saved', target_amount: 5000, achieved: true, achieved_at: '2025-03-20T00:00:00Z', created_at: '2025-01-01T00:00:00Z' },
],
2: [
{ id: 3, goal_id: 2, title: '$1,000 saved', target_amount: 1000, achieved: true, achieved_at: '2025-02-28T00:00:00Z', created_at: '2025-01-01T00:00:00Z' },
],
};

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

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

it('renders the savings page title', async () => {
const { listSavingsGoals, listSavingsMilestones } = await import('../api/savings');
vi.mocked(listSavingsGoals).mockRejectedValueOnce(new Error('API not available'));
vi.mocked(listSavingsMilestones).mockRejectedValue(new Error('API not available'));

renderWithProviders(<Savings />);
expect(screen.getByText('Savings Goals')).toBeTruthy();
});

it('displays overview cards with correct totals', async () => {
const { listSavingsGoals, listSavingsMilestones } = await import('../api/savings');
vi.mocked(listSavingsGoals).mockRejectedValueOnce(new Error('API not available'));
vi.mocked(listSavingsMilestones).mockRejectedValue(new Error('API not available'));

renderWithProviders(<Savings />);

await waitFor(() => {
expect(screen.getByText(/\$9,100/)).toBeTruthy(); // Total saved
});
expect(screen.getByText(/\$13,000/)).toBeTruthy(); // Total target
});

it('renders goal cards with progress bars', async () => {
const { listSavingsGoals, listSavingsMilestones } = await import('../api/savings');
vi.mocked(listSavingsGoals).mockRejectedValueOnce(new Error('API not available'));
vi.mocked(listSavingsMilestones).mockRejectedValue(new Error('API not available'));

renderWithProviders(<Savings />);

await waitFor(() => {
expect(screen.getByText('Emergency Fund')).toBeTruthy();
expect(screen.getByText('Vacation Fund')).toBeTruthy();
});
});

it('shows milestones section when goals have milestones', async () => {
const { listSavingsGoals, listSavingsMilestones } = await import('../api/savings');
vi.mocked(listSavingsGoals).mockRejectedValueOnce(new Error('API not available'));
vi.mocked(listSavingsMilestones).mockRejectedValue(new Error('API not available'));

renderWithProviders(<Savings />);

await waitFor(() => {
expect(screen.getByText('Milestones')).toBeTruthy();
});
});

it('opens create goal dialog when clicking New Goal button', async () => {
const { listSavingsGoals, listSavingsMilestones } = await import('../api/savings');
vi.mocked(listSavingsGoals).mockRejectedValueOnce(new Error('API not available'));
vi.mocked(listSavingsMilestones).mockRejectedValue(new Error('API not available'));

renderWithProviders(<Savings />);

const newGoalButton = screen.getByRole('button', { name: /New Goal/i });
fireEvent.click(newGoalButton);

await waitFor(() => {
expect(screen.getByText('Create Savings Goal')).toBeTruthy();
});
});

it('displays empty state when no goals exist', async () => {
const { listSavingsGoals, listSavingsMilestones } = await import('../api/savings');
vi.mocked(listSavingsGoals).mockResolvedValue([]);
vi.mocked(listSavingsMilestones).mockResolvedValue([]);

renderWithProviders(<Savings />);

await waitFor(() => {
expect(screen.getByText('No savings goals yet')).toBeTruthy();
});
});

it('displays goal status badges correctly', async () => {
const { listSavingsGoals, listSavingsMilestones } = await import('../api/savings');
vi.mocked(listSavingsGoals).mockRejectedValueOnce(new Error('API not available'));
vi.mocked(listSavingsMilestones).mockRejectedValue(new Error('API not available'));

renderWithProviders(<Savings />);

await waitFor(() => {
expect(screen.getByText('On Track')).toBeTruthy();
expect(screen.getByText('Behind')).toBeTruthy();
});
});
});
104 changes: 104 additions & 0 deletions app/src/api/savings.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { api } from './client';

export type SavingsGoal = {
id: number;
title: string;
target_amount: number;
current_amount: number;
deadline: string; // ISO date
monthly_target: number;
status: 'on-track' | 'ahead' | 'behind' | 'completed';
created_at: string;
updated_at: string;
};

export type SavingsGoalCreate = {
title: string;
target_amount: number;
deadline: string; // ISO date
current_amount?: number;
};

export type SavingsGoalUpdate = Partial<SavingsGoalCreate> & {
status?: 'on-track' | 'ahead' | 'behind' | 'completed';
};

export type SavingsContribution = {
id: number;
goal_id: number;
amount: number;
date: string;
note?: string;
created_at: string;
};

export type SavingsContributionCreate = {
goal_id: number;
amount: number;
date: string;
note?: string;
};

export type SavingsMilestone = {
id: number;
goal_id: number;
title: string;
target_amount: number;
achieved: boolean;
achieved_at?: string;
created_at: string;
};

export type SavingsMilestoneCreate = {
goal_id: number;
title: string;
target_amount: number;
};

export type SavingsMilestoneUpdate = {
achieved?: boolean;
};

export async function listSavingsGoals(): Promise<SavingsGoal[]> {
return api<SavingsGoal[]>('/savings/goals');
}

export async function getSavingsGoal(id: number): Promise<SavingsGoal> {
return api<SavingsGoal>(`/savings/goals/${id}`);
}

export async function createSavingsGoal(payload: SavingsGoalCreate): Promise<SavingsGoal> {
return api<SavingsGoal>('/savings/goals', { method: 'POST', body: payload });
}

export async function updateSavingsGoal(id: number, payload: SavingsGoalUpdate): Promise<SavingsGoal> {
return api<SavingsGoal>(`/savings/goals/${id}`, { method: 'PATCH', body: payload });
}

export async function deleteSavingsGoal(id: number): Promise<{ message: string }> {
return api<{ message: string }>(`/savings/goals/${id}`, { method: 'DELETE' });
}

export async function addSavingsContribution(payload: SavingsContributionCreate): Promise<SavingsContribution> {
return api<SavingsContribution>('/savings/contributions', { method: 'POST', body: payload });
}

export async function listSavingsContributions(goalId: number): Promise<SavingsContribution[]> {
return api<SavingsContribution[]>(`/savings/goals/${goalId}/contributions`);
}

export async function listSavingsMilestones(goalId: number): Promise<SavingsMilestone[]> {
return api<SavingsMilestone[]>(`/savings/goals/${goalId}/milestones`);
}

export async function createSavingsMilestone(payload: SavingsMilestoneCreate): Promise<SavingsMilestone> {
return api<SavingsMilestone>('/savings/milestones', { method: 'POST', body: payload });
}

export async function updateSavingsMilestone(id: number, payload: SavingsMilestoneUpdate): Promise<SavingsMilestone> {
return api<SavingsMilestone>(`/savings/milestones/${id}`, { method: 'PATCH', body: payload });
}

export async function deleteSavingsMilestone(id: number): Promise<{ message: string }> {
return api<{ message: string }>(`/savings/milestones/${id}`, { method: 'DELETE' });
}
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: 'Savings', href: '/savings' },
];

export function Navbar() {
Expand Down
Loading
Loading