diff --git a/app/src/App.tsx b/app/src/App.tsx index f0dc594..d5ee4ab 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -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 { SavingsGoals } from "./pages/SavingsGoals"; const queryClient = new QueryClient({ defaultOptions: { @@ -91,6 +92,14 @@ const App = () => ( } /> + + + + } + /> } /> } /> diff --git a/app/src/__tests__/savings-goals.test.ts b/app/src/__tests__/savings-goals.test.ts new file mode 100644 index 0000000..481c9ad --- /dev/null +++ b/app/src/__tests__/savings-goals.test.ts @@ -0,0 +1,302 @@ +import { + listSavingsGoals, + getSavingsGoal, + createSavingsGoal, + updateSavingsGoal, + deleteSavingsGoal, + addToSavingsGoal, + withdrawFromSavingsGoal, + listMilestones, + createMilestone, + deleteMilestone, + getGoalProgress, + type SavingsGoal, + type GoalMilestone, +} from '../api/savings-goals'; +import * as client from '../api/client'; + +// Mock the API client +jest.mock('../api/client', () => ({ + api: jest.fn(), + baseURL: 'http://localhost:3000/api', +})); + +const mockGoal: SavingsGoal = { + id: 1, + name: 'Emergency Fund', + description: 'Save for 6 months of expenses', + target_amount: 10000, + current_amount: 5000, + currency: 'USD', + deadline: '2026-12-31', + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-03-01T00:00:00Z', +}; + +const mockMilestone: GoalMilestone = { + id: 1, + goal_id: 1, + name: '25% Milestone', + target_percentage: 25, + achieved: true, + achieved_at: '2026-02-15T00:00:00Z', +}; + +describe('savings-goals API', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('listSavingsGoals', () => { + it('fetches all savings goals', async () => { + const mockGoals = [mockGoal]; + (client.api as jest.Mock).mockResolvedValueOnce(mockGoals); + + const result = await listSavingsGoals(); + + expect(client.api).toHaveBeenCalledWith('/savings-goals'); + expect(result).toEqual(mockGoals); + }); + + it('filters by active status', async () => { + const mockGoals = [mockGoal]; + (client.api as jest.Mock).mockResolvedValueOnce(mockGoals); + + const result = await listSavingsGoals({ active: true }); + + expect(client.api).toHaveBeenCalledWith('/savings-goals?active=true'); + expect(result).toEqual(mockGoals); + }); + + it('searches by name', async () => { + const mockGoals = [mockGoal]; + (client.api as jest.Mock).mockResolvedValueOnce(mockGoals); + + const result = await listSavingsGoals({ search: 'emergency' }); + + expect(client.api).toHaveBeenCalledWith('/savings-goals?search=emergency'); + expect(result).toEqual(mockGoals); + }); + }); + + describe('getSavingsGoal', () => { + it('fetches a single goal by ID', async () => { + (client.api as jest.Mock).mockResolvedValueOnce(mockGoal); + + const result = await getSavingsGoal(1); + + expect(client.api).toHaveBeenCalledWith('/savings-goals/1'); + expect(result).toEqual(mockGoal); + }); + }); + + describe('createSavingsGoal', () => { + it('creates a new savings goal', async () => { + const payload = { + name: 'Vacation Fund', + target_amount: 3000, + deadline: '2026-06-30', + }; + const created = { ...mockGoal, ...payload, id: 2 }; + (client.api as jest.Mock).mockResolvedValueOnce(created); + + const result = await createSavingsGoal(payload); + + expect(client.api).toHaveBeenCalledWith('/savings-goals', { + method: 'POST', + body: payload, + }); + expect(result).toEqual(created); + }); + + it('creates a goal with optional fields', async () => { + const payload = { + name: 'New Car', + description: 'Down payment', + target_amount: 15000, + current_amount: 2000, + currency: 'USD', + deadline: '2027-01-01', + }; + (client.api as jest.Mock).mockResolvedValueOnce({ ...mockGoal, ...payload }); + + const result = await createSavingsGoal(payload); + + expect(client.api).toHaveBeenCalledWith('/savings-goals', { + method: 'POST', + body: payload, + }); + }); + }); + + describe('updateSavingsGoal', () => { + it('updates an existing goal', async () => { + const update = { target_amount: 12000 }; + const updated = { ...mockGoal, ...update }; + (client.api as jest.Mock).mockResolvedValueOnce(updated); + + const result = await updateSavingsGoal(1, update); + + expect(client.api).toHaveBeenCalledWith('/savings-goals/1', { + method: 'PATCH', + body: update, + }); + expect(result.target_amount).toBe(12000); + }); + }); + + describe('deleteSavingsGoal', () => { + it('deletes a goal', async () => { + const response = { message: 'Goal deleted' }; + (client.api as jest.Mock).mockResolvedValueOnce(response); + + const result = await deleteSavingsGoal(1); + + expect(client.api).toHaveBeenCalledWith('/savings-goals/1', { + method: 'DELETE', + }); + expect(result).toEqual(response); + }); + }); + + describe('addToSavingsGoal', () => { + it('adds funds to a goal', async () => { + const updated = { ...mockGoal, current_amount: 5500 }; + (client.api as jest.Mock).mockResolvedValueOnce(updated); + + const result = await addToSavingsGoal(1, 500); + + expect(client.api).toHaveBeenCalledWith('/savings-goals/1/add', { + method: 'POST', + body: { amount: 500 }, + }); + expect(result.current_amount).toBe(5500); + }); + }); + + describe('withdrawFromSavingsGoal', () => { + it('withdraws funds from a goal', async () => { + const updated = { ...mockGoal, current_amount: 4500 }; + (client.api as jest.Mock).mockResolvedValueOnce(updated); + + const result = await withdrawFromSavingsGoal(1, 500); + + expect(client.api).toHaveBeenCalledWith('/savings-goals/1/withdraw', { + method: 'POST', + body: { amount: 500 }, + }); + expect(result.current_amount).toBe(4500); + }); + }); + + describe('Milestones', () => { + describe('listMilestones', () => { + it('lists milestones for a goal', async () => { + const mockMilestones = [mockMilestone]; + (client.api as jest.Mock).mockResolvedValueOnce(mockMilestones); + + const result = await listMilestones(1); + + expect(client.api).toHaveBeenCalledWith('/savings-goals/1/milestones'); + expect(result).toEqual(mockMilestones); + }); + }); + + describe('createMilestone', () => { + it('creates a milestone', async () => { + const payload = { + goal_id: 1, + name: '50% Milestone', + target_percentage: 50, + }; + const created = { ...mockMilestone, ...payload, id: 2 }; + (client.api as jest.Mock).mockResolvedValueOnce(created); + + const result = await createMilestone(payload); + + expect(client.api).toHaveBeenCalledWith('/savings-goals/milestones', { + method: 'POST', + body: payload, + }); + expect(result).toEqual(created); + }); + }); + + describe('deleteMilestone', () => { + it('deletes a milestone', async () => { + const response = { message: 'Milestone deleted' }; + (client.api as jest.Mock).mockResolvedValueOnce(response); + + const result = await deleteMilestone(1); + + expect(client.api).toHaveBeenCalledWith('/savings-goals/milestones/1', { + method: 'DELETE', + }); + expect(result).toEqual(response); + }); + }); + }); + + describe('getGoalProgress', () => { + it('calculates goal progress metrics', async () => { + const progress = { + percentage: 50, + remaining: 5000, + days_left: 300, + on_track: true, + required_daily_saving: 16.67, + }; + (client.api as jest.Mock).mockResolvedValueOnce(progress); + + const result = await getGoalProgress(1); + + expect(client.api).toHaveBeenCalledWith('/savings-goals/1/progress'); + expect(result).toEqual(progress); + expect(result.percentage).toBe(50); + expect(result.on_track).toBe(true); + }); + + it('handles goals without deadlines', async () => { + const progress = { + percentage: 50, + remaining: 5000, + on_track: true, + }; + (client.api as jest.Mock).mockResolvedValueOnce(progress); + + const result = await getGoalProgress(2); + + expect(result.days_left).toBeUndefined(); + expect(result.required_daily_saving).toBeUndefined(); + }); + }); + + describe('Edge Cases', () => { + it('handles zero target amount', async () => { + const zeroGoal = { ...mockGoal, target_amount: 0 }; + (client.api as jest.Mock).mockResolvedValueOnce(zeroGoal); + + const result = await getSavingsGoal(99); + + expect(result.target_amount).toBe(0); + }); + + it('handles completed goals', async () => { + const completedGoal = { ...mockGoal, current_amount: 10000 }; + (client.api as jest.Mock).mockResolvedValueOnce(completedGoal); + + const result = await getSavingsGoal(1); + + expect(result.current_amount).toBe(result.target_amount); + }); + + it('handles overdue goals', async () => { + const overdueGoal = { ...mockGoal, deadline: '2020-01-01' }; + (client.api as jest.Mock).mockResolvedValueOnce(overdueGoal); + + const result = await getSavingsGoal(1); + + expect(new Date(result.deadline!).getTime()).toBeLessThan(Date.now()); + }); + }); +}); diff --git a/app/src/api/savings-goals.ts b/app/src/api/savings-goals.ts new file mode 100644 index 0000000..2ad1d98 --- /dev/null +++ b/app/src/api/savings-goals.ts @@ -0,0 +1,144 @@ +import { api } from './client'; + +export type SavingsGoal = { + id: number; + name: string; + description?: string; + target_amount: number; + current_amount: number; + currency: string; + deadline?: string; // ISO date + created_at: string; + updated_at: string; +}; + +export type SavingsGoalCreate = { + name: string; + description?: string; + target_amount: number; + current_amount?: number; + currency?: string; + deadline?: string; +}; + +export type SavingsGoalUpdate = Partial; + +export type GoalMilestone = { + id: number; + goal_id: number; + name: string; + target_percentage: number; // 0-100 + achieved: boolean; + achieved_at?: string; +}; + +export type MilestoneCreate = { + goal_id: number; + name: string; + target_percentage: number; +}; + +/** + * List all savings goals for the current user + */ +export async function listSavingsGoals(params?: { + active?: boolean; + search?: string; +}): Promise { + const qs = new URLSearchParams(); + if (params) { + Object.entries(params).forEach(([k, v]) => { + if (v !== undefined && v !== null && v !== '') qs.set(k, String(v)); + }); + } + const path = '/savings-goals' + (qs.toString() ? `?${qs.toString()}` : ''); + return api(path); +} + +/** + * Get a single savings goal by ID + */ +export async function getSavingsGoal(id: number): Promise { + return api(`/savings-goals/${id}`); +} + +/** + * Create a new savings goal + */ +export async function createSavingsGoal(payload: SavingsGoalCreate): Promise { + return api('/savings-goals', { method: 'POST', body: payload }); +} + +/** + * Update an existing savings goal + */ +export async function updateSavingsGoal(id: number, payload: SavingsGoalUpdate): Promise { + return api(`/savings-goals/${id}`, { method: 'PATCH', body: payload }); +} + +/** + * Delete a savings goal + */ +export async function deleteSavingsGoal(id: number): Promise<{ message: string }> { + return api<{ message: string }>(`/savings-goals/${id}`, { method: 'DELETE' }); +} + +/** + * Add funds to a savings goal + */ +export async function addToSavingsGoal( + id: number, + amount: number, +): Promise { + return api(`/savings-goals/${id}/add`, { + method: 'POST', + body: { amount }, + }); +} + +/** + * Withdraw funds from a savings goal + */ +export async function withdrawFromSavingsGoal( + id: number, + amount: number, +): Promise { + return api(`/savings-goals/${id}/withdraw`, { + method: 'POST', + body: { amount }, + }); +} + +/** + * List milestones for a savings goal + */ +export async function listMilestones(goalId: number): Promise { + return api(`/savings-goals/${goalId}/milestones`); +} + +/** + * Create a milestone for a savings goal + */ +export async function createMilestone(payload: MilestoneCreate): Promise { + return api('/savings-goals/milestones', { method: 'POST', body: payload }); +} + +/** + * Delete a milestone + */ +export async function deleteMilestone(milestoneId: number): Promise<{ message: string }> { + return api<{ message: string }>(`/savings-goals/milestones/${milestoneId}`, { method: 'DELETE' }); +} + +/** + * Get goal progress metrics + */ +export async function getGoalProgress(goalId: number): Promise<{ + percentage: number; + remaining: number; + days_left?: number; + on_track: boolean; + required_daily_saving?: number; +}> { + return api(`/savings-goals/${goalId}/progress`); +} diff --git a/app/src/components/layout/Navbar.tsx b/app/src/components/layout/Navbar.tsx index c7593b7..b717f9b 100644 --- a/app/src/components/layout/Navbar.tsx +++ b/app/src/components/layout/Navbar.tsx @@ -10,6 +10,7 @@ const navigation = [ { name: 'Dashboard', href: '/dashboard' }, { name: 'Budgets', href: '/budgets' }, { name: 'Bills', href: '/bills' }, + { name: 'Savings Goals', href: '/savings-goals' }, { name: 'Reminders', href: '/reminders' }, { name: 'Expenses', href: '/expenses' }, { name: 'Analytics', href: '/analytics' }, diff --git a/app/src/pages/SavingsGoals.tsx b/app/src/pages/SavingsGoals.tsx new file mode 100644 index 0000000..dee9c77 --- /dev/null +++ b/app/src/pages/SavingsGoals.tsx @@ -0,0 +1,489 @@ +import { useState, useEffect } from 'react'; +import { + FinancialCard, + FinancialCardContent, + FinancialCardDescription, + FinancialCardFooter, + FinancialCardHeader, + FinancialCardTitle, +} from '@/components/ui/financial-card'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { Progress } from '@/components/ui/progress'; +import { + Target, + Plus, + TrendingUp, + Calendar, + DollarSign, + Trophy, + Edit, + Trash2, + PlusCircle, + MinusCircle, +} from 'lucide-react'; +import { + listSavingsGoals, + createSavingsGoal, + updateSavingsGoal, + deleteSavingsGoal, + addToSavingsGoal, + withdrawFromSavingsGoal, + getGoalProgress, + listMilestones, + createMilestone, + deleteMilestone, + type SavingsGoal, + type GoalMilestone, +} from '@/api/savings-goals'; + +export function SavingsGoals() { + const [goals, setGoals] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [showCreateModal, setShowCreateModal] = useState(false); + const [editingGoal, setEditingGoal] = useState(null); + const [selectedGoal, setSelectedGoal] = useState(null); + const [milestones, setMilestones] = useState>({}); + + useEffect(() => { + loadGoals(); + }, []); + + async function loadGoals() { + try { + setLoading(true); + setError(null); + const data = await listSavingsGoals(); + setGoals(data); + + // Load milestones for each goal + const milestonesData: Record = {}; + await Promise.all( + data.map(async (goal) => { + try { + milestonesData[goal.id] = await listMilestones(goal.id); + } catch (err) { + console.error(`Failed to load milestones for goal ${goal.id}:`, err); + milestonesData[goal.id] = []; + } + }) + ); + setMilestones(milestonesData); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load savings goals'); + } finally { + setLoading(false); + } + } + + async function handleCreateGoal(data: { + name: string; + description?: string; + target_amount: number; + current_amount?: number; + deadline?: string; + }) { + try { + await createSavingsGoal(data); + await loadGoals(); + setShowCreateModal(false); + } catch (err) { + alert(err instanceof Error ? err.message : 'Failed to create goal'); + } + } + + async function handleUpdateGoal(id: number, data: Partial) { + try { + await updateSavingsGoal(id, data); + await loadGoals(); + setEditingGoal(null); + } catch (err) { + alert(err instanceof Error ? err.message : 'Failed to update goal'); + } + } + + async function handleDeleteGoal(id: number) { + if (!confirm('Are you sure you want to delete this savings goal?')) return; + try { + await deleteSavingsGoal(id); + await loadGoals(); + } catch (err) { + alert(err instanceof Error ? err.message : 'Failed to delete goal'); + } + } + + async function handleAddFunds(id: number, amount: number) { + try { + await addToSavingsGoal(id, amount); + await loadGoals(); + } catch (err) { + alert(err instanceof Error ? err.message : 'Failed to add funds'); + } + } + + async function handleWithdraw(id: number, amount: number) { + try { + await withdrawFromSavingsGoal(id, amount); + await loadGoals(); + } catch (err) { + alert(err instanceof Error ? err.message : 'Failed to withdraw funds'); + } + } + + async function handleCreateMilestone(goalId: number, name: string, percentage: number) { + try { + await createMilestone({ goal_id: goalId, name, target_percentage: percentage }); + await loadGoals(); + } catch (err) { + alert(err instanceof Error ? err.message : 'Failed to create milestone'); + } + } + + async function handleDeleteMilestone(milestoneId: number) { + try { + await deleteMilestone(milestoneId); + await loadGoals(); + } catch (err) { + alert(err instanceof Error ? err.message : 'Failed to delete milestone'); + } + } + + function calculateProgress(goal: SavingsGoal): number { + if (goal.target_amount === 0) return 0; + return Math.min(100, (goal.current_amount / goal.target_amount) * 100); + } + + function getDaysLeft(deadline?: string): number | null { + if (!deadline) return null; + const now = new Date(); + const end = new Date(deadline); + const diff = end.getTime() - now.getTime(); + return Math.ceil(diff / (1000 * 60 * 60 * 24)); + } + + function getStatusBadge(goal: SavingsGoal) { + const progress = calculateProgress(goal); + if (progress >= 100) { + return Completed; + } + + const daysLeft = getDaysLeft(goal.deadline); + if (!daysLeft) { + return In Progress; + } + + if (daysLeft < 0) { + return Overdue; + } + + // Calculate if on track + const daysTotal = goal.deadline ? + Math.ceil((new Date(goal.deadline).getTime() - new Date(goal.created_at).getTime()) / (1000 * 60 * 60 * 24)) : + 0; + const expectedProgress = daysTotal > 0 ? ((daysTotal - daysLeft) / daysTotal) * 100 : 0; + + if (progress >= expectedProgress) { + return On Track; + } else { + return Behind Schedule; + } + } + + const totalSaved = goals.reduce((sum, g) => sum + g.current_amount, 0); + const totalTarget = goals.reduce((sum, g) => sum + g.target_amount, 0); + const overallProgress = totalTarget > 0 ? (totalSaved / totalTarget) * 100 : 0; + + if (loading) { + return ( +
+
+

