Skip to content
This repository was archived by the owner on Jun 19, 2026. It is now read-only.
Draft
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
47 changes: 47 additions & 0 deletions app/src/api/savings.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { api } from './client';

export type SavingsGoal = {
id: number;
title: string;
target: number;
current: number;
currency: string;
deadline: string | null;
monthlyTarget: number;
status: 'on-track' | 'ahead' | 'behind' | 'completed';
created_at?: string;
};

export type SavingsGoalCreate = {
title: string;
target_amount: number;
current_amount?: number;
currency?: string;
deadline?: string | null;
};

export type SavingsGoalUpdate = Partial<SavingsGoalCreate>;

export async function listSavingsGoals(): Promise<SavingsGoal[]> {
return api<SavingsGoal[]>('/savings');
}

export async function createSavingsGoal(payload: SavingsGoalCreate): Promise<SavingsGoal> {
return api<SavingsGoal>('/savings', { method: 'POST', body: payload });
}

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

export async function updateSavingsGoal(id: number, payload: SavingsGoalUpdate): Promise<SavingsGoal> {
return api<SavingsGoal>(`/savings/${id}`, { method: 'PATCH', body: payload });
}

export async function contributeSavingsGoal(id: number, amount: number): Promise<SavingsGoal> {
return api<SavingsGoal>(`/savings/${id}/contribute`, { method: 'POST', body: { amount } });
}

export async function deleteSavingsGoal(id: number): Promise<{ message: string }> {
return api(`/savings/${id}`, { method: 'DELETE' });
}
155 changes: 82 additions & 73 deletions app/src/pages/Budgets.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { useState } from 'react';
import { useState, useEffect } from 'react';
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 { Calendar, DollarSign, Plus, PieChart, TrendingDown, TrendingUp, Target, AlertCircle, Settings } from 'lucide-react';
import { Calendar, DollarSign, Plus, PieChart, TrendingDown, TrendingUp, Target, AlertCircle, Settings, Loader2 } from 'lucide-react';
import { listSavingsGoals, type SavingsGoal } from '@/api/savings';

