Skip to content
Open
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
21 changes: 21 additions & 0 deletions app/src/api/insights.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -32,3 +48,8 @@ export async function getBudgetSuggestion(params?: {
if (params?.persona) headers['X-Insight-Persona'] = params.persona;
return api<BudgetSuggestion>(`/insights/budget-suggestion${monthQuery}`, { headers });
}

export async function getWeeklySmartDigest(date?: string): Promise<WeeklySmartDigest> {
const query = date ? `?date=${encodeURIComponent(date)}` : '';
return api<WeeklySmartDigest>(`/insights/weekly-digest${query}`);
}
106 changes: 106 additions & 0 deletions app/src/components/WeeklySmartDigest.tsx
Original file line number Diff line number Diff line change
@@ -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<WeeklySmartDigestType | null>(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 (
<FinancialCard variant="financial" className="mb-6 overflow-hidden border-primary/20 bg-primary/5 shadow-md">
<FinancialCardHeader className="bg-primary/10 pb-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<BrainCircuit className="w-5 h-5 text-primary" />
<FinancialCardTitle className="text-lg font-bold">Smart Weekly Digest</FinancialCardTitle>
</div>
<div className="px-2 py-1 rounded-full bg-background/50 text-xs font-medium border border-primary/20">
{digest.period}
</div>
</div>
<FinancialCardDescription className="text-primary/70">
AI-powered analysis of your spending deltas and trends
</FinancialCardDescription>
</FinancialCardHeader>
<FinancialCardContent className="pt-6">
<div className="grid md:grid-cols-2 gap-6">
<div className="space-y-4">
<div className="flex items-end gap-3">
<div className="text-2xl font-bold">{formatMoney(digest.total_spend)}</div>
<div className={`flex items-center text-sm mb-1 ${digest.total_change_pct > 0 ? 'text-destructive' : 'text-success'}`}>
{digest.total_change_pct > 0 ? <TrendingUp className="w-4 h-4 mr-1" /> : <TrendingDown className="w-4 h-4 mr-1" />}
{Math.abs(digest.total_change_pct)}% from last week
</div>
</div>

<div className="space-y-2">
<h4 className="text-sm font-semibold flex items-center gap-2">
<Info className="w-4 h-4" />
Key Observations
</h4>
<ul className="space-y-2">
{digest.insights.map((insight, i) => (
<li key={i} className="text-sm bg-background/40 p-2 rounded border border-primary/10">
{insight}
</li>
))}
</ul>
</div>
</div>

<div className="space-y-4">
<div className="p-3 rounded-lg bg-primary/10 border border-primary/20">
<div className="flex items-center gap-2 mb-2 text-sm font-bold uppercase tracking-wider text-primary">
<AlertCircle className="w-4 h-4" />
Prediction: {digest.prediction}
</div>
<p className="text-sm text-muted-foreground leading-relaxed">
{digest.trend_analysis}
</p>
</div>

{digest.significant_changes.length > 0 && (
<div className="space-y-2">
<h4 className="text-xs font-bold uppercase text-muted-foreground">Notable Shifts</h4>
<div className="grid grid-cols-2 gap-2">
{digest.significant_changes.slice(0, 4).map((change, i) => (
<div key={i} className="text-xs p-2 bg-background/60 rounded border flex flex-col justify-between">
<span className="font-medium truncate">{change.category}</span>
<span className={change.change_pct > 0 ? 'text-destructive font-bold' : 'text-success font-bold'}>
{change.change_pct > 0 ? '+' : ''}{change.change_pct}%
</span>
</div>
))}
</div>
</div>
)}
</div>
</div>
</FinancialCardContent>
</FinancialCard>
);
}
3 changes: 3 additions & 0 deletions app/src/pages/Dashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -131,6 +132,8 @@ export function Dashboard() {
</div>
</div>

<WeeklySmartDigest />

{error && (
<div className="error mb-6">{error}. Showing empty fallback state.</div>
)}
Expand Down
18 changes: 18 additions & 0 deletions packages/backend/app/routes/insights.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)
Expand All @@ -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)
107 changes: 107 additions & 0 deletions packages/backend/app/services/weekly_digest.py
Original file line number Diff line number Diff line change
@@ -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
50 changes: 50 additions & 0 deletions packages/backend/tests/test_weekly_digest.py
Original file line number Diff line number Diff line change
@@ -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