From 1cdc5c9deb8f1cdaebde428c078ed70e5f3c669d Mon Sep 17 00:00:00 2001 From: Kasion <540853397@qq.com> Date: Tue, 21 Apr 2026 00:50:48 +0800 Subject: [PATCH] feat: Goal-based savings tracking & milestones --- README.md | 2 + app/src/App.tsx | 9 + .../__tests__/Savings.integration.test.tsx | 159 ++++ app/src/api/savings.ts | 104 ++ app/src/components/layout/Navbar.tsx | 1 + app/src/pages/Savings.tsx | 896 ++++++++++++++++++ 6 files changed, 1171 insertions(+) create mode 100644 app/src/__tests__/Savings.integration.test.tsx create mode 100644 app/src/api/savings.ts create mode 100644 app/src/pages/Savings.tsx diff --git a/README.md b/README.md index 49592bffc..b0f1bf580 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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 diff --git a/app/src/App.tsx b/app/src/App.tsx index f0dc5942d..bf917f978 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 { Savings } from "./pages/Savings"; const queryClient = new QueryClient({ defaultOptions: { @@ -83,6 +84,14 @@ const App = () => ( } /> + + + + } + /> ({ + 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 = [ + { + 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 = { + 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( + + {ui} + + ); +} + +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(); + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + await waitFor(() => { + expect(screen.getByText('On Track')).toBeTruthy(); + expect(screen.getByText('Behind')).toBeTruthy(); + }); + }); +}); diff --git a/app/src/api/savings.ts b/app/src/api/savings.ts new file mode 100644 index 000000000..51f8a6476 --- /dev/null +++ b/app/src/api/savings.ts @@ -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 & { + 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 { + return api('/savings/goals'); +} + +export async function getSavingsGoal(id: number): Promise { + return api(`/savings/goals/${id}`); +} + +export async function createSavingsGoal(payload: SavingsGoalCreate): Promise { + return api('/savings/goals', { method: 'POST', body: payload }); +} + +export async function updateSavingsGoal(id: number, payload: SavingsGoalUpdate): Promise { + return api(`/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 { + return api('/savings/contributions', { method: 'POST', body: payload }); +} + +export async function listSavingsContributions(goalId: number): Promise { + return api(`/savings/goals/${goalId}/contributions`); +} + +export async function listSavingsMilestones(goalId: number): Promise { + return api(`/savings/goals/${goalId}/milestones`); +} + +export async function createSavingsMilestone(payload: SavingsMilestoneCreate): Promise { + return api('/savings/milestones', { method: 'POST', body: payload }); +} + +export async function updateSavingsMilestone(id: number, payload: SavingsMilestoneUpdate): Promise { + return api(`/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' }); +} diff --git a/app/src/components/layout/Navbar.tsx b/app/src/components/layout/Navbar.tsx index c7593b701..4c1a34d86 100644 --- a/app/src/components/layout/Navbar.tsx +++ b/app/src/components/layout/Navbar.tsx @@ -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() { diff --git a/app/src/pages/Savings.tsx b/app/src/pages/Savings.tsx new file mode 100644 index 000000000..1a3bc434c --- /dev/null +++ b/app/src/pages/Savings.tsx @@ -0,0 +1,896 @@ +import { useState, useEffect } from 'react'; +import { + listSavingsGoals, + createSavingsGoal, + updateSavingsGoal, + deleteSavingsGoal, + addSavingsContribution, + listSavingsMilestones, + createSavingsMilestone, + updateSavingsMilestone, + deleteSavingsMilestone, + SavingsGoal, + SavingsGoalCreate, + SavingsMilestone, + SavingsMilestoneCreate, +} from '@/api/savings'; +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 { Input } from '@/components/ui/input'; +import { Textarea } from '@/components/ui/textarea'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/components/ui/dialog'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { Progress } from '@/components/ui/progress'; +import { Calendar, Plus, Target, TrendingUp, TrendingDown, CheckCircle2, Flag, Trash2, Edit, ChevronRight } from 'lucide-react'; + +const statusConfig = { + 'on-track': { label: 'On Track', variant: 'default' as const, color: 'text-success' }, + 'ahead': { label: 'Ahead', variant: 'secondary' as const, color: 'text-primary' }, + 'behind': { label: 'Behind', variant: 'destructive' as const, color: 'text-destructive' }, + 'completed': { label: 'Completed', variant: 'default' as const, color: 'text-success' }, +}; + +function calculateGoalStatus(goal: SavingsGoal): 'on-track' | 'ahead' | 'behind' | 'completed' { + if (goal.current_amount >= goal.target_amount) return 'completed'; + + const now = new Date(); + const deadline = new Date(goal.deadline); + const created = new Date(goal.created_at); + + const totalDays = (deadline.getTime() - created.getTime()) / (1000 * 60 * 60 * 24); + const elapsedDays = (now.getTime() - created.getTime()) / (1000 * 60 * 60 * 24); + + if (totalDays <= 0) return 'behind'; + + const expectedProgress = (elapsedDays / totalDays) * goal.target_amount; + const actualProgress = goal.current_amount; + + const variance = (actualProgress - expectedProgress) / goal.target_amount; + + if (variance > 0.05) return 'ahead'; + if (variance < -0.05) return 'behind'; + return 'on-track'; +} + +function getProgressColor(percentage: number): string { + if (percentage >= 100) return 'bg-success'; + if (percentage >= 75) return 'bg-primary'; + if (percentage >= 50) return 'bg-accent'; + if (percentage >= 25) return 'bg-warning'; + return 'bg-destructive'; +} + +function GoalDialog({ + open, + onOpenChange, + onSubmit, + initialGoal, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + onSubmit: (goal: SavingsGoalCreate) => void; + initialGoal?: SavingsGoal; +}) { + const [title, setTitle] = useState(''); + const [targetAmount, setTargetAmount] = useState(''); + const [deadline, setDeadline] = useState(''); + const [currentAmount, setCurrentAmount] = useState('0'); + + useEffect(() => { + if (initialGoal) { + setTitle(initialGoal.title); + setTargetAmount(String(initialGoal.target_amount)); + setDeadline(initialGoal.deadline.split('T')[0]); + setCurrentAmount(String(initialGoal.current_amount)); + } else { + setTitle(''); + setTargetAmount(''); + setDeadline(''); + setCurrentAmount('0'); + } + }, [initialGoal, open]); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + onSubmit({ + title, + target_amount: parseFloat(targetAmount), + deadline, + current_amount: parseFloat(currentAmount) || 0, + }); + onOpenChange(false); + }; + + return ( + + +
+ + {initialGoal ? 'Edit Savings Goal' : 'Create Savings Goal'} + + {initialGoal ? 'Update your savings goal details.' : 'Set a new savings goal to track your progress.'} + + +
+
+ + setTitle(e.target.value)} + placeholder="e.g., Emergency Fund, Vacation" + required + /> +
+
+ + setTargetAmount(e.target.value)} + placeholder="10000" + required + /> +
+
+ + setCurrentAmount(e.target.value)} + placeholder="0" + /> +
+
+ + setDeadline(e.target.value)} + required + /> +
+
+ + + + +
+
+
+ ); +} + +function MilestoneDialog({ + open, + onOpenChange, + onSubmit, + goalId, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + onSubmit: (milestone: SavingsMilestoneCreate) => void; + goalId: number; +}) { + const [title, setTitle] = useState(''); + const [targetAmount, setTargetAmount] = useState(''); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + onSubmit({ + goal_id: goalId, + title, + target_amount: parseFloat(targetAmount), + }); + onOpenChange(false); + setTitle(''); + setTargetAmount(''); + }; + + return ( + + +
+ + Add Milestone + + Create a milestone to track progress toward your goal. + + +
+
+ + setTitle(e.target.value)} + placeholder="e.g., $1,000 saved" + required + /> +
+
+ + setTargetAmount(e.target.value)} + placeholder="1000" + required + /> +
+
+ + + + +
+
+
+ ); +} + +function ContributionDialog({ + open, + onOpenChange, + onSubmit, + goalTitle, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + onSubmit: (amount: number, note: string) => void; + goalTitle: string; +}) { + const [amount, setAmount] = useState(''); + const [note, setNote] = useState(''); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + onSubmit(parseFloat(amount), note); + onOpenChange(false); + setAmount(''); + setNote(''); + }; + + return ( + + +
+ + Add Contribution + + Record a contribution to your "{goalTitle}" goal. + + +
+
+ + setAmount(e.target.value)} + placeholder="100" + required + /> +
+
+ +