Skip to content
This repository was archived by the owner on Jun 19, 2026. It is now read-only.

Commit e08cf8a

Browse files
committed
feat: Goal-based savings tracking & milestones (Fixes #133)
1 parent 080c371 commit e08cf8a

7 files changed

Lines changed: 260 additions & 0 deletions

File tree

app/src/App.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ const App = () => (
9494
</Route>
9595
<Route path="/signin" element={<SignIn />} />
9696
<Route path="/register" element={<Register />} />
97+
<Route path="goals" element={<ProtectedRoute><Goals /></ProtectedRoute>} />
9798
{/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */}
9899
<Route path="*" element={<NotFound />} />
99100
</Routes>

app/src/api/goals.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import axios from "axios";
2+
3+
const api = axios.create({
4+
baseURL: "/api/goals",
5+
});
6+
7+
api.interceptors.request.use((config) => {
8+
const token = localStorage.getItem("token");
9+
if (token) {
10+
config.headers.Authorization = `Bearer ${token}`;
11+
}
12+
return config;
13+
});
14+
15+
export interface GoalMilestone {
16+
id: number;
17+
name: string;
18+
target_amount: number;
19+
achieved: boolean;
20+
}
21+
22+
export interface Goal {
23+
id: number;
24+
name: string;
25+
target_amount: number;
26+
current_amount: number;
27+
currency: string;
28+
deadline: string | null;
29+
created_at: string;
30+
milestones: GoalMilestone[];
31+
}
32+
33+
export const getGoals = async (): Promise<Goal[]> => {
34+
const response = await api.get("");
35+
return response.data;
36+
};
37+
38+
export const createGoal = async (data: any) => {
39+
const response = await api.post("", data);
40+
return response.data;
41+
};
42+
43+
export const updateGoal = async (id: number, data: any) => {
44+
const response = await api.put(`/${id}`, data);
45+
return response.data;
46+
};
47+
48+
export const deleteGoal = async (id: number) => {
49+
const response = await api.delete(`/${id}`);
50+
return response.data;
51+
};

app/src/pages/Goals.tsx

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { useEffect, useState } from "react";
2+
import { FinancialCard, FinancialCardContent, FinancialCardDescription, FinancialCardHeader, FinancialCardTitle } from "@/components/ui/financial-card";
3+
import { Button } from "@/components/ui/button";
4+
import { getGoals, Goal, createGoal } from "@/api/goals";
5+
6+
export function Goals() {
7+
const [goals, setGoals] = useState<Goal[]>([]);
8+
const [loading, setLoading] = useState(true);
9+
10+
useEffect(() => {
11+
getGoals().then((data) => {
12+
setGoals(data);
13+
setLoading(false);
14+
}).catch(err => {
15+
console.error(err);
16+
setLoading(false);
17+
});
18+
}, []);
19+
20+
const handleCreate = async () => {
21+
const name = prompt("Goal name:");
22+
if (!name) return;
23+
const target = parseFloat(prompt("Target amount:") || "0");
24+
if (target <= 0) return;
25+
26+
await createGoal({ name, target_amount: target, current_amount: 0 });
27+
const updated = await getGoals();
28+
setGoals(updated);
29+
};
30+
31+
if (loading) return <div>Loading...</div>;
32+
33+
return (
34+
<div className="space-y-6">
35+
<div className="flex justify-between items-center">
36+
<h1 className="text-3xl font-bold">Goals & Milestones</h1>
37+
<Button onClick={handleCreate}>New Goal</Button>
38+
</div>
39+
40+
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
41+
{goals.map((g) => (
42+
<FinancialCard key={g.id}>
43+
<FinancialCardHeader>
44+
<FinancialCardTitle>{g.name}</FinancialCardTitle>
45+
<FinancialCardDescription>
46+
{g.current_amount} / {g.target_amount} {g.currency}
47+
</FinancialCardDescription>
48+
</FinancialCardHeader>
49+
<FinancialCardContent>
50+
<div className="w-full bg-secondary rounded-full h-2.5">
51+
<div
52+
className="bg-primary h-2.5 rounded-full"
53+
style={{ width: `${Math.min((g.current_amount / g.target_amount) * 100, 100)}%` }}
54+
></div>
55+
</div>
56+
</FinancialCardContent>
57+
</FinancialCard>
58+
))}
59+
</div>
60+
{goals.length === 0 && <p className="text-muted-foreground">No goals set yet.</p>}
61+
</div>
62+
);
63+
}

packages/backend/app/models.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,3 +133,26 @@ class AuditLog(db.Model):
133133
user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=True)
134134
action = db.Column(db.String(100), nullable=False)
135135
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
136+
137+
138+
class Goal(db.Model):
139+
__tablename__ = "goals"
140+
id = db.Column(db.Integer, primary_key=True)
141+
user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False)
142+
name = db.Column(db.String(255), nullable=False)
143+
target_amount = db.Column(db.Numeric(12, 2), nullable=False)
144+
current_amount = db.Column(db.Numeric(12, 2), default=0.00, nullable=False)
145+
currency = db.Column(db.String(10), default="INR", nullable=False)
146+
deadline = db.Column(db.Date, nullable=True)
147+
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
148+
149+
milestones = db.relationship("GoalMilestone", backref="goal", lazy=True, cascade="all, delete-orphan")
150+
151+
class GoalMilestone(db.Model):
152+
__tablename__ = "goal_milestones"
153+
id = db.Column(db.Integer, primary_key=True)
154+
goal_id = db.Column(db.Integer, db.ForeignKey("goals.id"), nullable=False)
155+
name = db.Column(db.String(255), nullable=False)
156+
target_amount = db.Column(db.Numeric(12, 2), nullable=False)
157+
achieved = db.Column(db.Boolean, default=False, nullable=False)
158+
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)

