diff --git a/app/src/App.tsx b/app/src/App.tsx index f0dc5942..edb8dd89 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 LifestyleInflation from "./pages/LifestyleInflation"; const queryClient = new QueryClient({ defaultOptions: { @@ -91,6 +92,14 @@ const App = () => ( } /> + + + + } + /> } /> } /> diff --git a/app/src/api/lifestyle.ts b/app/src/api/lifestyle.ts new file mode 100644 index 00000000..139055b4 --- /dev/null +++ b/app/src/api/lifestyle.ts @@ -0,0 +1,43 @@ +import api from "./index"; + +export interface InflationTrend { + month: string; + amount: number; +} + +export interface InflatedCategory { + category_id: number; + category_name: string; + recent_avg_monthly: number; + previous_avg_monthly: number; + pct_change: number; + abs_change_monthly: number; + annualised_extra: number; + trend: InflationTrend[]; +} + +export interface LifestyleInflationSummary { + inflated_count: number; + stable_count: number; + total_extra_monthly_spend: number; + total_extra_annual_spend: number; +} + +export interface LifestyleInflationData { + inflated_categories: InflatedCategory[]; + stable_categories: InflatedCategory[]; + summary: LifestyleInflationSummary; + window_months: number; + inflation_threshold_pct: number; +} + +export async function getLifestyleInflation( + windowMonths = 3, + thresholdPct = 10 +): Promise { + const { data } = await api.get( + "/insights/lifestyle-inflation", + { params: { window_months: windowMonths, threshold_pct: thresholdPct } } + ); + return data; +} diff --git a/app/src/components/layout/Navbar.tsx b/app/src/components/layout/Navbar.tsx index c7593b70..b02def94 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: 'Inflation', href: '/lifestyle' }, ]; export function Navbar() { diff --git a/app/src/pages/LifestyleInflation.tsx b/app/src/pages/LifestyleInflation.tsx new file mode 100644 index 00000000..af65cbcc --- /dev/null +++ b/app/src/pages/LifestyleInflation.tsx @@ -0,0 +1,225 @@ +import { useEffect, useState } from "react"; +import { getLifestyleInflation, LifestyleInflationData, InflatedCategory } from "../api/lifestyle"; + +const WINDOW_OPTIONS = [1, 2, 3, 6]; + +function TrendBar({ trend }: { trend: { month: string; amount: number }[] }) { + const max = Math.max(...trend.map((t) => t.amount), 1); + return ( +
+ {trend.map((t, i) => ( +
+
= trend.length / 2 ? "#ef4444" : "#94a3b8", + }} + title={`${t.month}: ${t.amount.toFixed(2)}`} + /> +
+ ))} +
+ ); +} + +function CategoryCard({ cat, currency }: { cat: InflatedCategory; currency: string }) { + const sign = cat.pct_change >= 0 ? "+" : ""; + const color = + cat.pct_change >= 50 + ? "bg-red-50 border-red-200" + : cat.pct_change >= 25 + ? "bg-orange-50 border-orange-200" + : "bg-yellow-50 border-yellow-200"; + const badge = + cat.pct_change >= 50 + ? "bg-red-100 text-red-700" + : cat.pct_change >= 25 + ? "bg-orange-100 text-orange-700" + : "bg-yellow-100 text-yellow-700"; + + return ( +
+
+
+

{cat.category_name}

+

+ {currency} {cat.previous_avg_monthly.toFixed(2)} → {currency}{" "} + {cat.recent_avg_monthly.toFixed(2)} /month +

+
+ + {sign}{cat.pct_change.toFixed(1)}% + +
+
+
+ Extra/month: + + {currency} {cat.abs_change_monthly.toFixed(2)} + +
+
+ Extra/year: + + {currency} {cat.annualised_extra.toFixed(2)} + +
+
+ +

grey = previous window · red = recent window

+
+ ); +} + +export default function LifestyleInflation() { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [window, setWindow] = useState(3); + const [threshold, setThreshold] = useState(10); + const [showStable, setShowStable] = useState(false); + + const currency = "₹"; + + useEffect(() => { + setLoading(true); + setError(null); + getLifestyleInflation(window, threshold) + .then(setData) + .catch((e) => setError(e.message)) + .finally(() => setLoading(false)); + }, [window, threshold]); + + return ( +
+
+

Lifestyle Inflation

+

+ Categories where your spending has grown compared to the previous period. +

+
+ + {/* Controls */} +
+
+ +
+ {WINDOW_OPTIONS.map((w) => ( + + ))} +
+
+
+ + +
+
+ + {loading && ( +
+ Analysing spending patterns… +
+ )} + + {error && ( +
+ {error} +
+ )} + + {data && !loading && ( + <> + {/* Summary banner */} +
+
+

+ {data.summary.inflated_count} +

+

Inflated categories

+
+
+

+ {data.summary.stable_count} +

+

Stable categories

+
+
+

+ {currency} {data.summary.total_extra_monthly_spend.toFixed(2)} +

+

Extra spend/month

+
+
+

+ {currency} {data.summary.total_extra_annual_spend.toFixed(2)} +

+

Extra spend/year

+
+
+ + {/* Inflated categories */} + {data.inflated_categories.length === 0 ? ( +
+

No lifestyle inflation detected 🎉

+

+ Your spending is stable or decreasing across all categories. +

+
+ ) : ( +
+

+ Inflated Categories ({data.inflated_categories.length}) +

+
+ {data.inflated_categories.map((cat) => ( + + ))} +
+
+ )} + + {/* Stable categories (collapsible) */} + {data.stable_categories.length > 0 && ( +
+ + {showStable && ( +
+ {data.stable_categories.map((cat) => ( + + ))} +
+ )} +
+ )} + + )} +
+ ); +} diff --git a/packages/backend/app/routes/insights.py b/packages/backend/app/routes/insights.py index bfc02e43..e2134ca9 100644 --- a/packages/backend/app/routes/insights.py +++ b/packages/backend/app/routes/insights.py @@ -2,6 +2,7 @@ from flask import Blueprint, jsonify, request from flask_jwt_extended import jwt_required, get_jwt_identity from ..services.ai import monthly_budget_suggestion +from ..services.lifestyle import detect_lifestyle_inflation import logging bp = Blueprint("insights", __name__) @@ -23,3 +24,27 @@ def budget_suggestion(): ) logger.info("Budget suggestion served user=%s month=%s", uid, ym) return jsonify(suggestion) + + +@bp.get("/lifestyle-inflation") +@jwt_required() +def lifestyle_inflation(): + uid = int(get_jwt_identity()) + try: + window = max(1, min(12, int(request.args.get("window_months", 3)))) + except (TypeError, ValueError): + window = 3 + try: + threshold = float(request.args.get("threshold_pct", 10.0)) + threshold = max(0.0, min(100.0, threshold)) + except (TypeError, ValueError): + threshold = 10.0 + + result = detect_lifestyle_inflation(uid, window_months=window, inflation_threshold_pct=threshold) + logger.info( + "Lifestyle inflation served user=%s window=%s inflated=%s", + uid, + window, + result["summary"]["inflated_count"], + ) + return jsonify(result) diff --git a/packages/backend/app/services/lifestyle.py b/packages/backend/app/services/lifestyle.py new file mode 100644 index 00000000..2aa5b16e --- /dev/null +++ b/packages/backend/app/services/lifestyle.py @@ -0,0 +1,190 @@ +""" +Lifestyle inflation detection service. + +Compares spending per category between two equal time windows (recent vs +previous) and surfaces categories where spending has grown materially, +indicating lifestyle inflation. +""" + +from __future__ import annotations + +from collections import defaultdict +from datetime import date +from decimal import Decimal + +from sqlalchemy import func, extract + +from ..extensions import db +from ..models import Expense, Category + + +def _monthly_totals(user_id: int, months: int) -> dict[int, dict[str, Decimal]]: + """ + Return {category_id: {YYYY-MM: total_spent}} for EXPENSE rows only, + covering the last `months` calendar months up to today. + """ + today = date.today() + # Build a simple list of (year, month) for the last `months` months + periods: list[tuple[int, int]] = [] + y, m = today.year, today.month + for _ in range(months): + periods.append((y, m)) + m -= 1 + if m == 0: + m = 12 + y -= 1 + + if not periods: + return {} + + oldest_year, oldest_month = periods[-1] + newest_year, newest_month = periods[0] + + rows = ( + db.session.query( + Expense.category_id, + extract("year", Expense.spent_at).label("yr"), + extract("month", Expense.spent_at).label("mo"), + func.sum(Expense.amount).label("total"), + ) + .filter( + Expense.user_id == user_id, + Expense.expense_type == "EXPENSE", + Expense.category_id.isnot(None), + ) + .filter( + # Keep rows within the rolling window + db.or_( + extract("year", Expense.spent_at) > oldest_year, + db.and_( + extract("year", Expense.spent_at) == oldest_year, + extract("month", Expense.spent_at) >= oldest_month, + ), + ), + db.or_( + extract("year", Expense.spent_at) < newest_year, + db.and_( + extract("year", Expense.spent_at) == newest_year, + extract("month", Expense.spent_at) <= newest_month, + ), + ), + ) + .group_by( + Expense.category_id, + extract("year", Expense.spent_at), + extract("month", Expense.spent_at), + ) + .all() + ) + + result: dict[int, dict[str, Decimal]] = defaultdict(dict) + for row in rows: + ym = f"{int(row.yr):04d}-{int(row.mo):02d}" + result[int(row.category_id)][ym] = Decimal(str(row.total)) + return result + + +def _category_names(user_id: int) -> dict[int, str]: + rows = db.session.query(Category.id, Category.name).filter_by(user_id=user_id).all() + return {r.id: r.name for r in rows} + + +def detect_lifestyle_inflation( + user_id: int, + window_months: int = 3, + inflation_threshold_pct: float = 10.0, +) -> dict: + """ + Detect lifestyle inflation for a user. + + Compares average monthly spend in the most recent `window_months` months + against the previous `window_months` months. Categories where spending + grew by more than `inflation_threshold_pct` percent are returned as + inflation signals. + + Returns a dict with: + - inflated_categories: list of category signals (sorted by pct_change desc) + - stable_categories: categories with no significant growth + - summary: counts and total_extra_monthly_spend + - window_months: echo input param + """ + total_window = window_months * 2 + monthly = _monthly_totals(user_id, total_window) + names = _category_names(user_id) + + today = date.today() + + # Build ordered list of YYYY-MM for recent and previous windows + recent_months: list[str] = [] + previous_months: list[str] = [] + y, m = today.year, today.month + for i in range(total_window): + ym = f"{y:04d}-{m:02d}" + if i < window_months: + recent_months.append(ym) + else: + previous_months.append(ym) + m -= 1 + if m == 0: + m = 12 + y -= 1 + + inflated: list[dict] = [] + stable: list[dict] = [] + + all_cat_ids = set(monthly.keys()) + for cat_id in all_cat_ids: + totals = monthly[cat_id] + recent_values = [float(totals.get(mo, Decimal("0"))) for mo in recent_months] + prev_values = [float(totals.get(mo, Decimal("0"))) for mo in previous_months] + + recent_avg = sum(recent_values) / len(recent_months) + prev_avg = sum(prev_values) / len(previous_months) + + # Need at least some spending in the previous window to compare + if prev_avg < 0.01: + continue + + pct_change = ((recent_avg - prev_avg) / prev_avg) * 100.0 + abs_change = recent_avg - prev_avg + + # Build monthly trend (all periods, ordered oldest→newest) + trend = [] + for mo in reversed(previous_months): + trend.append({"month": mo, "amount": float(totals.get(mo, Decimal("0")))}) + for mo in reversed(recent_months): + trend.append({"month": mo, "amount": float(totals.get(mo, Decimal("0")))}) + + entry = { + "category_id": cat_id, + "category_name": names.get(cat_id, "Unknown"), + "recent_avg_monthly": round(recent_avg, 2), + "previous_avg_monthly": round(prev_avg, 2), + "pct_change": round(pct_change, 1), + "abs_change_monthly": round(abs_change, 2), + "annualised_extra": round(abs_change * 12, 2), + "trend": trend, + } + + if pct_change >= inflation_threshold_pct: + inflated.append(entry) + else: + stable.append(entry) + + inflated.sort(key=lambda x: x["pct_change"], reverse=True) + stable.sort(key=lambda x: x["pct_change"], reverse=True) + + total_extra = sum(e["abs_change_monthly"] for e in inflated) + + return { + "inflated_categories": inflated, + "stable_categories": stable, + "summary": { + "inflated_count": len(inflated), + "stable_count": len(stable), + "total_extra_monthly_spend": round(total_extra, 2), + "total_extra_annual_spend": round(total_extra * 12, 2), + }, + "window_months": window_months, + "inflation_threshold_pct": inflation_threshold_pct, + } diff --git a/packages/backend/tests/test_lifestyle.py b/packages/backend/tests/test_lifestyle.py new file mode 100644 index 00000000..afdf4f14 --- /dev/null +++ b/packages/backend/tests/test_lifestyle.py @@ -0,0 +1,199 @@ +"""Tests for lifestyle inflation detection endpoint.""" +import pytest +from datetime import date, timedelta +from decimal import Decimal + + +# --------------------------------------------------------------------------- +# helpers +# --------------------------------------------------------------------------- + +def _add_expense(client, headers, amount, category_id, days_ago, expense_type="EXPENSE"): + d = (date.today() - timedelta(days=days_ago)).isoformat() + r = client.post( + "/expenses", + json={ + "amount": amount, + "category_id": category_id, + "expense_type": expense_type, + "spent_at": d, + "notes": "test", + }, + headers=headers, + ) + return r + + +def _add_category(client, headers, name): + r = client.post("/categories", json={"name": name}, headers=headers) + assert r.status_code in (200, 201) + return r.get_json()["id"] + + +# --------------------------------------------------------------------------- +# tests +# --------------------------------------------------------------------------- + +def test_lifestyle_inflation_requires_auth(client): + r = client.get("/insights/lifestyle-inflation") + assert r.status_code == 401 + + +def test_lifestyle_inflation_empty(client, auth_header): + r = client.get("/insights/lifestyle-inflation", headers=auth_header) + assert r.status_code == 200 + data = r.get_json() + assert data["inflated_categories"] == [] + assert data["stable_categories"] == [] + assert data["summary"]["inflated_count"] == 0 + + +def test_lifestyle_inflation_detects_growth(client, auth_header): + cat_id = _add_category(client, auth_header, "Dining") + + # Previous window: months ~4 and ~5 ago → small spend + for days in [120, 150]: + _add_expense(client, auth_header, 100, cat_id, days_ago=days) + + # Recent window: months ~1 and ~2 ago → big spend (inflated) + for days in [15, 45]: + _add_expense(client, auth_header, 300, cat_id, days_ago=days) + + r = client.get("/insights/lifestyle-inflation?window_months=2", headers=auth_header) + assert r.status_code == 200 + data = r.get_json() + # Should detect inflation in Dining + assert data["summary"]["inflated_count"] >= 1 + names = [c["category_name"] for c in data["inflated_categories"]] + assert "Dining" in names + + +def test_lifestyle_inflation_no_growth_stable(client, auth_header): + cat_id = _add_category(client, auth_header, "Transport") + + # Stable spend across both windows + for days in [15, 45, 75, 105]: + _add_expense(client, auth_header, 200, cat_id, days_ago=days) + + r = client.get("/insights/lifestyle-inflation?window_months=2", headers=auth_header) + assert r.status_code == 200 + data = r.get_json() + # Not inflated (0% change) + names_inflated = [c["category_name"] for c in data["inflated_categories"]] + assert "Transport" not in names_inflated + + +def test_lifestyle_inflation_income_excluded(client, auth_header): + cat_id = _add_category(client, auth_header, "Salary") + + # Large INCOME entries in recent window + for days in [15, 45]: + _add_expense(client, auth_header, 5000, cat_id, days_ago=days, expense_type="INCOME") + + r = client.get("/insights/lifestyle-inflation?window_months=2", headers=auth_header) + assert r.status_code == 200 + data = r.get_json() + # Income should not appear in inflation results + for cat in data["inflated_categories"] + data["stable_categories"]: + assert cat["category_name"] != "Salary" + + +def test_lifestyle_inflation_response_structure(client, auth_header): + r = client.get("/insights/lifestyle-inflation", headers=auth_header) + assert r.status_code == 200 + data = r.get_json() + assert "inflated_categories" in data + assert "stable_categories" in data + assert "summary" in data + assert "window_months" in data + assert "inflation_threshold_pct" in data + summary = data["summary"] + assert "inflated_count" in summary + assert "stable_count" in summary + assert "total_extra_monthly_spend" in summary + assert "total_extra_annual_spend" in summary + + +def test_lifestyle_inflation_custom_window(client, auth_header): + r = client.get("/insights/lifestyle-inflation?window_months=6", headers=auth_header) + assert r.status_code == 200 + assert r.get_json()["window_months"] == 6 + + +def test_lifestyle_inflation_custom_threshold(client, auth_header): + r = client.get( + "/insights/lifestyle-inflation?threshold_pct=20", headers=auth_header + ) + assert r.status_code == 200 + assert r.get_json()["inflation_threshold_pct"] == 20.0 + + +def test_lifestyle_inflation_window_capped(client, auth_header): + # window > 12 should be capped at 12 + r = client.get("/insights/lifestyle-inflation?window_months=99", headers=auth_header) + assert r.status_code == 200 + assert r.get_json()["window_months"] == 12 + + +def test_lifestyle_inflation_sorted_by_pct(client, auth_header): + cat_a = _add_category(client, auth_header, "InflatA") + cat_b = _add_category(client, auth_header, "InflatB") + + # cat_a: 100 → 300 (+200%) + for d in [120, 150]: + _add_expense(client, auth_header, 100, cat_a, days_ago=d) + for d in [15, 45]: + _add_expense(client, auth_header, 300, cat_a, days_ago=d) + + # cat_b: 100 → 150 (+50%) + for d in [120, 150]: + _add_expense(client, auth_header, 100, cat_b, days_ago=d) + for d in [15, 45]: + _add_expense(client, auth_header, 150, cat_b, days_ago=d) + + r = client.get("/insights/lifestyle-inflation?window_months=2", headers=auth_header) + assert r.status_code == 200 + inflated = r.get_json()["inflated_categories"] + assert len(inflated) >= 2 + # First item should have higher pct_change + assert inflated[0]["pct_change"] >= inflated[1]["pct_change"] + + +def test_lifestyle_inflation_annualised_extra(client, auth_header): + cat_id = _add_category(client, auth_header, "Shopping") + + for d in [120, 150]: + _add_expense(client, auth_header, 100, cat_id, days_ago=d) + for d in [15, 45]: + _add_expense(client, auth_header, 400, cat_id, days_ago=d) + + r = client.get("/insights/lifestyle-inflation?window_months=2", headers=auth_header) + assert r.status_code == 200 + inflated = r.get_json()["inflated_categories"] + found = next((c for c in inflated if c["category_name"] == "Shopping"), None) + assert found is not None + # annualised_extra should be 12x abs_change_monthly + assert abs(found["annualised_extra"] - found["abs_change_monthly"] * 12) < 0.1 + + +def test_lifestyle_inflation_trend_included(client, auth_header): + cat_id = _add_category(client, auth_header, "Gym") + + for d in [120, 150]: + _add_expense(client, auth_header, 50, cat_id, days_ago=d) + for d in [15, 45]: + _add_expense(client, auth_header, 200, cat_id, days_ago=d) + + r = client.get("/insights/lifestyle-inflation?window_months=2", headers=auth_header) + assert r.status_code == 200 + inflated = r.get_json()["inflated_categories"] + found = next((c for c in inflated if c["category_name"] == "Gym"), None) + assert found is not None + assert isinstance(found["trend"], list) + assert len(found["trend"]) == 4 # 2 recent + 2 previous months + for t in found["trend"]: + assert "month" in t + assert "amount" in t + + +import pytest