const budgetCategories = [
{
Expand Down Expand Up @@ -67,43 +68,41 @@ const budgetCategories = [
}
];

const budgetGoals = [
{
id: 1,
title: 'Emergency Fund',
target: 10000,
current: 7250,
deadline: 'Dec 2025',
monthlyTarget: 458,
status: 'on-track'
},
{
id: 2,
title: 'Vacation Fund',
target: 3000,
current: 1850,
deadline: 'Jun 2025',
monthlyTarget: 383,
status: 'behind'
},
{
id: 3,
title: 'New Car',
target: 25000,
current: 15600,
deadline: 'Mar 2026',
monthlyTarget: 625,
status: 'ahead'
}
];

export function Budgets() {
const [selectedPeriod] = useState('monthly');

const [savingsGoals, setSavingsGoals] = useState<SavingsGoal[]>([]);
const [goalsLoading, setGoalsLoading] = useState(true);

useEffect(() => {
listSavingsGoals()
.then(setSavingsGoals)
.catch(() => setSavingsGoals([]))
.finally(() => setGoalsLoading(false));
}, []);

const totalAllocated = budgetCategories.reduce((sum, cat) => sum + cat.allocated, 0);
const totalSpent = budgetCategories.reduce((sum, cat) => sum + cat.spent, 0);
const totalRemaining = totalAllocated - totalSpent;

const statusLabel = (status: SavingsGoal['status']) => {
if (status === 'on-track') return 'On Track';
if (status === 'ahead') return 'Ahead';
if (status === 'completed') return 'Completed';
return 'Behind';
};

const statusVariant = (status: SavingsGoal['status']): 'default' | 'secondary' | 'destructive' => {
if (status === 'on-track') return 'default';
if (status === 'ahead' || status === 'completed') return 'secondary';
return 'destructive';
};

const formatDeadline = (deadline: string | null) => {
if (!deadline) return '—';
const d = new Date(deadline);
return d.toLocaleDateString('en-US', { month: 'short', year: 'numeric' });
};

return (
<div className="page-wrap">
<div className="page-header">
Expand Down Expand Up @@ -211,7 +210,7 @@ export function Budgets() {
{budgetCategories.map((category) => {
const percentage = (category.spent / category.allocated) * 100;
const isOverBudget = category.remaining < 0;

return (
<div key={category.id} className="space-y-3 interactive-row">
<div className="flex items-center justify-between">
Expand Down Expand Up @@ -278,48 +277,58 @@ export function Budgets() {
</FinancialCardDescription>
</FinancialCardHeader>
<FinancialCardContent>
<div className="space-y-4">
{budgetGoals.map((goal) => {
const percentage = (goal.current / goal.target) * 100;

return (
<div key={goal.id} className="interactive-row p-3 rounded-lg border border-border">
<div className="flex items-center justify-between mb-2">
<div className="font-medium text-foreground text-sm">
{goal.title}
</div>
<Badge
variant={
goal.status === 'on-track' ? 'default' :
goal.status === 'ahead' ? 'secondary' : 'destructive'
}
className="text-xs"
>
{goal.status === 'on-track' ? 'On Track' :
goal.status === 'ahead' ? 'Ahead' : 'Behind'}
</Badge>
</div>
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">
${goal.current.toLocaleString()} / ${goal.target.toLocaleString()}
</span>
<span className="text-foreground font-medium">
{percentage.toFixed(0)}%
</span>
</div>
<div className="chart-track">
<div className="chart-fill-success" style={{ width: `${Math.min(percentage, 100)}%` }} />
{goalsLoading ? (
<div className="flex justify-center py-6">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
</div>
) : savingsGoals.length === 0 ? (
<div className="text-center py-6 text-muted-foreground text-sm">
No savings goals yet. Add one to get started!
</div>
) : (
<div className="space-y-4">
{savingsGoals.map((goal) => {
const percentage = Math.min((goal.current / goal.target) * 100, 100);
return (
<div key={goal.id} className="interactive-row p-3 rounded-lg border border-border">
<div className="flex items-center justify-between mb-2">
<div className="font-medium text-foreground text-sm">
{goal.title}
</div>
<Badge
variant={statusVariant(goal.status)}
className="text-xs"
>
{statusLabel(goal.status)}
</Badge>
</div>
<div className="flex justify-between text-xs text-muted-foreground">
<span>Target: {goal.deadline}</span>
<span>${goal.monthlyTarget}/mo</span>
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">
{goal.currency} {goal.current.toLocaleString()} / {goal.target.toLocaleString()}
</span>
<span className="text-foreground font-medium">
{percentage.toFixed(0)}%
</span>
</div>
<div className="chart-track">
<div
className="chart-fill-success"
style={{ width: `${percentage}%` }}
/>
</div>
<div className="flex justify-between text-xs text-muted-foreground">
<span>Target: {formatDeadline(goal.deadline)}</span>
{goal.monthlyTarget > 0 && (
<span>{goal.currency} {goal.monthlyTarget.toLocaleString()}/mo</span>
)}
</div>
</div>
</div>
</div>
);
})}
</div>
);
})}
</div>
)}
</FinancialCardContent>
<FinancialCardFooter>
<Button variant="financial" size="sm" className="w-full">
Expand Down
13 changes: 13 additions & 0 deletions packages/backend/app/db/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -123,3 +123,16 @@ CREATE TABLE IF NOT EXISTS audit_logs (
action VARCHAR(100) NOT NULL,
created_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,
title VARCHAR(200) NOT NULL,
target_amount NUMERIC(12,2) NOT NULL,
current_amount NUMERIC(12,2) NOT NULL DEFAULT 0,
currency VARCHAR(10) NOT NULL DEFAULT 'INR',
deadline DATE,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_savings_goals_user ON savings_goals(user_id);
15 changes: 15 additions & 0 deletions packages/backend/app/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,21 @@ 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)
title = 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), nullable=False, default=0)
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)
updated_at = db.Column(
db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False
)


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