diff --git a/app/src/App.tsx b/app/src/App.tsx index f0dc5942..69a90cb3 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: { @@ -83,6 +84,14 @@ const App = () => ( } /> + + + + } + /> ({ + useToast: () => ({ toast: toastMock }), +})); + +jest.mock('@/components/ui/button', () => ({ + Button: ({ children, ...props }: React.PropsWithChildren & React.ButtonHTMLAttributes) => ( + + ), +})); +jest.mock('@/components/ui/input', () => ({ + Input: ({ ...props }: React.InputHTMLAttributes) => , +})); +jest.mock('@/components/ui/label', () => ({ + Label: ({ children, ...props }: React.PropsWithChildren & React.LabelHTMLAttributes) => ( + + ), +})); +jest.mock('@/components/ui/dialog', () => ({ + Dialog: ({ children }: React.PropsWithChildren) =>
{children}
, + DialogContent: ({ children }: React.PropsWithChildren) =>
{children}
, + DialogHeader: ({ children }: React.PropsWithChildren) =>
{children}
, + DialogTitle: ({ children }: React.PropsWithChildren) =>

{children}

, + DialogDescription: ({ children }: React.PropsWithChildren) =>
{children}
, + DialogTrigger: ({ children }: React.PropsWithChildren) =>
{children}
, + DialogFooter: ({ children }: React.PropsWithChildren) =>
{children}
, +})); +jest.mock('@/components/ui/alert-dailog', () => ({ + AlertDialog: ({ children }: React.PropsWithChildren) =>
{children}
, + AlertDialogTrigger: ({ children }: React.PropsWithChildren) =>
{children}
, + AlertDialogContent: ({ children }: React.PropsWithChildren) =>
{children}
, + AlertDialogHeader: ({ children }: React.PropsWithChildren) =>
{children}
, + AlertDialogTitle: ({ children }: React.PropsWithChildren) =>
{children}
, + AlertDialogDescription: ({ children }: React.PropsWithChildren) =>
{children}
, + AlertDialogFooter: ({ children }: React.PropsWithChildren) =>
{children}
, + AlertDialogCancel: ({ children }: React.PropsWithChildren) => , + AlertDialogAction: ({ children, ...props }: React.PropsWithChildren & React.ButtonHTMLAttributes) => , +})); +jest.mock('@/components/ui/select', () => ({ + Select: ({ children }: React.PropsWithChildren) =>
{children}
, + SelectContent: ({ children }: React.PropsWithChildren) =>
{children}
, + SelectItem: ({ children, value }: React.PropsWithChildren & { value: string }) => , + SelectTrigger: ({ children }: React.PropsWithChildren) =>
{children}
, + SelectValue: ({ placeholder }: { placeholder?: string }) => {placeholder}, +})); +jest.mock('@/components/ui/badge', () => ({ + Badge: ({ children, variant }: React.PropsWithChildren & { variant?: string }) => ( + {children} + ), +})); +jest.mock('@/components/ui/financial-card', () => ({ + FinancialCard: ({ children, className }: React.PropsWithChildren & { className?: string }) => ( +
{children}
+ ), + FinancialCardHeader: ({ children, className }: React.PropsWithChildren & { className?: string }) => ( +
{children}
+ ), + FinancialCardTitle: ({ children, className }: React.PropsWithChildren & { className?: string }) => ( +

{children}

+ ), + FinancialCardDescription: ({ children, className }: React.PropsWithChildren & { className?: string }) => ( +

{children}

+ ), + FinancialCardContent: ({ children, className }: React.PropsWithChildren & { className?: string }) => ( +
{children}
+ ), + FinancialCardFooter: ({ children, className }: React.PropsWithChildren & { className?: string }) => ( +
{children}
+ ), +})); + +describe('SavingsGoals', () => { + beforeEach(() => { + toastMock.mockClear(); + }); + + it('renders the page title and summary cards', () => { + render(); + + expect(screen.getByText('Savings Goals')).toBeInTheDocument(); + expect(screen.getByText('Total Saved')).toBeInTheDocument(); + expect(screen.getByText('Monthly Savings')).toBeInTheDocument(); + // "On Track" appears both as summary label and as badge; use getAllByText + expect(screen.getAllByText('On Track').length).toBeGreaterThan(0); + expect(screen.getByText('Needs Attention')).toBeInTheDocument(); + }); + + it('renders default savings goals', () => { + render(); + + // "Emergency Fund" appears both as goal name and category option; use getAllByText + expect(screen.getAllByText('Emergency Fund').length).toBeGreaterThan(0); + expect(screen.getByText('Vacation to Japan')).toBeInTheDocument(); + expect(screen.getByText('New Car Down Payment')).toBeInTheDocument(); + }); + + it('displays progress information for each goal', () => { + render(); + + // Check that percentage complete is shown + expect(screen.getByText('72.5% complete')).toBeInTheDocument(); + expect(screen.getByText('36.0% complete')).toBeInTheDocument(); + expect(screen.getByText('21.3% complete')).toBeInTheDocument(); + }); + + it('renders the New Goal button', () => { + render(); + + expect(screen.getByText('New Goal')).toBeInTheDocument(); + }); + + it('renders contribute buttons for each goal', () => { + render(); + + // 3 goal cards + 1 contribute dialog button = 4 + const contributeButtons = screen.getAllByText('Contribute'); + expect(contributeButtons.length).toBeGreaterThanOrEqual(3); + }); + + it('shows milestone indicators for each goal', () => { + render(); + + // Emergency Fund is at 72.5%, so should show 25% and 50% as reached + expect(screen.getAllByText('25%').length).toBeGreaterThan(0); + expect(screen.getAllByText('50%').length).toBeGreaterThan(0); + expect(screen.getAllByText('75%').length).toBeGreaterThan(0); + }); + + it('shows on-track or behind status badges', () => { + render(); + + const badges = screen.getAllByText(/On Track|Behind|Completed/); + expect(badges.length).toBeGreaterThan(0); + }); + + it('calculates summary correctly with mock data', () => { + render(); + + // Total saved: 7250 + 1800 + 3200 = 12250 + expect(screen.getByText('$12,250')).toBeInTheDocument(); + + // Monthly total: 500 + 400 + 600 = 1500 + expect(screen.getByText('$1,500')).toBeInTheDocument(); + }); +}); diff --git a/app/src/api/savings-goals.ts b/app/src/api/savings-goals.ts new file mode 100644 index 00000000..b2c9baf7 --- /dev/null +++ b/app/src/api/savings-goals.ts @@ -0,0 +1,87 @@ +import { apiClient } from './client'; + +export interface SavingsGoal { + id: string; + name: string; + targetAmount: number; + currentAmount: number; + deadline: string; + monthlyContribution: number; + category: string; + color: string; + createdAt: string; + updatedAt: string; +} + +export interface SavingsMilestone { + id: string; + goalId: string; + percentage: number; + reachedAt: string; + amount: number; +} + +export interface CreateSavingsGoalRequest { + name: string; + targetAmount: number; + currentAmount?: number; + deadline: string; + monthlyContribution: number; + category: string; + color?: string; +} + +export interface UpdateSavingsGoalRequest { + name?: string; + targetAmount?: number; + currentAmount?: number; + deadline?: string; + monthlyContribution?: number; + category?: string; + color?: string; +} + +export const getSavingsGoals = async (): Promise => { + const response = await apiClient.get('/savings-goals'); + return response.data; +}; + +export const getSavingsGoal = async (id: string): Promise => { + const response = await apiClient.get(`/savings-goals/${id}`); + return response.data; +}; + +export const createSavingsGoal = async (data: CreateSavingsGoalRequest): Promise => { + const response = await apiClient.post('/savings-goals', data); + return response.data; +}; + +export const updateSavingsGoal = async (id: string, data: UpdateSavingsGoalRequest): Promise => { + const response = await apiClient.put(`/savings-goals/${id}`, data); + return response.data; +}; + +export const deleteSavingsGoal = async (id: string): Promise => { + await apiClient.delete(`/savings-goals/${id}`); +}; + +export const contributeToGoal = async (id: string, amount: number): Promise => { + const response = await apiClient.post(`/savings-goals/${id}/contribute`, { amount }); + return response.data; +}; + +export const getGoalMilestones = async (goalId: string): Promise => { + const response = await apiClient.get(`/savings-goals/${goalId}/milestones`); + return response.data; +}; + +export const getSavingsGoalsSummary = async (): Promise<{ + totalSaved: number; + totalTarget: number; + goalsOnTrack: number; + goalsBehind: number; + monthlyTotal: number; +}> => { + const response = await apiClient.get('/savings-goals/summary'); + return response.data; +}; diff --git a/app/src/components/layout/Navbar.tsx b/app/src/components/layout/Navbar.tsx index c7593b70..e99fbeb5 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', 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 00000000..6c1966d8 --- /dev/null +++ b/app/src/pages/SavingsGoals.tsx @@ -0,0 +1,660 @@ +import { useState, useEffect, useMemo } from 'react'; +import { + FinancialCard, + FinancialCardContent, + FinancialCardDescription, + FinancialCardHeader, + FinancialCardTitle, + FinancialCardFooter, +} from '@/components/ui/financial-card'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/components/ui/dialog'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from '@/components/ui/alert-dailog'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { + Target, + Plus, + TrendingUp, + TrendingDown, + Calendar, + DollarSign, + Trash2, + Edit, + PiggyBank, + Trophy, + CheckCircle2, + AlertCircle, + Clock, +} from 'lucide-react'; +import { useToast } from '@/hooks/use-toast'; +import type { SavingsGoal, CreateSavingsGoalRequest } from '@/api/savings-goals'; + +const GOAL_COLORS = [ + 'bg-primary', + 'bg-success', + 'bg-accent', + 'bg-warning', + 'bg-destructive', + 'bg-secondary', +]; + +const GOAL_CATEGORIES = [ + 'Emergency Fund', + 'Vacation', + 'Education', + 'Home Purchase', + 'Vehicle', + 'Retirement', + 'Wedding', + 'Healthcare', + 'Investment', + 'Other', +]; + +// Mock data for initial rendering (would be replaced by API calls) +const mockGoals: SavingsGoal[] = [ + { + id: '1', + name: 'Emergency Fund', + targetAmount: 10000, + currentAmount: 7250, + deadline: '2026-12-31', + monthlyContribution: 500, + category: 'Emergency Fund', + color: 'bg-success', + createdAt: '2025-01-01', + updatedAt: '2026-03-28', + }, + { + id: '2', + name: 'Vacation to Japan', + targetAmount: 5000, + currentAmount: 1800, + deadline: '2026-09-01', + monthlyContribution: 400, + category: 'Vacation', + color: 'bg-primary', + createdAt: '2025-06-01', + updatedAt: '2026-03-28', + }, + { + id: '3', + name: 'New Car Down Payment', + targetAmount: 15000, + currentAmount: 3200, + deadline: '2027-06-01', + monthlyContribution: 600, + category: 'Vehicle', + color: 'bg-accent', + createdAt: '2025-09-01', + updatedAt: '2026-03-28', + }, +]; + +interface Milestone { + percentage: number; + label: string; + icon: typeof Trophy; +} + +const MILESTONES: Milestone[] = [ + { percentage: 25, label: 'Quarter Way', icon: TrendingUp }, + { percentage: 50, label: 'Halfway There', icon: Target }, + { percentage: 75, label: 'Almost There', icon: Trophy }, + { percentage: 100, label: 'Goal Reached!', icon: CheckCircle2 }, +]; + +function getProgressStatus(current: number, target: number, deadline: string, monthly: number) { + const progress = (current / target) * 100; + const deadlineDate = new Date(deadline); + const now = new Date(); + const monthsRemaining = Math.max( + 1, + Math.ceil((deadlineDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24 * 30)) + ); + const projectedTotal = current + monthly * monthsRemaining; + const isOnTrack = projectedTotal >= target; + + if (progress >= 100) return { status: 'completed', label: 'Completed', variant: 'default' as const }; + if (isOnTrack) return { status: 'on-track', label: 'On Track', variant: 'default' as const }; + return { status: 'behind', label: 'Behind', variant: 'destructive' as const }; +} + +function getMonthsRemaining(deadline: string): number { + const deadlineDate = new Date(deadline); + const now = new Date(); + return Math.max(0, Math.ceil((deadlineDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24 * 30))); +} + +function formatCurrency(amount: number): string { + return new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + minimumFractionDigits: 0, + maximumFractionDigits: 0, + }).format(amount); +} + +export function SavingsGoals() { + const [goals, setGoals] = useState(mockGoals); + const [isCreateOpen, setIsCreateOpen] = useState(false); + const [isContributeOpen, setIsContributeOpen] = useState(false); + const [selectedGoal, setSelectedGoal] = useState(null); + const [contributeAmount, setContributeAmount] = useState(''); + const [formData, setFormData] = useState({ + name: '', + targetAmount: 0, + currentAmount: 0, + deadline: '', + monthlyContribution: 0, + category: 'Other', + color: 'bg-primary', + }); + const { toast } = useToast(); + + const summary = useMemo(() => { + const totalSaved = goals.reduce((sum, g) => sum + g.currentAmount, 0); + const totalTarget = goals.reduce((sum, g) => sum + g.targetAmount, 0); + const monthlyTotal = goals.reduce((sum, g) => sum + g.monthlyContribution, 0); + const goalsOnTrack = goals.filter((g) => { + const { status } = getProgressStatus(g.currentAmount, g.targetAmount, g.deadline, g.monthlyContribution); + return status === 'on-track' || status === 'completed'; + }).length; + const goalsBehind = goals.length - goalsOnTrack; + return { totalSaved, totalTarget, goalsOnTrack, goalsBehind, monthlyTotal }; + }, [goals]); + + const handleCreateGoal = () => { + if (!formData.name || formData.targetAmount <= 0 || !formData.deadline) { + toast({ + title: 'Validation Error', + description: 'Please fill in all required fields.', + variant: 'destructive', + }); + return; + } + + const newGoal: SavingsGoal = { + id: Date.now().toString(), + ...formData, + color: formData.color || GOAL_COLORS[goals.length % GOAL_COLORS.length], + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + + setGoals((prev) => [...prev, newGoal]); + setIsCreateOpen(false); + resetForm(); + toast({ + title: 'Goal Created', + description: `"${newGoal.name}" has been added to your savings goals.`, + }); + }; + + const handleContribute = () => { + if (!selectedGoal || !contributeAmount || parseFloat(contributeAmount) <= 0) { + toast({ + title: 'Invalid Amount', + description: 'Please enter a valid contribution amount.', + variant: 'destructive', + }); + return; + } + + const amount = parseFloat(contributeAmount); + const prevAmount = selectedGoal.currentAmount; + const newAmount = Math.min(prevAmount + amount, selectedGoal.targetAmount); + + setGoals((prev) => + prev.map((g) => + g.id === selectedGoal.id + ? { ...g, currentAmount: newAmount, updatedAt: new Date().toISOString() } + : g + ) + ); + + // Check for milestone achievements + const prevProgress = (prevAmount / selectedGoal.targetAmount) * 100; + const newProgress = (newAmount / selectedGoal.targetAmount) * 100; + + for (const milestone of MILESTONES) { + if (prevProgress < milestone.percentage && newProgress >= milestone.percentage) { + setTimeout(() => { + toast({ + title: `🎉 ${milestone.label}!`, + description: `You've reached ${milestone.percentage}% of your "${selectedGoal.name}" goal!`, + }); + }, 300); + break; + } + } + + if (newAmount >= selectedGoal.targetAmount) { + toast({ + title: '🏆 Goal Completed!', + description: `Congratulations! You've reached your "${selectedGoal.name}" goal!`, + }); + } + + setIsContributeOpen(false); + setSelectedGoal(null); + setContributeAmount(''); + }; + + const handleDeleteGoal = (goal: SavingsGoal) => { + setGoals((prev) => prev.filter((g) => g.id !== goal.id)); + toast({ + title: 'Goal Deleted', + description: `"${goal.name}" has been removed.`, + }); + }; + + const resetForm = () => { + setFormData({ + name: '', + targetAmount: 0, + currentAmount: 0, + deadline: '', + monthlyContribution: 0, + category: 'Other', + color: 'bg-primary', + }); + }; + + const openContributeDialog = (goal: SavingsGoal) => { + setSelectedGoal(goal); + setContributeAmount(''); + setIsContributeOpen(true); + }; + + return ( +
+ {/* Header */} +
+
+

Savings Goals

+

Track your progress towards financial milestones.

+
+ + + + + + + Create Savings Goal + Set a new savings target and track your progress. + +
+
+ + setFormData((prev) => ({ ...prev, name: e.target.value }))} + /> +
+
+
+ + + setFormData((prev) => ({ ...prev, targetAmount: parseFloat(e.target.value) || 0 })) + } + /> +
+
+ + + setFormData((prev) => ({ ...prev, currentAmount: parseFloat(e.target.value) || 0 })) + } + /> +
+
+
+
+ + setFormData((prev) => ({ ...prev, deadline: e.target.value }))} + /> +
+
+ + + setFormData((prev) => ({ ...prev, monthlyContribution: parseFloat(e.target.value) || 0 })) + } + /> +
+
+
+ + +
+
+ + + + +
+
+
+ + {/* Summary Cards */} +
+ + + Total Saved + + {formatCurrency(summary.totalSaved)} + + + +