packages/backend/app/routes/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from .categories import bp as categories_bp
88
from .docs import bp as docs_bp
99
from .dashboard import bp as dashboard_bp
10+
from .goals import bp as goals_bp
1011

1112

1213
def register_routes(app: Flask):
@@ -18,3 +19,4 @@ def register_routes(app: Flask):
1819
app.register_blueprint(categories_bp, url_prefix="/categories")
1920
app.register_blueprint(docs_bp, url_prefix="/docs")
2021
app.register_blueprint(dashboard_bp, url_prefix="/dashboard")
22+
app.register_blueprint(goals_bp, url_prefix="/goals")
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
from flask import Blueprint, jsonify, request
2+
from app.models import Goal, GoalMilestone, db
3+
from app.auth import token_required
4+
from datetime import datetime
5+
6+
bp = Blueprint("goals", __name__)
7+
8+
@bp.route("", methods=["GET"])
9+
@token_required
10+
def get_goals(current_user):
11+
goals = Goal.query.filter_by(user_id=current_user.id).all()
12+
result = []
13+
for g in goals:
14+
milestones = GoalMilestone.query.filter_by(goal_id=g.id).all()
15+
result.append({
16+
"id": g.id,
17+
"name": g.name,
18+
"target_amount": float(g.target_amount),
19+
"current_amount": float(g.current_amount),
20+
"currency": g.currency,
21+
"deadline": g.deadline.isoformat() if g.deadline else None,
22+
"created_at": g.created_at.isoformat(),
23+
"milestones": [{
24+
"id": m.id,
25+
"name": m.name,
26+
"target_amount": float(m.target_amount),
27+
"achieved": m.achieved
28+
} for m in milestones]
29+
})
30+
return jsonify(result), 200
31+
32+
@bp.route("", methods=["POST"])
33+
@token_required
34+
def create_goal(current_user):
35+
data = request.json
36+
try:
37+
deadline = datetime.strptime(data["deadline"], "%Y-%m-%d").date() if data.get("deadline") else None
38+
except ValueError:
39+
return jsonify({"error": "Invalid date format, use YYYY-MM-DD"}), 400
40+
41+
new_goal = Goal(
42+
user_id=current_user.id,
43+
name=data["name"],
44+
target_amount=data["target_amount"],
45+
current_amount=data.get("current_amount", 0.0),
46+
currency=data.get("currency", "INR"),
47+
deadline=deadline
48+
)
49+
db.session.add(new_goal)
50+
db.session.commit()
51+
52+
if "milestones" in data:
53+
for m in data["milestones"]:
54+
new_ms = GoalMilestone(
55+
goal_id=new_goal.id,
56+
name=m["name"],
57+
target_amount=m["target_amount"],
58+
achieved=m.get("achieved", False)
59+
)
60+
db.session.add(new_ms)
61+
db.session.commit()
62+
63+
return jsonify({"message": "Goal created successfully", "id": new_goal.id}), 201
64+
65+
@bp.route("/<int:goal_id>", methods=["PUT"])
66+
@token_required
67+
def update_goal(current_user, goal_id):
68+
goal = Goal.query.filter_by(id=goal_id, user_id=current_user.id).first()
69+
if not goal:
70+
return jsonify({"error": "Goal not found"}), 404
71+
72+
data = request.json
73+
if "name" in data:
74+
goal.name = data["name"]
75+
if "target_amount" in data:
76+
goal.target_amount = data["target_amount"]
77+
if "current_amount" in data:
78+
goal.current_amount = data["current_amount"]
79+
if "deadline" in data:
80+
try:
81+
goal.deadline = datetime.strptime(data["deadline"], "%Y-%m-%d").date() if data["deadline"] else None
82+
except ValueError:
83+
return jsonify({"error": "Invalid date format"}), 400
84+
85+
db.session.commit()
86+
return jsonify({"message": "Goal updated successfully"}), 200
87+
88+
@bp.route("/<int:goal_id>", methods=["DELETE"])
89+
@token_required
90+
def delete_goal(current_user, goal_id):
91+
goal = Goal.query.filter_by(id=goal_id, user_id=current_user.id).first()
92+
if not goal:
93+
return jsonify({"error": "Goal not found"}), 404
94+
db.session.delete(goal)
95+
db.session.commit()
96+
return jsonify({"message": "Goal deleted successfully"}), 200
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import pytest
2+
from app.models import Goal, GoalMilestone, db
3+
4+
def test_get_goals_empty(client, auth_headers):
5+
response = client.get("/goals", headers=auth_headers)
6+
assert response.status_code == 200
7+
assert response.json == []
8+
9+
def test_create_goal(client, auth_headers):
10+
data = {
11+
"name": "Buy a car",
12+
"target_amount": 10000.0,
13+
"deadline": "2026-12-31",
14+
"milestones": [
15+
{"name": "Save 5k", "target_amount": 5000.0}
16+
]
17+
}
18+
response = client.post("/goals", json=data, headers=auth_headers)
19+
assert response.status_code == 201
20+
21+
res2 = client.get("/goals", headers=auth_headers)
22+
assert len(res2.json) == 1
23+
assert res2.json[0]["name"] == "Buy a car"
24+
assert len(res2.json[0]["milestones"]) == 1

0 commit comments

Comments
 (0)