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
18 changes: 18 additions & 0 deletions app/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ 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";
import Household from "./pages/Household";

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

export interface SavingsGoal {
id: number;
name: string;
target_amount: number;
current_amount: number;
currency: string;
deadline: string | null;
progress_pct: number;
created_at: string;
}

export interface SavingsMilestone {
id: number;
goal_id: number;
name: string;
amount: number;
reached_at: string | null;
}

export interface CreateGoalPayload {
name: string;
target_amount: number;
currency?: string;
deadline?: string;
current_amount?: number;
}

export interface UpdateGoalPayload {
name?: string;
target_amount?: number;
current_amount?: number;
add_funds?: number;
currency?: string;
deadline?: string | null;
}

export function listGoals() {
return api<SavingsGoal[]>('/savings/goals');
}

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

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

export function deleteGoal(id: number) {
return api(`/savings/goals/${id}`, { method: 'DELETE' });
}

export function getMilestones(goalId: number) {
return api<SavingsMilestone[]>(`/savings/goals/${goalId}/milestones`);
}
2 changes: 2 additions & 0 deletions app/src/components/layout/Navbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@ const navigation = [
{ name: 'Bills', href: '/bills' },
{ name: 'Reminders', href: '/reminders' },
{ name: 'Expenses', href: '/expenses' },
{ name: 'Savings', href: '/savings' },
{ name: 'Analytics', href: '/analytics' },
{ name: 'Household', href: '/household' },
];