+ of {formatCurrency(summary.totalTarget)} target +

+
+
+
+ + + + + + Monthly Savings + {formatCurrency(summary.monthlyTotal)} + + +

Total monthly contributions

+
+
+ + + + On Track + {summary.goalsOnTrack} + + +

Goals progressing well

+
+
+ + + + Needs Attention + 0 ? 'text-2xl text-destructive' : 'text-2xl'}> + {summary.goalsBehind} + + + +

Goals behind schedule

+
+
+
+ + {/* Goals List */} +
+ {goals.map((goal) => { + const progress = (goal.currentAmount / goal.targetAmount) * 100; + const { status, label, variant } = getProgressStatus( + goal.currentAmount, + goal.targetAmount, + goal.deadline, + goal.monthlyContribution + ); + const monthsLeft = getMonthsRemaining(goal.deadline); + const remaining = goal.targetAmount - goal.currentAmount; + + return ( + + {/* Progress bar at top */} +
+
+
+ + +
+
+ {goal.name} + + + {monthsLeft} months remaining + +
+ {label} +
+
+ + + {/* Progress */} +
+
+ {formatCurrency(goal.currentAmount)} + {formatCurrency(goal.targetAmount)} +
+
+
+
+

{progress.toFixed(1)}% complete

+
+ + {/* Details */} +
+
+ +
+

Remaining

+

{formatCurrency(Math.max(0, remaining))}

+
+
+
+ +
+

Monthly

+

{formatCurrency(goal.monthlyContribution)}

+
+
+
+ + {/* Milestones */} +
+ {MILESTONES.map((milestone) => { + const reached = progress >= milestone.percentage; + const MilestoneIcon = milestone.icon; + return ( +
+ + {milestone.percentage}% +
+ ); + })} +
+ + + + + + + + + + + Delete Goal + + Are you sure you want to delete "{goal.name}"? This action cannot be undone. + + + + Cancel + handleDeleteGoal(goal)}>Delete + + + + + + ); + })} + + {/* Empty state / Add card */} + {goals.length === 0 && ( + + +

No Goals Yet

+

+ Start by creating your first savings goal to track your progress. +

+ +
+ )} +
+ + {/* Contribute Dialog */} + + + + Contribute to "{selectedGoal?.name}" + + {selectedGoal && ( + <> + Current: {formatCurrency(selectedGoal.currentAmount)} /{' '} + {formatCurrency(selectedGoal.targetAmount)} + + )} + + +
+
+ + setContributeAmount(e.target.value)} + autoFocus + /> +
+ {selectedGoal && ( +
+

+ After this contribution:{' '} + + {formatCurrency( + Math.min( + selectedGoal.currentAmount + (parseFloat(contributeAmount) || 0), + selectedGoal.targetAmount + ) + )} + +

+
+ )} +
+ + + + +
+
+
+ ); +}