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
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 Goals from "./pages/Goals";

const queryClient = new QueryClient({
defaultOptions: {
Expand Down Expand Up @@ -83,6 +84,14 @@ const App = () => (
</ProtectedRoute>
}
/>
<Route
path="goals"
element={
<ProtectedRoute>
<Goals />
</ProtectedRoute>
}
/>
<Route
path="account"
element={
Expand Down
77 changes: 77 additions & 0 deletions app/src/api/goals.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { api } from './client';

export type GoalStatus = 'ACTIVE' | 'COMPLETED' | 'CANCELLED';

export type SavingsGoal = {
id: number;
name: string;
target_amount: number;
current_amount: number;
currency: string;
deadline: string | null;
status: GoalStatus;
created_at: string;
};

export type GoalCreate = {
name: string;
target_amount: number;
currency?: string;
deadline?: string;
};

export type Contribution = {
id: number;
goal_id: number;
amount: number;
note: string | null;
contributed_at: string;
};

export type GoalProgress = {
goal_id: number;
name: string;
target: number;
current: number;
remaining: number;
progress_pct: number;
status: GoalStatus;
on_track: boolean;
deadline?: string;
days_left?: number;
daily_savings_needed?: number;
milestones: { pct: number; reached: boolean; amount: number }[];
};

export async function listGoals(status?: GoalStatus): Promise<SavingsGoal[]> {
const qs = status ? `?status=${status}` : '';
return api<SavingsGoal[]>(`/goals${qs}`);
}

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

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

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

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

export async function getProgress(id: number): Promise<GoalProgress> {
return api<GoalProgress>(`/goals/${id}/progress`);
}

export async function addContribution(id: number, amount: number, note?: string): Promise<Contribution> {
return api<Contribution>(`/goals/${id}/contribute`, { method: 'POST', body: { amount, note } });
}

export async function listContributions(id: number): Promise<Contribution[]> {
return api<Contribution[]>(`/goals/${id}/contributions`);
}
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: 'Goals', href: '/goals' },
];

export function Navbar() {
Expand Down
212 changes: 212 additions & 0 deletions app/src/pages/Goals.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
import { useState, useEffect, useCallback } from 'react';
import { FinancialCard, FinancialCardContent, FinancialCardHeader, FinancialCardTitle } from '@/components/ui/financial-card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Target, Plus, TrendingUp, Calendar, CheckCircle2, XCircle } from 'lucide-react';
import { useToast } from '@/hooks/use-toast';
import { listGoals, createGoal, getProgress, addContribution, cancelGoal, type SavingsGoal, type GoalProgress } from '@/api/goals';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
import { formatMoney } from '@/lib/currency';

const STATUS_COLORS: Record<string, string> = {
ACTIVE: 'bg-blue-100 text-blue-800',
COMPLETED: 'bg-green-100 text-green-800',
CANCELLED: 'bg-gray-100 text-gray-800',
};

export default function Goals() {
const { toast } = useToast();
const [goals, setGoals] = useState<SavingsGoal[]>([]);
const [progressMap, setProgressMap] = useState<Record<number, GoalProgress>>({});
const [loading, setLoading] = useState(true);
const [createOpen, setCreateOpen] = useState(false);
const [contributeGoalId, setContributeGoalId] = useState<number | null>(null);
const [newGoal, setNewGoal] = useState({ name: '', target_amount: '', currency: 'INR', deadline: '' });
const [contributeAmount, setContributeAmount] = useState('');
const [contributeNote, setContributeNote] = useState('');

const loadData = useCallback(async () => {
try {
const g = await listGoals();
setGoals(g);
const progEntries = await Promise.all(
g.filter(gl => gl.status === 'ACTIVE').map(async gl => {
try {
const p = await getProgress(gl.id);
return [gl.id, p] as [number, GoalProgress];
} catch { return null; }
})
);
const map: Record<number, GoalProgress> = {};
progEntries.forEach(e => { if (e) map[e[0]] = e[1]; });
setProgressMap(map);
} catch (e: unknown) {
toast({ title: 'Error', description: e instanceof Error ? e.message : 'Failed to load goals', variant: 'destructive' });
} finally { setLoading(false); }
}, [toast]);

useEffect(() => { loadData(); }, [loadData]);

const handleCreate = async () => {
try {
await createGoal({
name: newGoal.name,
target_amount: parseFloat(newGoal.target_amount),
currency: newGoal.currency,
deadline: newGoal.deadline || undefined,
});
toast({ title: 'Goal created' });
setCreateOpen(false);
setNewGoal({ name: '', target_amount: '', currency: 'INR', deadline: '' });
loadData();
} catch (e: unknown) {
toast({ title: 'Error', description: e instanceof Error ? e.message : 'Failed', variant: 'destructive' });
}
};

const handleContribute = async () => {
if (!contributeGoalId) return;
try {
await addContribution(contributeGoalId, parseFloat(contributeAmount), contributeNote || undefined);
toast({ title: 'Contribution added' });
setContributeGoalId(null);
setContributeAmount('');
setContributeNote('');
loadData();
} catch (e: unknown) {
toast({ title: 'Error', description: e instanceof Error ? e.message : 'Failed', variant: 'destructive' });
}
};

const handleCancel = async (id: number) => {
try {
await cancelGoal(id);
toast({ title: 'Goal cancelled' });
loadData();
} catch (e: unknown) {
toast({ title: 'Error', description: e instanceof Error ? e.message : 'Failed', variant: 'destructive' });
}
};

if (loading) return <div className="flex justify-center p-8">Loading...</div>;

return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Savings Goals</h1>
<p className="text-muted-foreground">Track your savings goals and milestones</p>
</div>
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogTrigger asChild>
<Button><Plus className="mr-2 h-4 w-4" /> New Goal</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Create Savings Goal</DialogTitle>
<DialogDescription>Set a target and track your progress.</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<input className="w-full rounded-md border px-3 py-2" placeholder="Goal name (e.g. Emergency Fund)" value={newGoal.name} onChange={e => setNewGoal(p => ({ ...p, name: e.target.value }))} />
<input className="w-full rounded-md border px-3 py-2" type="number" placeholder="Target amount" value={newGoal.target_amount} onChange={e => setNewGoal(p => ({ ...p, target_amount: e.target.value }))} />
<input className="w-full rounded-md border px-3 py-2" placeholder="Currency" value={newGoal.currency} onChange={e => setNewGoal(p => ({ ...p, currency: e.target.value }))} />
<input className="w-full rounded-md border px-3 py-2" type="date" placeholder="Deadline (optional)" value={newGoal.deadline} onChange={e => setNewGoal(p => ({ ...p, deadline: e.target.value }))} />
</div>
<DialogFooter>
<Button onClick={handleCreate} disabled={!newGoal.name || !newGoal.target_amount}>Create</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>

{/* Contribute Dialog */}
<Dialog open={contributeGoalId !== null} onOpenChange={open => { if (!open) setContributeGoalId(null); }}>
<DialogContent>
<DialogHeader>
<DialogTitle>Add Contribution</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<input className="w-full rounded-md border px-3 py-2" type="number" placeholder="Amount" value={contributeAmount} onChange={e => setContributeAmount(e.target.value)} />
<input className="w-full rounded-md border px-3 py-2" placeholder="Note (optional)" value={contributeNote} onChange={e => setContributeNote(e.target.value)} />
</div>
<DialogFooter>
<Button onClick={handleContribute} disabled={!contributeAmount}>Add</Button>
</DialogFooter>
</DialogContent>
</Dialog>

{goals.length === 0 ? (
<FinancialCard>
<FinancialCardContent className="flex flex-col items-center justify-center py-12">
<Target className="h-12 w-12 text-muted-foreground mb-4" />
<p className="text-muted-foreground">No goals yet. Create your first savings goal!</p>
</FinancialCardContent>
</FinancialCard>
) : (
<div className="grid gap-4 md:grid-cols-2">
{goals.map(goal => {
const progress = progressMap[goal.id];
const pct = progress?.progress_pct ?? (goal.target_amount > 0 ? (goal.current_amount / goal.target_amount) * 100 : 0);

return (
<FinancialCard key={goal.id}>
<FinancialCardHeader className="flex flex-row items-center justify-between pb-2">
<div className="flex items-center gap-2">
{goal.status === 'COMPLETED' ? <CheckCircle2 className="h-5 w-5 text-green-600" /> : <Target className="h-5 w-5 text-muted-foreground" />}
<FinancialCardTitle className="text-base">{goal.name}</FinancialCardTitle>
</div>
<Badge className={STATUS_COLORS[goal.status] || ''}>{goal.status}</Badge>
</FinancialCardHeader>
<FinancialCardContent className="space-y-3">
<div className="flex justify-between text-sm">
<span>{formatMoney(goal.current_amount, goal.currency)}</span>
<span className="text-muted-foreground">of {formatMoney(goal.target_amount, goal.currency)}</span>
</div>
{/* Progress bar */}
<div className="w-full bg-gray-200 rounded-full h-3">
<div className="bg-primary h-3 rounded-full transition-all" style={{ width: `${Math.min(pct, 100)}%` }} />
</div>
<div className="flex justify-between text-xs text-muted-foreground">
<span>{pct.toFixed(1)}%</span>
{goal.deadline && (
<span className="flex items-center gap-1">
<Calendar className="h-3 w-3" />
{goal.deadline}
</span>
)}
</div>
{/* Milestones */}
{progress?.milestones && (
<div className="flex gap-2">
{progress.milestones.map(m => (
<div key={m.pct} className={`text-xs px-2 py-0.5 rounded ${m.reached ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-500'}`}>
{m.pct}%
</div>
))}
</div>
)}
{progress?.daily_savings_needed && (
<p className="text-xs text-muted-foreground flex items-center gap-1">
<TrendingUp className="h-3 w-3" />
Save {formatMoney(progress.daily_savings_needed, goal.currency)}/day to reach your goal
</p>
)}
{goal.status === 'ACTIVE' && (
<div className="flex gap-2 pt-2">
<Button size="sm" onClick={() => setContributeGoalId(goal.id)}>
<Plus className="mr-1 h-3 w-3" /> Contribute
</Button>
<Button size="sm" variant="ghost" onClick={() => handleCancel(goal.id)}>
<XCircle className="mr-1 h-3 w-3" /> Cancel
</Button>
</div>
)}
</FinancialCardContent>
</FinancialCard>
);
})}
</div>
)}
</div>
);
}
26 changes: 26 additions & 0 deletions packages/backend/app/db/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,32 @@ CREATE TABLE IF NOT EXISTS user_subscriptions (
started_at TIMESTAMP NOT NULL DEFAULT NOW()
);

CREATE TABLE IF NOT EXISTS savings_goals (
id SERIAL PRIMARY KEY,
user_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name VARCHAR(200) NOT NULL,
target_amount NUMERIC(12,2) NOT NULL,
Comment on lines +120 to +124
Copy link

Copilot AI Mar 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This feature introduces queries that filter goals by user_id (and optionally status) and order by created_at, but savings_goals is created without any supporting indexes. Add an index such as (user_id, status, created_at DESC) (or at least (user_id, created_at DESC)) to keep list/progress operations fast as the table grows.

Copilot uses AI. Check for mistakes.
current_amount NUMERIC(12,2) NOT NULL DEFAULT 0,
currency VARCHAR(10) NOT NULL DEFAULT 'INR',
deadline DATE,
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);

CREATE INDEX IF NOT EXISTS idx_savings_goals_user_status ON savings_goals(user_id, status, created_at DESC);

CREATE TABLE IF NOT EXISTS goal_contributions (
id SERIAL PRIMARY KEY,
goal_id INT NOT NULL REFERENCES savings_goals(id) ON DELETE CASCADE,
amount NUMERIC(12,2) NOT NULL,
note VARCHAR(200),
Comment on lines +135 to +139
Copy link

Copilot AI Mar 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

goal_contributions will be queried by goal_id and ordered by contribution time, but the table has no index to support that access pattern. Add an index such as (goal_id, contributed_at DESC) (or (goal_id, created_at DESC)) to avoid slow scans as contributions grow.

Copilot uses AI. Check for mistakes.
contributed_at DATE NOT NULL DEFAULT CURRENT_DATE,
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);

CREATE INDEX IF NOT EXISTS idx_goal_contributions_goal ON goal_contributions(goal_id, contributed_at DESC);

CREATE TABLE IF NOT EXISTS audit_logs (
id SERIAL PRIMARY KEY,
user_id INT REFERENCES users(id) ON DELETE SET NULL,
Expand Down
32 changes: 32 additions & 0 deletions packages/backend/app/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,38 @@ class UserSubscription(db.Model):
started_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)


class GoalStatus(str, Enum):
ACTIVE = "ACTIVE"
COMPLETED = "COMPLETED"
CANCELLED = "CANCELLED"


class SavingsGoal(db.Model):
__tablename__ = "savings_goals"
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False)
name = db.Column(db.String(200), nullable=False)
target_amount = db.Column(db.Numeric(12, 2), nullable=False)
current_amount = db.Column(db.Numeric(12, 2), default=0, nullable=False)
currency = db.Column(db.String(10), default="INR", nullable=False)
deadline = db.Column(db.Date, nullable=True)
status = db.Column(db.String(20), default=GoalStatus.ACTIVE.value, nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
updated_at = db.Column(
db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False
)


class GoalContribution(db.Model):
__tablename__ = "goal_contributions"
id = db.Column(db.Integer, primary_key=True)
goal_id = db.Column(db.Integer, db.ForeignKey("savings_goals.id"), nullable=False)
amount = db.Column(db.Numeric(12, 2), nullable=False)
note = db.Column(db.String(200), nullable=True)
contributed_at = db.Column(db.Date, default=date.today, nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)


class AuditLog(db.Model):
__tablename__ = "audit_logs"
id = db.Column(db.Integer, primary_key=True)
Expand Down
Loading