Loading savings goals...

+
+
+ ); + } + + return ( +
+ {/* Header */} +
+
+

Savings Goals

+

Track your savings progress and achieve your financial milestones

+
+ +
+ + {error && ( +
+ {error} +
+ )} + + {/* Overview Stats */} +
+ + + + + Total Goals + + + +

{goals.length}

+

Active savings goals

+
+
+ + + + + + Total Saved + + + +

${totalSaved.toFixed(2)}

+

+ of ${totalTarget.toFixed(2)} target +

+
+
+ + + + + + Overall Progress + + + +

{overallProgress.toFixed(1)}%

+ +
+
+
+ + {/* Goals List */} + {goals.length === 0 ? ( + + + +

No savings goals yet

+

+ Create your first savings goal to start tracking your financial progress +

+ +
+
+ ) : ( +
+ {goals.map((goal) => { + const progress = calculateProgress(goal); + const daysLeft = getDaysLeft(goal.deadline); + const goalMilestones = milestones[goal.id] || []; + + return ( + + +
+
+ + + {goal.name} + + {goal.description && ( + {goal.description} + )} +
+ {getStatusBadge(goal)} +
+
+ + + {/* Progress Bar */} +
+
+ ${goal.current_amount.toFixed(2)} + ${goal.target_amount.toFixed(2)} +
+ +

+ {progress.toFixed(1)}% Complete +

+
+ + {/* Deadline */} + {goal.deadline && ( +
+ + + {daysLeft !== null && daysLeft >= 0 + ? `${daysLeft} days left` + : daysLeft !== null && daysLeft < 0 + ? `${Math.abs(daysLeft)} days overdue` + : new Date(goal.deadline).toLocaleDateString()} + +
+ )} + + {/* Milestones */} + {goalMilestones.length > 0 && ( +
+

+ + Milestones +

+
+ {goalMilestones.map((milestone) => { + const isAchieved = progress >= milestone.target_percentage; + return ( +
+ {milestone.name} + {milestone.target_percentage}% +
+ ); + })} +
+
+ )} +
+ + + + + + + +
+ ); + })} +
+ )} + + {/* Create Modal (simplified placeholder) */} + {showCreateModal && ( +
+
+

Create Savings Goal

+
{ + e.preventDefault(); + const formData = new FormData(e.currentTarget); + handleCreateGoal({ + name: formData.get('name') as string, + description: formData.get('description') as string, + target_amount: parseFloat(formData.get('target_amount') as string), + current_amount: parseFloat(formData.get('current_amount') as string) || 0, + deadline: formData.get('deadline') as string || undefined, + }); + }} + className="space-y-4" + > +
+ + +
+
+ +