diff --git a/app/src/App.tsx b/app/src/App.tsx index f0dc5942..df96cc79 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 { Digest } from "./pages/Digest"; const queryClient = new QueryClient({ defaultOptions: { @@ -83,6 +84,14 @@ const App = () => ( } /> + + + + } + /> { + const qs = weekStart ? `?week_start=${weekStart}` : ''; + return api(`/digest/weekly${qs}`); +} diff --git a/app/src/components/layout/Navbar.tsx b/app/src/components/layout/Navbar.tsx index c7593b70..f0616b26 100644 --- a/app/src/components/layout/Navbar.tsx +++ b/app/src/components/layout/Navbar.tsx @@ -12,6 +12,7 @@ const navigation = [ { name: 'Bills', href: '/bills' }, { name: 'Reminders', href: '/reminders' }, { name: 'Expenses', href: '/expenses' }, + { name: 'Weekly Digest', href: '/digest' }, { name: 'Analytics', href: '/analytics' }, ]; diff --git a/app/src/pages/Digest.tsx b/app/src/pages/Digest.tsx new file mode 100644 index 00000000..f437ec6e --- /dev/null +++ b/app/src/pages/Digest.tsx @@ -0,0 +1,237 @@ +import { useState, useEffect, useCallback } from 'react'; +import { + FinancialCard, + FinancialCardContent, + FinancialCardHeader, + FinancialCardTitle, + FinancialCardDescription, +} from '@/components/ui/financial-card'; +import { useToast } from '@/hooks/use-toast'; +import { TrendingUp, TrendingDown, Minus, CalendarDays, Lightbulb, Receipt, AlertCircle } from 'lucide-react'; +import { getWeeklyDigest, type WeeklyDigest, type CategoryBreakdown } from '@/api/digest'; +import { formatMoney } from '@/lib/currency'; + +function WowBadge({ pct }: { pct: number | null }) { + if (pct === null) return No prior data; + if (Math.abs(pct) < 1) return ( + + Flat vs last week + + ); + const up = pct > 0; + return ( + + {up ? : } + {up ? '+' : ''}{pct.toFixed(1)}% vs last week + + ); +} + +function CategoryBar({ item, max }: { item: CategoryBreakdown; max: number }) { + const pct = max > 0 ? (item.total / max) * 100 : 0; + const hasDelta = item.delta_pct !== null; + const up = (item.delta ?? 0) > 0; + + return ( +
+
+ {item.category} +
+ {hasDelta && ( + + {up ? '+' : ''}{item.delta_pct?.toFixed(0)}% + + )} + {formatMoney(item.total, 'INR')} +
+
+
+
+
+
+ ); +} + +export function Digest() { + const [digest, setDigest] = useState(null); + const [loading, setLoading] = useState(true); + const [weekOffset, setWeekOffset] = useState(0); + const { toast } = useToast(); + + const getWeekStart = useCallback((offset: number) => { + const d = new Date(); + d.setDate(d.getDate() - d.getDay() + 1 - offset * 7); // most recent Monday - offset + return d.toISOString().slice(0, 10); + }, []); + + const load = useCallback(async (offset: number) => { + try { + setLoading(true); + const data = await getWeeklyDigest(getWeekStart(offset)); + setDigest(data); + } catch { + toast({ title: 'Failed to load weekly digest', variant: 'destructive' }); + } finally { + setLoading(false); + } + }, [toast, getWeekStart]); + + useEffect(() => { + load(weekOffset); + }, [load, weekOffset]); + + const maxCategorySpend = digest + ? Math.max(...digest.category_breakdown.map((c) => c.total), 1) + : 1; + + const weekLabel = weekOffset === 0 ? 'This Week' : weekOffset === 1 ? 'Last Week' : `${weekOffset} Weeks Ago`; + + return ( +
+ {/* Header */} +
+
+

+ + Weekly Digest +

+

+ {digest ? `${digest.week_start} — ${digest.week_end}` : 'Loading...'} +

+
+
+ + {weekLabel} + +
+
+ + {loading ? ( +

Loading digest...

+ ) : !digest ? null : ( + <> + {/* Summary cards */} +
+ + +
+

Total Spent

+

+ {formatMoney(digest.summary.total_spent, 'INR')} +

+ +
+
+
+ + + +
+

Total Income

+

+ {formatMoney(digest.summary.total_income, 'INR')} +

+ This week +
+
+
+ + + +
+

Net Flow

+

= 0 ? 'text-success' : 'text-destructive'}`}> + {formatMoney(digest.summary.net_flow, 'INR')} +

+ Income − Expenses +
+
+
+
+ +
+ {/* Category breakdown */} + + + + Spending by Category + + + {digest.top_spending_category + ? `Top: ${digest.top_spending_category}` + : 'No spending this week'} + + + + {digest.category_breakdown.length === 0 ? ( +

No expenses recorded this week.

+ ) : ( + digest.category_breakdown.map((item) => ( + + )) + )} +
+
+ +
+ {/* Insights */} + + + + Insights + + + +
    + {digest.insights.map((insight, i) => ( +
  • + + {insight} +
  • + ))} +
+
+
+ + {/* Upcoming bills */} + {digest.upcoming_bills.length > 0 && ( + + + + Bills Due Soon + + + + {digest.upcoming_bills.map((bill) => ( +
+ {bill.name} + + {formatMoney(bill.amount, bill.currency)} · {new Date(bill.due_date).toLocaleDateString()} + +
+ ))} +
+
+ )} +
+
+ + )} +
+ ); +} diff --git a/packages/backend/app/routes/__init__.py b/packages/backend/app/routes/__init__.py index f13b0f89..4eb8ee8e 100644 --- a/packages/backend/app/routes/__init__.py +++ b/packages/backend/app/routes/__init__.py @@ -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 .digest import bp as digest_bp def register_routes(app: Flask): @@ -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(digest_bp, url_prefix="/digest") diff --git a/packages/backend/app/routes/digest.py b/packages/backend/app/routes/digest.py new file mode 100644 index 00000000..fd142df0 --- /dev/null +++ b/packages/backend/app/routes/digest.py @@ -0,0 +1,235 @@ +""" +Weekly financial digest endpoint. + +GET /digest/weekly?week_start=YYYY-MM-DD + Returns a weekly summary for the 7-day window starting on week_start + (defaults to the most recent Monday). Includes: + - total_spent, total_income, net_flow + - category_breakdown with week-over-week delta + - top_spending_category, biggest_increase, biggest_decrease + - upcoming_bills in the next 7 days + - insights[] — human-readable trend sentences + +Responses are cached in Redis for 1 hour (falls back gracefully if Redis +is unavailable). +""" + +from datetime import date, timedelta +from sqlalchemy import func +from flask import Blueprint, jsonify, request +from flask_jwt_extended import jwt_required, get_jwt_identity + +from ..extensions import db +from ..models import Expense, Category, Bill +from ..services.cache import cache_get, cache_set + +bp = Blueprint("digest", __name__) + +_CACHE_TTL = 3600 # 1 hour + + +def _monday(d: date) -> date: + """Return the Monday on or before d.""" + return d - timedelta(days=d.weekday()) + + +def _parse_week_start(raw: str | None) -> date: + if raw: + try: + return date.fromisoformat(raw) + except ValueError: + pass + return _monday(date.today()) + + +def _week_expenses(uid: int, start: date) -> list: + """Return (category_name, total) rows for the given 7-day window.""" + end = start + timedelta(days=6) + rows = ( + db.session.query( + func.coalesce(Category.name, "Uncategorised").label("category"), + func.sum(Expense.amount).label("total"), + ) + .outerjoin(Category, Expense.category_id == Category.id) + .filter( + Expense.user_id == uid, + Expense.expense_type != "INCOME", + Expense.spent_at >= start, + Expense.spent_at <= end, + ) + .group_by(Category.name) + .order_by(func.sum(Expense.amount).desc()) + .all() + ) + return [(r.category, float(r.total)) for r in rows] + + +def _week_income(uid: int, start: date) -> float: + end = start + timedelta(days=6) + val = ( + db.session.query(func.coalesce(func.sum(Expense.amount), 0)) + .filter( + Expense.user_id == uid, + Expense.expense_type == "INCOME", + Expense.spent_at >= start, + Expense.spent_at <= end, + ) + .scalar() + ) + return float(val or 0) + + +def _upcoming_bills(uid: int, from_date: date, days: int = 7) -> list: + to_date = from_date + timedelta(days=days) + bills = ( + db.session.query(Bill) + .filter( + Bill.user_id == uid, + Bill.active.is_(True), + Bill.next_due_date >= from_date, + Bill.next_due_date <= to_date, + ) + .order_by(Bill.next_due_date) + .all() + ) + return [ + { + "id": b.id, + "name": b.name, + "amount": float(b.amount), + "currency": b.currency, + "due_date": b.next_due_date.isoformat(), + } + for b in bills + ] + + +def _build_insights( + current_breakdown: list, + prev_breakdown: list, + total_spent: float, + prev_total: float, + upcoming: list, +) -> list[str]: + insights = [] + + # Week-over-week total + if prev_total > 0: + change_pct = ((total_spent - prev_total) / prev_total) * 100 + if abs(change_pct) >= 5: + direction = "up" if change_pct > 0 else "down" + insights.append( + f"Total spending is {direction} {abs(change_pct):.0f}% compared to last week " + f"({_fmt(prev_total)} → {_fmt(total_spent)})." + ) + elif total_spent > 0: + insights.append(f"You spent {_fmt(total_spent)} this week.") + + # Biggest category + if current_breakdown: + top_cat, top_amt = current_breakdown[0] + pct = (top_amt / total_spent * 100) if total_spent > 0 else 0 + insights.append( + f"{top_cat} is your top spending category at {_fmt(top_amt)} ({pct:.0f}% of total)." + ) + + # Category deltas + prev_map = dict(prev_breakdown) + curr_map = dict(current_breakdown) + deltas = [] + for cat, amt in curr_map.items(): + prev_amt = prev_map.get(cat, 0) + if prev_amt > 0: + delta_pct = ((amt - prev_amt) / prev_amt) * 100 + deltas.append((cat, delta_pct, amt - prev_amt)) + + if deltas: + deltas.sort(key=lambda x: x[1], reverse=True) + biggest_up = deltas[0] + if biggest_up[1] >= 20: + insights.append( + f"{biggest_up[0]} spending increased by {biggest_up[1]:.0f}% vs last week " + f"(+{_fmt(biggest_up[2])})." + ) + biggest_down = deltas[-1] + if biggest_down[1] <= -20: + insights.append( + f"{biggest_down[0]} spending decreased by {abs(biggest_down[1]):.0f}% vs last week " + f"({_fmt(biggest_down[2])})." + ) + + # Upcoming bills + if upcoming: + names = ", ".join(b["name"] for b in upcoming[:3]) + suffix = f" and {len(upcoming) - 3} more" if len(upcoming) > 3 else "" + insights.append(f"Bills due this week: {names}{suffix}.") + + if not insights: + insights.append("No significant spending activity this week.") + + return insights + + +def _fmt(amount: float) -> str: + return f"₹{amount:,.2f}" + + +@bp.get("/weekly") +@jwt_required() +def weekly_digest(): + uid = int(get_jwt_identity()) + week_start = _parse_week_start(request.args.get("week_start")) + week_end = week_start + timedelta(days=6) + prev_start = week_start - timedelta(days=7) + + cache_key = f"digest:weekly:{uid}:{week_start.isoformat()}" + cached = cache_get(cache_key) + if cached: + return jsonify(cached) + + current = _week_expenses(uid, week_start) + previous = _week_expenses(uid, prev_start) + income = _week_income(uid, week_start) + upcoming = _upcoming_bills(uid, date.today()) + + total_spent = sum(amt for _, amt in current) + prev_total = sum(amt for _, amt in previous) + + category_breakdown = [] + prev_map = dict(previous) + for cat, amt in current: + prev_amt = prev_map.get(cat, 0) + delta = amt - prev_amt + delta_pct = ((delta / prev_amt) * 100) if prev_amt > 0 else None + category_breakdown.append({ + "category": cat, + "total": amt, + "previous_total": prev_amt, + "delta": round(delta, 2), + "delta_pct": round(delta_pct, 1) if delta_pct is not None else None, + }) + + payload = { + "week_start": week_start.isoformat(), + "week_end": week_end.isoformat(), + "summary": { + "total_spent": round(total_spent, 2), + "total_income": round(income, 2), + "net_flow": round(income - total_spent, 2), + "prev_week_spent": round(prev_total, 2), + "wow_change_pct": round( + ((total_spent - prev_total) / prev_total * 100), 1 + ) if prev_total > 0 else None, + }, + "category_breakdown": category_breakdown, + "top_spending_category": current[0][0] if current else None, + "upcoming_bills": upcoming, + "insights": _build_insights(current, previous, total_spent, prev_total, upcoming), + } + + try: + cache_set(cache_key, payload, ttl_seconds=_CACHE_TTL) + except Exception: + pass # Redis unavailable — serve uncached + + return jsonify(payload) diff --git a/packages/backend/tests/test_digest.py b/packages/backend/tests/test_digest.py new file mode 100644 index 00000000..ff9e0b5b --- /dev/null +++ b/packages/backend/tests/test_digest.py @@ -0,0 +1,156 @@ +"""Tests for weekly financial digest endpoint.""" +from datetime import date, timedelta +from unittest.mock import patch + +from app.extensions import db +from app.models import Expense, Category, Bill, BillCadence + + +def _monday(d: date) -> date: + return d - timedelta(days=d.weekday()) + + +def _add_expense(app_fixture, uid, amount, category_name=None, offset_days=0, expense_type="EXPENSE"): + with app_fixture.app_context(): + cat_id = None + if category_name: + cat = db.session.query(Category).filter_by(user_id=uid, name=category_name).first() + if not cat: + cat = Category(user_id=uid, name=category_name) + db.session.add(cat) + db.session.flush() + cat_id = cat.id + exp = Expense( + user_id=uid, + category_id=cat_id, + amount=amount, + expense_type=expense_type, + spent_at=date.today() - timedelta(days=offset_days), + ) + db.session.add(exp) + db.session.commit() + + +def _add_bill(app_fixture, uid, name, amount, due_offset_days=3): + with app_fixture.app_context(): + b = Bill( + user_id=uid, + name=name, + amount=amount, + currency="INR", + next_due_date=date.today() + timedelta(days=due_offset_days), + cadence=BillCadence.MONTHLY, + ) + db.session.add(b) + db.session.commit() + + +def _get_uid(client): + r = client.post("/auth/register", json={"email": "digest@test.com", "password": "pass1234"}) + r = client.post("/auth/login", json={"email": "digest@test.com", "password": "pass1234"}) + return r.get_json()["access_token"] if r.status_code == 200 else None + + +class TestWeeklyDigest: + def test_empty_week_returns_zero_summary(self, client, auth_header, app_fixture): + with patch("app.routes.digest.cache_get", return_value=None), \ + patch("app.routes.digest.cache_set"): + r = client.get("/digest/weekly", headers=auth_header) + assert r.status_code == 200 + data = r.get_json() + assert data["summary"]["total_spent"] == 0.0 + assert data["summary"]["total_income"] == 0.0 + assert data["category_breakdown"] == [] + assert "week_start" in data + assert "week_end" in data + assert "insights" in data + + def test_expenses_appear_in_breakdown(self, client, auth_header, app_fixture): + uid = 1 + _add_expense(app_fixture, uid, 500, "Food", offset_days=1) + _add_expense(app_fixture, uid, 200, "Transport", offset_days=2) + + with patch("app.routes.digest.cache_get", return_value=None), \ + patch("app.routes.digest.cache_set"): + r = client.get("/digest/weekly", headers=auth_header) + assert r.status_code == 200 + data = r.get_json() + assert data["summary"]["total_spent"] == 700.0 + cats = {c["category"]: c["total"] for c in data["category_breakdown"]} + assert cats["Food"] == 500.0 + assert cats["Transport"] == 200.0 + + def test_income_excluded_from_spending(self, client, auth_header, app_fixture): + uid = 1 + _add_expense(app_fixture, uid, 1000, offset_days=1, expense_type="INCOME") + _add_expense(app_fixture, uid, 300, "Bills", offset_days=1) + + with patch("app.routes.digest.cache_get", return_value=None), \ + patch("app.routes.digest.cache_set"): + r = client.get("/digest/weekly", headers=auth_header) + data = r.get_json() + assert data["summary"]["total_spent"] == 300.0 + assert data["summary"]["total_income"] == 1000.0 + assert data["summary"]["net_flow"] == 700.0 + + def test_custom_week_start(self, client, auth_header, app_fixture): + week_start = (date.today() - timedelta(days=14)).isoformat() + with patch("app.routes.digest.cache_get", return_value=None), \ + patch("app.routes.digest.cache_set"): + r = client.get(f"/digest/weekly?week_start={week_start}", headers=auth_header) + assert r.status_code == 200 + assert r.get_json()["week_start"] == week_start + + def test_upcoming_bills_included(self, client, auth_header, app_fixture): + uid = 1 + _add_bill(app_fixture, uid, "Netflix", 500, due_offset_days=3) + + with patch("app.routes.digest.cache_get", return_value=None), \ + patch("app.routes.digest.cache_set"): + r = client.get("/digest/weekly", headers=auth_header) + data = r.get_json() + bill_names = [b["name"] for b in data["upcoming_bills"]] + assert "Netflix" in bill_names + + def test_top_spending_category_is_highest(self, client, auth_header, app_fixture): + uid = 1 + _add_expense(app_fixture, uid, 100, "Food", offset_days=1) + _add_expense(app_fixture, uid, 800, "Rent", offset_days=2) + + with patch("app.routes.digest.cache_get", return_value=None), \ + patch("app.routes.digest.cache_set"): + r = client.get("/digest/weekly", headers=auth_header) + assert r.get_json()["top_spending_category"] == "Rent" + + def test_wow_change_computed(self, client, auth_header, app_fixture): + uid = 1 + # Current week: 500 + _add_expense(app_fixture, uid, 500, "Food", offset_days=1) + # Previous week: 250 + _add_expense(app_fixture, uid, 250, "Food", offset_days=8) + + with patch("app.routes.digest.cache_get", return_value=None), \ + patch("app.routes.digest.cache_set"): + r = client.get("/digest/weekly", headers=auth_header) + data = r.get_json() + assert data["summary"]["prev_week_spent"] == 250.0 + assert data["summary"]["wow_change_pct"] == 100.0 + + def test_cache_hit_returns_cached(self, client, auth_header): + cached = {"week_start": "2026-03-09", "cached": True} + with patch("app.routes.digest.cache_get", return_value=cached): + r = client.get("/digest/weekly", headers=auth_header) + assert r.status_code == 200 + assert r.get_json().get("cached") is True + + def test_requires_auth(self, client): + r = client.get("/digest/weekly") + assert r.status_code == 401 + + def test_insights_not_empty(self, client, auth_header, app_fixture): + uid = 1 + _add_expense(app_fixture, uid, 400, "Food", offset_days=1) + with patch("app.routes.digest.cache_get", return_value=None), \ + patch("app.routes.digest.cache_set"): + r = client.get("/digest/weekly", headers=auth_header) + assert len(r.get_json()["insights"]) > 0