diff --git a/README.md b/README.md index 49592bffc..ba60e4323 100644 --- a/README.md +++ b/README.md @@ -65,7 +65,7 @@ OpenAPI: `backend/app/openapi.yaml` - Expenses: CRUD `/expenses` - Bills: CRUD `/bills`, pay/mark `/bills/{id}/pay` - Reminders: CRUD `/reminders`, trigger `/reminders/run` -- Insights: `/insights/monthly`, `/insights/budget-suggestion` +- Insights: `/insights/monthly`, `/insights/budget-suggestion`, `/insights/weekly-digest` ## MVP UI/UX Plan - Auth screens: register/login. diff --git a/app/src/api/insights.ts b/app/src/api/insights.ts index 031d1e531..322c37995 100644 --- a/app/src/api/insights.ts +++ b/app/src/api/insights.ts @@ -21,6 +21,22 @@ export type BudgetSuggestion = { net_flow?: number; }; +export type WeeklySmartDigest = { + period: string; + total_spend: number; + prev_total_spend: number; + total_change_pct: number; + significant_changes: Array<{ + category: string; + current: number; + previous: number; + change_pct: number; + }>; + insights: string[]; + prediction: string; + trend_analysis?: string; +}; + export async function getBudgetSuggestion(params?: { month?: string; geminiApiKey?: string; @@ -32,3 +48,8 @@ export async function getBudgetSuggestion(params?: { if (params?.persona) headers['X-Insight-Persona'] = params.persona; return api(`/insights/budget-suggestion${monthQuery}`, { headers }); } + +export async function getWeeklySmartDigest(date?: string): Promise { + const query = date ? `?date=${encodeURIComponent(date)}` : ''; + return api(`/insights/weekly-digest${query}`); +} diff --git a/app/src/components/WeeklySmartDigest.tsx b/app/src/components/WeeklySmartDigest.tsx new file mode 100644 index 000000000..71e5e6e8a --- /dev/null +++ b/app/src/components/WeeklySmartDigest.tsx @@ -0,0 +1,106 @@ +import { useEffect, useState } from 'react'; +import { + FinancialCard, + FinancialCardContent, + FinancialCardHeader, + FinancialCardTitle, + FinancialCardDescription, +} from '@/components/ui/financial-card'; +import { getWeeklySmartDigest, type WeeklySmartDigest as WeeklySmartDigestType } from '@/api/insights'; +import { TrendingUp, TrendingDown, Info, BrainCircuit, AlertCircle } from 'lucide-react'; +import { formatMoney } from '@/lib/currency'; + +export function WeeklySmartDigest() { + const [digest, setDigest] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + (async () => { + try { + const data = await getWeeklySmartDigest(); + setDigest(data); + } catch (e) { + console.error('Failed to fetch weekly digest', e); + } finally { + setLoading(false); + } + })(); + }, []); + + if (loading) return null; + if (!digest) return null; + + return ( + + +
+
+ + Smart Weekly Digest +
+
+ {digest.period} +
+
+ + AI-powered analysis of your spending deltas and trends + +
+ +
+
+
+
{formatMoney(digest.total_spend)}
+
0 ? 'text-destructive' : 'text-success'}`}> + {digest.total_change_pct > 0 ? : } + {Math.abs(digest.total_change_pct)}% from last week +
+
+ +
+

+ + Key Observations +

+
    + {digest.insights.map((insight, i) => ( +
  • + {insight} +
  • + ))} +
+
+
+ +
+
+
+ + Prediction: {digest.prediction} +
+

+ {digest.trend_analysis} +

+
+ + {digest.significant_changes.length > 0 && ( +
+

Notable Shifts

+
+ {digest.significant_changes.slice(0, 4).map((change, i) => ( +
+ {change.category} + 0 ? 'text-destructive font-bold' : 'text-success font-bold'}> + {change.change_pct > 0 ? '+' : ''}{change.change_pct}% + +
+ ))} +
+
+ )} +
+
+
+
+ ); +} diff --git a/app/src/pages/Dashboard.tsx b/app/src/pages/Dashboard.tsx index b2d4e7aa8..0714a54c1 100644 --- a/app/src/pages/Dashboard.tsx +++ b/app/src/pages/Dashboard.tsx @@ -22,6 +22,7 @@ import { import { getDashboardSummary, type DashboardSummary } from '@/api/dashboard'; import { useNavigate } from 'react-router-dom'; import { formatMoney } from '@/lib/currency'; +import { WeeklySmartDigest } from '@/components/WeeklySmartDigest'; function currency(n: number, code?: string) { return formatMoney(Number(n || 0), code); @@ -131,6 +132,8 @@ export function Dashboard() { + + {error && (
{error}. Showing empty fallback state.
)} diff --git a/packages/backend/app/routes/insights.py b/packages/backend/app/routes/insights.py index bfc02e43c..cc40d6b34 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.weekly_digest import get_weekly_smart_digest import logging bp = Blueprint("insights", __name__) @@ -23,3 +24,20 @@ def budget_suggestion(): ) logger.info("Budget suggestion served user=%s month=%s", uid, ym) return jsonify(suggestion) + + +@bp.get("/weekly-digest") +@jwt_required() +def weekly_digest(): + uid = int(get_jwt_identity()) + target_str = request.args.get("date") + target_date = None + if target_str: + try: + target_date = date.fromisoformat(target_str) + except ValueError: + return jsonify({"error": "Invalid date format, use YYYY-MM-DD"}), 400 + + digest = get_weekly_smart_digest(uid, target_date) + logger.info("Weekly smart digest served user=%s", uid) + return jsonify(digest) diff --git a/packages/backend/app/services/weekly_digest.py b/packages/backend/app/services/weekly_digest.py new file mode 100644 index 000000000..61bac4e90 --- /dev/null +++ b/packages/backend/app/services/weekly_digest.py @@ -0,0 +1,107 @@ +from datetime import date, timedelta +from sqlalchemy import func, extract +from ..extensions import db +from ..models import Expense, Category +from .ai import _extract_json_object, DEFAULT_PERSONA +from ..config import Settings +from urllib import request +import json + +_settings = Settings() + +def _get_week_data(uid: int, start_date: date, end_date: date): + expenses = db.session.query( + Category.name, + func.sum(Expense.amount).label('total'), + func.count(Expense.id).label('count') + ).join(Category, Expense.category_id == Category.id, isouter=True)\ + .filter( + Expense.user_id == uid, + Expense.spent_at >= start_date, + Expense.spent_at <= end_date, + Expense.expense_type != "INCOME" + ).group_by(Category.name).all() + + return {str(name or "Uncategorized"): {"total": float(total), "count": count} for name, total, count in expenses} + +def get_weekly_smart_digest(uid: int, target_date: date = None): + if not target_date: + target_date = date.today() + + # End of current week is target_date, start is 6 days ago + curr_end = target_date + curr_start = curr_end - timedelta(days=6) + + # Previous week + prev_end = curr_start - timedelta(days=1) + prev_start = prev_end - timedelta(days=6) + + curr_data = _get_week_data(uid, curr_start, curr_end) + prev_data = _get_week_data(uid, prev_start, prev_end) + + curr_total = sum(d['total'] for d in curr_data.values()) + prev_total = sum(d['total'] for d in prev_data.values()) + + # Identify anomalies and significant deltas + deltas = [] + all_cats = set(curr_data.keys()) | set(prev_data.keys()) + for cat in all_cats: + c = curr_data.get(cat, {"total": 0, "count": 0}) + p = prev_data.get(cat, {"total": 0, "count": 0}) + + if p['total'] > 0: + diff_pct = ((c['total'] - p['total']) / p['total']) * 100 + else: + diff_pct = 100.0 if c['total'] > 0 else 0.0 + + if abs(diff_pct) > 20 or abs(c['total'] - p['total']) > 50: + deltas.append({ + "category": cat, + "current": c['total'], + "previous": p['total'], + "change_pct": round(diff_pct, 2) + }) + + # AI Integration + api_key = _settings.gemini_api_key + model = _settings.gemini_model + + digest = { + "period": f"{curr_start.strftime('%b %d')} - {curr_end.strftime('%b %d')}", + "total_spend": round(curr_total, 2), + "prev_total_spend": round(prev_total, 2), + "total_change_pct": round(((curr_total - prev_total) / prev_total * 100), 2) if prev_total > 0 else 0, + "significant_changes": deltas, + "insights": [], + "prediction": "Steady" + } + + if api_key: + prompt = ( + f"{DEFAULT_PERSONA}\n" + "Analyze this weekly financial data. Focus on WHY spending changed and provide PREDICTIVE warnings.\n" + "Return strict JSON with keys: insights (list of strings, max 3), prediction (string, short), trend_analysis (string).\n" + f"Current Week ({digest['period']}): Total {curr_total}, Data: {curr_data}\n" + f"Previous Week: Total {prev_total}, Data: {prev_data}\n" + f"Significant Deltas: {deltas}" + ) + try: + url = f"https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent?key={api_key}" + body = json.dumps({ + "contents": [{"parts": [{"text": prompt}]}], + "generationConfig": {"temperature": 0.3} + }).encode("utf-8") + req = request.Request(url=url, data=body, headers={"Content-Type": "application/json"}, method="POST") + with request.urlopen(req, timeout=10) as resp: + res = json.loads(resp.read().decode("utf-8")) + text = res.get("candidates", [{}])[0].get("content", {}).get("parts", [{}])[0].get("text", "") + ai_res = _extract_json_object(text) + digest["insights"] = ai_res.get("insights", []) + digest["prediction"] = ai_res.get("prediction", "Unknown") + digest["trend_analysis"] = ai_res.get("trend_analysis", "") + except Exception: + digest["insights"] = ["AI insights temporarily unavailable. Using heuristic analysis."] + if digest["total_change_pct"] > 10: + digest["insights"].append("Spending is trending up compared to last week.") + + return digest diff --git a/packages/backend/tests/test_weekly_digest.py b/packages/backend/tests/test_weekly_digest.py new file mode 100644 index 000000000..edbb2514f --- /dev/null +++ b/packages/backend/tests/test_weekly_digest.py @@ -0,0 +1,50 @@ +from datetime import date, timedelta + +def test_weekly_digest_returns_correct_fields(client, auth_header): + # Setup: 2 transactions in current week, 1 in previous + today = date.today() + curr_date = today - timedelta(days=2) + prev_date = today - timedelta(days=9) + + # Add a category first + r = client.post("/categories", json={"name": "Food"}, headers=auth_header) + cat_id = r.get_json()["id"] + + # Current week spend + client.post("/expenses", json={ + "amount": 100, + "category_id": cat_id, + "description": "Lunch", + "date": curr_date.isoformat(), + "expense_type": "EXPENSE" + }, headers=auth_header) + + # Previous week spend + client.post("/expenses", json={ + "amount": 50, + "category_id": cat_id, + "description": "Old Lunch", + "date": prev_date.isoformat(), + "expense_type": "EXPENSE" + }, headers=auth_header) + + # Fetch digest + r = client.get(f"/insights/weekly-digest?date={today.isoformat()}", headers=auth_header) + assert r.status_code == 200 + payload = r.get_json() + + assert "period" in payload + assert payload["total_spend"] == 100 + assert payload["prev_total_spend"] == 50 + assert payload["total_change_pct"] == 100.0 + assert len(payload["significant_changes"]) > 0 + assert payload["significant_changes"][0]["category"] == "Food" + assert payload["significant_changes"][0]["change_pct"] == 100.0 + +def test_weekly_digest_handles_empty_data(client, auth_header): + r = client.get("/insights/weekly-digest", headers=auth_header) + assert r.status_code == 200 + payload = r.get_json() + assert payload["total_spend"] == 0 + assert payload["prev_total_spend"] == 0 + assert payload["total_change_pct"] == 0