export function Navbar() {
Expand Down
193 changes: 193 additions & 0 deletions app/src/pages/Savings.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
import { useEffect, useState } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { useToast } from '@/components/ui/use-toast';
import {
listGoals,
createGoal,
updateGoal,
deleteGoal,
getMilestones,
type SavingsGoal,
type SavingsMilestone,
} from '@/api/savings';
import { Target, Plus, Trash2, PiggyBank, Trophy } from 'lucide-react';

export default function Savings() {
const [goals, setGoals] = useState<SavingsGoal[]>([]);
const [loading, setLoading] = useState(true);
const [showForm, setShowForm] = useState(false);
const [name, setName] = useState('');
const [targetAmount, setTargetAmount] = useState('');
const [currency, setCurrency] = useState('INR');
const [deadline, setDeadline] = useState('');
const [addFundsId, setAddFundsId] = useState<number | null>(null);
const [fundsAmount, setFundsAmount] = useState('');
const [milestonesMap, setMilestonesMap] = useState<Record<number, SavingsMilestone[]>>({});
const [expandedGoal, setExpandedGoal] = useState<number | null>(null);
const { toast } = useToast();

const fetchGoals = async () => {
try {
const data = await listGoals();
setGoals(data);
} catch (e: any) {

Check warning on line 34 in app/src/pages/Savings.tsx

View workflow job for this annotation

GitHub Actions / frontend

Unexpected any. Specify a different type
toast({ title: 'Error', description: e.message, variant: 'destructive' });
} finally {
setLoading(false);
}
};

useEffect(() => { void fetchGoals(); }, []);

Check warning on line 41 in app/src/pages/Savings.tsx

View workflow job for this annotation

GitHub Actions / frontend

React Hook useEffect has a missing dependency: 'fetchGoals'. Either include it or remove the dependency array

const handleCreate = async () => {
if (!name.trim() || !targetAmount) return;
try {
await createGoal({
name: name.trim(),
target_amount: parseFloat(targetAmount),
currency,
deadline: deadline || undefined,
});
setName(''); setTargetAmount(''); setDeadline(''); setShowForm(false);
toast({ title: 'Goal created!' });
await fetchGoals();
} catch (e: any) {

Check warning on line 55 in app/src/pages/Savings.tsx

View workflow job for this annotation

GitHub Actions / frontend

Unexpected any. Specify a different type
toast({ title: 'Error', description: e.message, variant: 'destructive' });
}
};

const handleAddFunds = async (goalId: number) => {
if (!fundsAmount || parseFloat(fundsAmount) <= 0) return;
try {
await updateGoal(goalId, { add_funds: parseFloat(fundsAmount) });
setAddFundsId(null); setFundsAmount('');
toast({ title: 'Funds added!' });
await fetchGoals();
if (expandedGoal === goalId) await fetchMilestones(goalId);
} catch (e: any) {

Check warning on line 68 in app/src/pages/Savings.tsx

View workflow job for this annotation

GitHub Actions / frontend

Unexpected any. Specify a different type
toast({ title: 'Error', description: e.message, variant: 'destructive' });
}
};

const handleDelete = async (id: number) => {
try {
await deleteGoal(id);
toast({ title: 'Goal deleted' });
await fetchGoals();
} catch (e: any) {

Check warning on line 78 in app/src/pages/Savings.tsx

View workflow job for this annotation

GitHub Actions / frontend

Unexpected any. Specify a different type
toast({ title: 'Error', description: e.message, variant: 'destructive' });
}
};

const fetchMilestones = async (goalId: number) => {
try {
const ms = await getMilestones(goalId);
setMilestonesMap(prev => ({ ...prev, [goalId]: ms }));
} catch { /* ignore */ }
};

const toggleMilestones = async (goalId: number) => {
if (expandedGoal === goalId) {
setExpandedGoal(null);
} else {
setExpandedGoal(goalId);
if (!milestonesMap[goalId]) await fetchMilestones(goalId);
}
};

if (loading) return <div className="p-8 text-center text-muted-foreground">Loading…</div>;

return (
<div className="container-financial py-8 space-y-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<PiggyBank className="h-6 w-6 text-primary" />
<h1 className="text-2xl font-bold">Savings Goals</h1>
</div>
<Button onClick={() => setShowForm(!showForm)} variant="hero" size="sm">
<Plus className="h-4 w-4 mr-1" /> New Goal
</Button>
</div>

{showForm && (
<div className="rounded-xl border bg-card p-4 space-y-3">
<Input placeholder="Goal name" value={name} onChange={e => setName(e.target.value)} />
<div className="flex gap-2">
<Input type="number" placeholder="Target amount" value={targetAmount} onChange={e => setTargetAmount(e.target.value)} />
<Input placeholder="Currency" value={currency} onChange={e => setCurrency(e.target.value)} className="w-24" />
</div>
<Input type="date" placeholder="Deadline (optional)" value={deadline} onChange={e => setDeadline(e.target.value)} />
<Button onClick={handleCreate} size="sm">Create</Button>
</div>
)}

{goals.length === 0 ? (
<div className="text-center py-12 text-muted-foreground">
<Target className="h-12 w-12 mx-auto mb-3 opacity-40" />
<p>No savings goals yet. Create one to start tracking!</p>
</div>
) : (
<div className="space-y-4">
{goals.map(goal => (
<div key={goal.id} className="rounded-xl border bg-card p-4 space-y-3">
<div className="flex items-start justify-between">
<div>
<h3 className="font-semibold text-lg">{goal.name}</h3>
<p className="text-sm text-muted-foreground">
{goal.currency} {goal.current_amount.toLocaleString()} / {goal.target_amount.toLocaleString()}
{goal.deadline && <span className="ml-2">· Due {goal.deadline}</span>}
</p>
</div>
<div className="flex gap-1">
<Button variant="ghost" size="sm" onClick={() => setAddFundsId(addFundsId === goal.id ? null : goal.id)}>
<Plus className="h-4 w-4" />
</Button>
<Button variant="ghost" size="sm" onClick={() => toggleMilestones(goal.id)}>
<Trophy className="h-4 w-4" />
</Button>
<Button variant="ghost" size="sm" onClick={() => handleDelete(goal.id)}>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
</div>

{/* Progress bar */}
<div className="w-full bg-muted rounded-full h-3">
<div
className="bg-primary h-3 rounded-full transition-all"
style={{ width: `${Math.min(goal.progress_pct, 100)}%` }}
/>
</div>
<p className="text-xs text-muted-foreground text-right">{goal.progress_pct}%</p>

{/* Add funds form */}
{addFundsId === goal.id && (
<div className="flex gap-2">
<Input type="number" placeholder="Amount" value={fundsAmount} onChange={e => setFundsAmount(e.target.value)} className="w-40" />
<Button size="sm" onClick={() => handleAddFunds(goal.id)}>Add Funds</Button>
</div>
)}

{/* Milestones */}
{expandedGoal === goal.id && milestonesMap[goal.id] && (
<div className="space-y-1 pt-2 border-t">
<p className="text-xs font-semibold text-muted-foreground mb-1">Milestones</p>
{milestonesMap[goal.id].map(ms => (
<div key={ms.id} className="flex items-center gap-2 text-sm">
<span className={`inline-block h-2 w-2 rounded-full ${ms.reached_at ? 'bg-green-500' : 'bg-muted-foreground/30'}`} />
<span className={ms.reached_at ? 'text-foreground' : 'text-muted-foreground'}>
{ms.name} — {goal.currency} {ms.amount.toLocaleString()}
</span>
{ms.reached_at && <span className="text-xs text-green-600">✓</span>}
</div>
))}
</div>
)}
</div>
))}
</div>
)}
</div>
);
}
27 changes: 27 additions & 0 deletions packages/backend/app/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,33 @@ class UserSubscription(db.Model):
started_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)


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)
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)

milestones = db.relationship(
"SavingsMilestone", backref="goal", cascade="all, delete-orphan", lazy=True
)


class SavingsMilestone(db.Model):
__tablename__ = "savings_milestones"
id = db.Column(db.Integer, primary_key=True)
goal_id = db.Column(
db.Integer, db.ForeignKey("savings_goals.id"), nullable=False
)
name = db.Column(db.String(100), nullable=False)
amount = db.Column(db.Numeric(12, 2), nullable=False)
reached_at = db.Column(db.DateTime, nullable=True)


class AuditLog(db.Model):
__tablename__ = "audit_logs"
id = db.Column(db.Integer, primary_key=True)
Expand Down
2 changes: 2 additions & 0 deletions packages/backend/app/routes/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from .categories import bp as categories_bp
from .docs import bp as docs_bp
from .dashboard import bp as dashboard_bp
from .savings import bp as savings_bp


def register_routes(app: Flask):
Expand All @@ -18,3 +19,4 @@ def register_routes(app: Flask):
app.register_blueprint(categories_bp, url_prefix="/categories")
app.register_blueprint(docs_bp, url_prefix="/docs")
app.register_blueprint(dashboard_bp, url_prefix="/dashboard")
app.register_blueprint(savings_bp, url_prefix="/savings")
Loading
Loading