diff --git a/app/src/App.tsx b/app/src/App.tsx index f0dc5942..ae3e7519 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 WeeklyDigest from "./pages/WeeklyDigest"; const queryClient = new QueryClient({ defaultOptions: { @@ -83,6 +84,14 @@ const App = () => ( } /> + + + + } + /> { + const query = week ? `?week=${encodeURIComponent(week)}` : ''; + return api(`/digest/weekly${query}`); +} + +export async function getDigestHistory(limit?: number): Promise { + const query = limit ? `?limit=${limit}` : ''; + return api(`/digest/history${query}`); +} diff --git a/app/src/components/layout/Navbar.tsx b/app/src/components/layout/Navbar.tsx index c7593b70..998d6c75 100644 --- a/app/src/components/layout/Navbar.tsx +++ b/app/src/components/layout/Navbar.tsx @@ -8,6 +8,7 @@ import { logout as logoutApi } from '@/api/auth'; const navigation = [ { name: 'Dashboard', href: '/dashboard' }, + { name: 'Digest', href: '/digest' }, { name: 'Budgets', href: '/budgets' }, { name: 'Bills', href: '/bills' }, { name: 'Reminders', href: '/reminders' }, diff --git a/app/src/pages/Dashboard.tsx b/app/src/pages/Dashboard.tsx index b2d4e7aa..1d8db69d 100644 --- a/app/src/pages/Dashboard.tsx +++ b/app/src/pages/Dashboard.tsx @@ -20,6 +20,7 @@ import { Plus, } from 'lucide-react'; import { getDashboardSummary, type DashboardSummary } from '@/api/dashboard'; +import { getWeeklyDigest, type WeeklyDigestResponse } from '@/api/digest'; import { useNavigate } from 'react-router-dom'; import { formatMoney } from '@/lib/currency'; @@ -30,6 +31,7 @@ function currency(n: number, code?: string) { export function Dashboard() { const navigate = useNavigate(); const [data, setData] = useState(null); + const [digestData, setDigestData] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [month, setMonth] = useState(() => new Date().toISOString().slice(0, 7)); @@ -47,6 +49,8 @@ export function Dashboard() { setLoading(false); } })(); + // Fetch weekly digest for the widget (fire-and-forget) + getWeeklyDigest().then(setDigestData).catch(() => {}); }, [month]); const summary = useMemo(() => { @@ -277,6 +281,68 @@ export function Dashboard() { + + {/* Weekly Digest Widget */} + {digestData?.summary && ( + + +
+
+ Weekly Digest + + {new Date(digestData.summary.period.week_start + 'T00:00:00').toLocaleDateString(undefined, { month: 'short', day: 'numeric' })} + {' - '} + {new Date(digestData.summary.period.week_end + 'T00:00:00').toLocaleDateString(undefined, { month: 'short', day: 'numeric' })} + +
+ +
+
+ +
+
+
Spending
+
{currency(digestData.summary.overview.total_expenses)}
+ {digestData.summary.comparison.spending_change_pct !== null && ( +
0 ? 'text-destructive' : 'text-success'}`}> + {digestData.summary.comparison.spending_change_pct > 0 ? '+' : ''}{digestData.summary.comparison.spending_change_pct.toFixed(1)}% vs last week +
+ )} +
+
+
Net Flow
+
= 0 ? 'text-success' : 'text-destructive'}`}> + {digestData.summary.overview.net_flow >= 0 ? '+' : ''}{currency(digestData.summary.overview.net_flow)} +
+
+
+
Savings Rate
+
+ {digestData.summary.overview.savings_rate !== null ? `${digestData.summary.overview.savings_rate.toFixed(1)}%` : '--'} +
+
+
+ {digestData.summary.insights.length > 0 && ( +
+ {digestData.summary.insights.slice(0, 2).map((insight, idx) => ( +
+ {insight.title}: {insight.message} +
+ ))} +
+ )} +
+ + + +
+ )} ); } diff --git a/app/src/pages/WeeklyDigest.tsx b/app/src/pages/WeeklyDigest.tsx new file mode 100644 index 00000000..4ac5d256 --- /dev/null +++ b/app/src/pages/WeeklyDigest.tsx @@ -0,0 +1,662 @@ +import { useEffect, useState, useMemo } from 'react'; +import { + FinancialCard, + FinancialCardContent, + FinancialCardDescription, + FinancialCardFooter, + FinancialCardHeader, + FinancialCardTitle, +} from '@/components/ui/financial-card'; +import { Button } from '@/components/ui/button'; +import { + ArrowDownRight, + ArrowUpRight, + TrendingDown, + TrendingUp, + Wallet, + PiggyBank, + BarChart3, + CalendarDays, + ChevronLeft, + ChevronRight, + RefreshCw, + AlertTriangle, + CheckCircle2, + Info, + Receipt, + Zap, + History, +} from 'lucide-react'; +import { + getWeeklyDigest, + getDigestHistory, + type WeeklyDigestResponse, + type DigestHistoryItem, + type DigestInsight, +} from '@/api/digest'; +import { formatMoney } from '@/lib/currency'; + +function currency(n: number, code?: string) { + return formatMoney(Number(n || 0), code); +} + +function formatDate(iso: string) { + return new Date(iso + 'T00:00:00').toLocaleDateString(undefined, { + month: 'short', + day: 'numeric', + }); +} + +function formatWeekRange(start: string, end: string) { + return `${formatDate(start)} - ${formatDate(end)}`; +} + +function getMonday(d: Date): Date { + const day = d.getDay(); + const diff = d.getDate() - day + (day === 0 ? -6 : 1); + return new Date(d.getFullYear(), d.getMonth(), diff); +} + +function isoDate(d: Date): string { + return d.toISOString().split('T')[0]; +} + +function InsightBadge({ insight }: { insight: DigestInsight }) { + const iconMap = { + success: , + warning: , + info: , + }; + const bgMap = { + success: 'bg-success-light border-success/20', + warning: 'bg-warning-light border-warning/20', + info: 'bg-primary-light/10 border-primary/20', + }; + + return ( +
+
+
{iconMap[insight.type]}
+
+
{insight.title}
+
{insight.message}
+
+
+
+ ); +} + +function MiniBar({ value, max, color }: { value: number; max: number; color: string }) { + const pct = max > 0 ? Math.min(100, (value / max) * 100) : 0; + return ( +
+
+
+ ); +} + +export default function WeeklyDigest() { + const [digest, setDigest] = useState(null); + const [history, setHistory] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [currentMonday, setCurrentMonday] = useState(() => getMonday(new Date())); + const [showHistory, setShowHistory] = useState(false); + + const fetchDigest = async (monday: Date) => { + setLoading(true); + setError(null); + try { + const res = await getWeeklyDigest(isoDate(monday)); + setDigest(res); + } catch (err: unknown) { + setError(err instanceof Error ? err.message : 'Failed to load digest'); + } finally { + setLoading(false); + } + }; + + const fetchHistory = async () => { + try { + const res = await getDigestHistory(12); + setHistory(res); + } catch { + // silently fail for history + } + }; + + useEffect(() => { + fetchDigest(currentMonday); + fetchHistory(); + }, [currentMonday]); + + const navigateWeek = (direction: number) => { + const newMonday = new Date(currentMonday); + newMonday.setDate(newMonday.getDate() + direction * 7); + setCurrentMonday(newMonday); + }; + + const goToCurrentWeek = () => { + setCurrentMonday(getMonday(new Date())); + }; + + const summary = digest?.summary; + const overview = summary?.overview; + const comparison = summary?.comparison; + + const maxDailySpend = useMemo(() => { + if (!summary?.daily_breakdown) return 0; + return Math.max(...summary.daily_breakdown.map((d) => d.expenses), 1); + }, [summary]); + + const maxCategoryAmount = useMemo(() => { + if (!summary?.category_breakdown?.length) return 0; + return Math.max(...summary.category_breakdown.map((c) => c.amount), 1); + }, [summary]); + + const trendMax = useMemo(() => { + if (!summary?.trend?.length) return 0; + return Math.max(...summary.trend.map((w) => Math.max(w.expenses, w.income)), 1); + }, [summary]); + + if (showHistory) { + return ( +
+
+
+
+

Digest History

+

Browse your past weekly financial digests.

+
+ +
+
+ +
+ {history.length === 0 ? ( + + +
+ No digest history yet. Generate your first weekly digest to get started. +
+
+
+ ) : ( + history.map((item) => ( + { + setCurrentMonday(new Date(item.week_start + 'T00:00:00')); + setShowHistory(false); + }} + > + +
+
+
+ +
+
+
+ {formatWeekRange(item.week_start, item.week_end)} +
+
+ Generated{' '} + {item.generated_at + ? new Date(item.generated_at).toLocaleDateString() + : 'N/A'} +
+
+
+
+
+ {currency(item.summary?.overview?.total_expenses || 0)} spent +
+
= 0 + ? 'text-success' + : 'text-destructive' + }`} + > + {(item.summary?.overview?.net_flow || 0) >= 0 ? '+' : ''} + {currency(item.summary?.overview?.net_flow || 0)} net +
+
+
+
+
+ )) + )} +
+
+ ); + } + + return ( +
+ {/* Header */} +
+
+
+

Weekly Digest

+

+ {summary + ? formatWeekRange(summary.period.week_start, summary.period.week_end) + : 'Your weekly financial summary'} +

+
+
+ + + + + +
+
+
+ + {error &&
{error}
} + + {loading ? ( +
+ {[1, 2, 3, 4].map((i) => ( + + +
+ + + ))} +
+ ) : ( + <> + {/* Summary Cards */} +
+ {/* Net Flow */} + + +
+ + Net Flow + + +
+
+ +
+ {currency(overview?.net_flow || 0)} +
+
+ {(overview?.net_flow || 0) >= 0 ? ( + + ) : ( + + )} + = 0 + ? 'text-success font-medium' + : 'text-destructive font-medium' + } + > + {(overview?.net_flow || 0) >= 0 ? 'Surplus' : 'Deficit'} + +
+
+
+ + {/* Total Spending */} + + +
+ + Total Spending + + +
+
+ +
+ {currency(overview?.total_expenses || 0)} +
+
+ {comparison?.spending_change_pct !== null && + comparison?.spending_change_pct !== undefined ? ( + <> + {comparison.spending_change_pct > 0 ? ( + + ) : ( + + )} + 0 + ? 'text-destructive font-medium mr-2' + : 'text-success font-medium mr-2' + } + > + {comparison.spending_change_pct > 0 ? '+' : ''} + {comparison.spending_change_pct.toFixed(1)}% + + vs last week + + ) : ( + No prior week data + )} +
+
+
+ + {/* Savings Rate */} + + +
+ + Savings Rate + + +
+
+ +
+ {overview?.savings_rate !== null && overview?.savings_rate !== undefined + ? `${overview.savings_rate.toFixed(1)}%` + : '--'} +
+
+ {overview?.savings_rate !== null && overview?.savings_rate !== undefined ? ( + <> + {overview.savings_rate >= 20 ? ( + + ) : ( + + )} + + {overview.savings_rate >= 20 ? 'On track' : 'Target: 20%+'} + + + ) : ( + No income recorded + )} +
+
+
+ + {/* Transactions */} + + +
+ + Transactions + + +
+
+ +
+ {overview?.transaction_count || 0} +
+
+ + + Avg {currency(overview?.avg_daily_spending || 0)}/day + +
+
+
+
+ + {/* Main Content Grid */} +
+ {/* Left Column: Charts */} +
+ {/* Daily Spending Chart */} + + +
+ Daily Spending + +
+ + Expense distribution across the week + +
+ +
+ {summary?.daily_breakdown?.map((day) => ( +
+
+ + {day.day_name.slice(0, 3)} + + + {currency(day.expenses)} + {day.income > 0 && ( + +{currency(day.income)} + )} + +
+ +
+ ))} +
+
+
+ + {/* 3-Week Trend */} + + +
+ + 3-Week Trend + + +
+ + Rolling spending and income comparison + +
+ +
+ {summary?.trend?.map((week, idx) => { + const isCurrentWeek = idx === (summary?.trend?.length || 1) - 1; + return ( +
+
+ + {formatWeekRange(week.week_start, week.week_end)} + {isCurrentWeek && ( + + Current + + )} + + = 0 ? 'text-success' : 'text-destructive' + }`} + > + {week.net >= 0 ? '+' : ''} + {currency(week.net)} + +
+
+
+
Income
+ +
{currency(week.income)}
+
+
+
Expenses
+ +
+ {currency(week.expenses)} +
+
+
+
+ ); + })} +
+
+
+ + {/* Income vs Expenses Comparison */} + + + + Week-over-Week Comparison + + + This week vs previous week + + + +
+
+
+
+ This Week +
+
+ {currency(overview?.total_expenses || 0)} +
+
+ Income: {currency(overview?.total_income || 0)} +
+
+
+
+
+
+ Previous Week +
+
+ {currency(comparison?.prev_week_expenses || 0)} +
+
+ Income: {currency(comparison?.prev_week_income || 0)} +
+
+
+
+
+
+
+ + {/* Right Column: Breakdown & Insights */} +
+ {/* Category Breakdown */} + + + + Category Breakdown + + Where your money went + + + {!summary?.category_breakdown?.length ? ( +
+ No expenses this week. +
+ ) : ( +
+ {summary.category_breakdown.map((cat) => ( +
+
+ {cat.category_name} + + {currency(cat.amount)} ({cat.share_pct.toFixed(0)}%) + +
+ +
+ ))} +
+ )} +
+
+ + {/* Biggest Expense */} + {summary?.biggest_expense && ( + + + + Biggest Expense + + + +
+ {currency(summary.biggest_expense.amount)} +
+
+ {summary.biggest_expense.description} +
+
+ {formatDate(summary.biggest_expense.date)} +
+
+
+ )} + + {/* Smart Insights */} + + + Smart Insights + AI-powered observations + + + {!summary?.insights?.length ? ( +
+ Add more transactions to unlock insights. +
+ ) : ( +
+ {summary.insights.map((insight, idx) => ( + + ))} +
+ )} +
+
+
+
+ + )} +
+ ); +} diff --git a/packages/backend/app/db/schema.sql b/packages/backend/app/db/schema.sql index 410189de..ee7c863e 100644 --- a/packages/backend/app/db/schema.sql +++ b/packages/backend/app/db/schema.sql @@ -123,3 +123,14 @@ CREATE TABLE IF NOT EXISTS audit_logs ( action VARCHAR(100) NOT NULL, created_at TIMESTAMP NOT NULL DEFAULT NOW() ); + +CREATE TABLE IF NOT EXISTS weekly_digests ( + id SERIAL PRIMARY KEY, + user_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + week_start DATE NOT NULL, + week_end DATE NOT NULL, + summary JSONB NOT NULL, + generated_at TIMESTAMP NOT NULL DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_weekly_digests_user_week ON weekly_digests(user_id, week_start DESC); +CREATE UNIQUE INDEX IF NOT EXISTS idx_weekly_digests_user_week_unique ON weekly_digests(user_id, week_start); diff --git a/packages/backend/app/models.py b/packages/backend/app/models.py index 64d44810..50015b9b 100644 --- a/packages/backend/app/models.py +++ b/packages/backend/app/models.py @@ -1,6 +1,7 @@ from datetime import datetime, date from enum import Enum from sqlalchemy import Enum as SAEnum +from sqlalchemy.dialects.postgresql import JSON as PG_JSON from .extensions import db @@ -133,3 +134,13 @@ class AuditLog(db.Model): user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=True) action = db.Column(db.String(100), nullable=False) created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) + + +class WeeklyDigest(db.Model): + __tablename__ = "weekly_digests" + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False) + week_start = db.Column(db.Date, nullable=False) + week_end = db.Column(db.Date, nullable=False) + summary = db.Column(db.JSON, nullable=False) + generated_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) 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..03bf8061 --- /dev/null +++ b/packages/backend/app/routes/digest.py @@ -0,0 +1,66 @@ +"""Weekly financial digest API routes.""" + +from datetime import date +from flask import Blueprint, jsonify, request +from flask_jwt_extended import jwt_required, get_jwt_identity +import logging + +from ..services.digest import generate_weekly_digest, save_digest, get_digest_history + +bp = Blueprint("digest", __name__) +logger = logging.getLogger("finmind.digest") + + +@bp.get("/weekly") +@jwt_required() +def weekly_digest(): + """Generate (or regenerate) the weekly digest for a given week. + + Query params: + - week: ISO date (YYYY-MM-DD) falling within the desired week. + Defaults to today (current week). + """ + uid = int(get_jwt_identity()) + raw_date = (request.args.get("week") or "").strip() + ref_date = None + if raw_date: + try: + ref_date = date.fromisoformat(raw_date) + except ValueError: + return jsonify(error="invalid week parameter, expected YYYY-MM-DD"), 400 + + summary = generate_weekly_digest(uid, ref_date) + digest = save_digest(uid, summary) + logger.info( + "Weekly digest generated user=%s week=%s", uid, summary["period"]["week_start"] + ) + return jsonify( + { + "id": digest.id, + "week_start": digest.week_start.isoformat(), + "week_end": digest.week_end.isoformat(), + "generated_at": digest.generated_at.isoformat() + if digest.generated_at + else None, + "summary": summary, + } + ) + + +@bp.get("/history") +@jwt_required() +def digest_history(): + """List past weekly digests for the authenticated user. + + Query params: + - limit: max number of digests to return (default 12, max 52). + """ + uid = int(get_jwt_identity()) + try: + limit = min(52, max(1, int(request.args.get("limit", "12")))) + except ValueError: + limit = 12 + + history = get_digest_history(uid, limit=limit) + logger.info("Digest history served user=%s count=%s", uid, len(history)) + return jsonify(history) diff --git a/packages/backend/app/services/cache.py b/packages/backend/app/services/cache.py index cc5eb9a1..c1715a20 100644 --- a/packages/backend/app/services/cache.py +++ b/packages/backend/app/services/cache.py @@ -23,6 +23,10 @@ def dashboard_summary_key(user_id: int, ym: str) -> str: return f"user:{user_id}:dashboard_summary:{ym}" +def weekly_digest_key(user_id: int, week_start: str) -> str: + return f"user:{user_id}:weekly_digest:{week_start}" + + def cache_set(key: str, value, ttl_seconds: int | None = None): payload = json.dumps(value) if ttl_seconds: diff --git a/packages/backend/app/services/digest.py b/packages/backend/app/services/digest.py new file mode 100644 index 00000000..6cb44c4e --- /dev/null +++ b/packages/backend/app/services/digest.py @@ -0,0 +1,410 @@ +"""Weekly financial digest generation service. + +Computes comprehensive weekly summaries including spending trends, +category breakdowns, savings rate, and rolling analysis. +""" + +from datetime import date, datetime, timedelta +from decimal import Decimal +from typing import Any + +from sqlalchemy import func, and_ + +from ..extensions import db +from ..models import Expense, Category, WeeklyDigest + + +def _week_bounds(ref: date | None = None) -> tuple[date, date]: + """Return (Monday, Sunday) of the week containing *ref* (default today).""" + d = ref or date.today() + monday = d - timedelta(days=d.weekday()) + sunday = monday + timedelta(days=6) + return monday, sunday + + +def _prev_week_bounds(week_start: date) -> tuple[date, date]: + prev_monday = week_start - timedelta(days=7) + prev_sunday = prev_monday + timedelta(days=6) + return prev_monday, prev_sunday + + +def _query_totals(user_id: int, start: date, end: date) -> dict[str, float]: + """Return income and expense totals for a date range.""" + income = ( + db.session.query(func.coalesce(func.sum(Expense.amount), 0)) + .filter( + Expense.user_id == user_id, + Expense.spent_at >= start, + Expense.spent_at <= end, + Expense.expense_type == "INCOME", + ) + .scalar() + ) + expenses = ( + db.session.query(func.coalesce(func.sum(Expense.amount), 0)) + .filter( + Expense.user_id == user_id, + Expense.spent_at >= start, + Expense.spent_at <= end, + Expense.expense_type != "INCOME", + ) + .scalar() + ) + return { + "income": float(income or 0), + "expenses": float(expenses or 0), + } + + +def _category_breakdown(user_id: int, start: date, end: date) -> list[dict[str, Any]]: + """Category-level expense breakdown for a date range.""" + rows = ( + db.session.query( + Expense.category_id, + func.coalesce(Category.name, "Uncategorized").label("category_name"), + func.coalesce(func.sum(Expense.amount), 0).label("total"), + ) + .outerjoin( + Category, + and_(Category.id == Expense.category_id, Category.user_id == user_id), + ) + .filter( + Expense.user_id == user_id, + Expense.spent_at >= start, + Expense.spent_at <= end, + Expense.expense_type != "INCOME", + ) + .group_by(Expense.category_id, Category.name) + .order_by(func.sum(Expense.amount).desc()) + .all() + ) + total = sum(float(r.total or 0) for r in rows) + return [ + { + "category_id": r.category_id, + "category_name": r.category_name, + "amount": round(float(r.total or 0), 2), + "share_pct": round((float(r.total or 0) / total) * 100, 2) if total > 0 else 0, + } + for r in rows + ] + + +def _biggest_expense(user_id: int, start: date, end: date) -> dict[str, Any] | None: + """Find the single largest expense in the period.""" + row = ( + db.session.query(Expense) + .filter( + Expense.user_id == user_id, + Expense.spent_at >= start, + Expense.spent_at <= end, + Expense.expense_type != "INCOME", + ) + .order_by(Expense.amount.desc()) + .first() + ) + if not row: + return None + return { + "id": row.id, + "amount": float(row.amount), + "description": row.notes or "Transaction", + "date": row.spent_at.isoformat(), + "category_id": row.category_id, + } + + +def _pct_change(current: float, previous: float) -> float | None: + """Calculate percentage change; returns None if previous is zero.""" + if previous == 0: + return None + return round(((current - previous) / previous) * 100, 2) + + +def _rolling_trend(user_id: int, current_week_start: date, num_weeks: int = 3) -> list[dict]: + """Return spending totals for the last *num_weeks* weeks (including current).""" + weeks = [] + for i in range(num_weeks): + ws = current_week_start - timedelta(weeks=i) + we = ws + timedelta(days=6) + totals = _query_totals(user_id, ws, we) + weeks.append({ + "week_start": ws.isoformat(), + "week_end": we.isoformat(), + "income": totals["income"], + "expenses": totals["expenses"], + "net": round(totals["income"] - totals["expenses"], 2), + }) + weeks.reverse() # oldest first + return weeks + + +def _daily_breakdown(user_id: int, start: date, end: date) -> list[dict]: + """Per-day spending totals for chart visualization.""" + days = [] + current = start + while current <= end: + day_total = ( + db.session.query(func.coalesce(func.sum(Expense.amount), 0)) + .filter( + Expense.user_id == user_id, + Expense.spent_at == current, + Expense.expense_type != "INCOME", + ) + .scalar() + ) + day_income = ( + db.session.query(func.coalesce(func.sum(Expense.amount), 0)) + .filter( + Expense.user_id == user_id, + Expense.spent_at == current, + Expense.expense_type == "INCOME", + ) + .scalar() + ) + days.append({ + "date": current.isoformat(), + "day_name": current.strftime("%A"), + "expenses": float(day_total or 0), + "income": float(day_income or 0), + }) + current += timedelta(days=1) + return days + + +def generate_weekly_digest(user_id: int, ref_date: date | None = None) -> dict[str, Any]: + """Build a comprehensive weekly financial digest. + + Parameters + ---------- + user_id : int + The authenticated user. + ref_date : date, optional + Any date within the target week. Defaults to today. + + Returns + ------- + dict + Full digest payload suitable for JSON serialization. + """ + week_start, week_end = _week_bounds(ref_date) + prev_start, prev_end = _prev_week_bounds(week_start) + + # Core totals + current = _query_totals(user_id, week_start, week_end) + previous = _query_totals(user_id, prev_start, prev_end) + + spending_change = _pct_change(current["expenses"], previous["expenses"]) + income_change = _pct_change(current["income"], previous["income"]) + + # Savings rate + savings_rate = None + if current["income"] > 0: + savings_rate = round( + ((current["income"] - current["expenses"]) / current["income"]) * 100, 2 + ) + + # Category breakdown + categories = _category_breakdown(user_id, week_start, week_end) + + # Biggest expense + biggest = _biggest_expense(user_id, week_start, week_end) + + # Rolling trend (3-week) + trend = _rolling_trend(user_id, week_start, num_weeks=3) + + # Daily breakdown + daily = _daily_breakdown(user_id, week_start, week_end) + + # Transaction count + tx_count = ( + db.session.query(func.count(Expense.id)) + .filter( + Expense.user_id == user_id, + Expense.spent_at >= week_start, + Expense.spent_at <= week_end, + ) + .scalar() + ) or 0 + + # Average daily spending + days_elapsed = max(1, (min(date.today(), week_end) - week_start).days + 1) + avg_daily_spending = round(current["expenses"] / days_elapsed, 2) + + # Build insights list + insights = _generate_insights( + current, previous, spending_change, income_change, + savings_rate, categories, biggest, trend + ) + + summary = { + "period": { + "week_start": week_start.isoformat(), + "week_end": week_end.isoformat(), + }, + "overview": { + "total_income": current["income"], + "total_expenses": current["expenses"], + "net_flow": round(current["income"] - current["expenses"], 2), + "savings_rate": savings_rate, + "transaction_count": tx_count, + "avg_daily_spending": avg_daily_spending, + }, + "comparison": { + "prev_week_income": previous["income"], + "prev_week_expenses": previous["expenses"], + "spending_change_pct": spending_change, + "income_change_pct": income_change, + }, + "category_breakdown": categories, + "biggest_expense": biggest, + "daily_breakdown": daily, + "trend": trend, + "insights": insights, + } + + return summary + + +def _generate_insights( + current: dict, previous: dict, + spending_change: float | None, income_change: float | None, + savings_rate: float | None, categories: list, + biggest: dict | None, trend: list, +) -> list[dict[str, str]]: + """Generate human-readable insight bullets.""" + insights: list[dict[str, str]] = [] + + # Spending trend + if spending_change is not None: + if spending_change > 10: + insights.append({ + "type": "warning", + "title": "Spending Increase", + "message": f"Your spending increased by {spending_change:.1f}% compared to last week.", + }) + elif spending_change < -10: + insights.append({ + "type": "success", + "title": "Spending Decrease", + "message": f"Great job! Spending decreased by {abs(spending_change):.1f}% from last week.", + }) + else: + insights.append({ + "type": "info", + "title": "Spending Stable", + "message": f"Your spending changed by {spending_change:+.1f}% compared to last week.", + }) + elif current["expenses"] > 0: + insights.append({ + "type": "info", + "title": "First Week Tracked", + "message": "No previous week data for comparison. Keep tracking for trend analysis!", + }) + + # Savings rate + if savings_rate is not None: + if savings_rate >= 20: + insights.append({ + "type": "success", + "title": "Strong Savings", + "message": f"You saved {savings_rate:.1f}% of your income this week. Excellent!", + }) + elif savings_rate >= 0: + insights.append({ + "type": "info", + "title": "Savings Rate", + "message": f"You saved {savings_rate:.1f}% of your income. Aim for 20%+.", + }) + else: + insights.append({ + "type": "warning", + "title": "Overspending", + "message": f"You spent {abs(savings_rate):.1f}% more than you earned this week.", + }) + + # Top category + if categories: + top = categories[0] + insights.append({ + "type": "info", + "title": "Top Spending Category", + "message": f'"{top["category_name"]}" was your largest expense category at {top["share_pct"]:.0f}% of spending.', + }) + + # Biggest single expense + if biggest: + insights.append({ + "type": "info", + "title": "Biggest Single Expense", + "message": f'Your largest transaction was "{biggest["description"]}" for {biggest["amount"]:.2f}.', + }) + + # Rolling trend + if len(trend) >= 3: + expenses_trend = [w["expenses"] for w in trend] + if all(expenses_trend[i] < expenses_trend[i + 1] for i in range(len(expenses_trend) - 1)): + insights.append({ + "type": "warning", + "title": "Rising Spending Trend", + "message": "Your expenses have increased for 3 consecutive weeks.", + }) + elif all(expenses_trend[i] > expenses_trend[i + 1] for i in range(len(expenses_trend) - 1)): + insights.append({ + "type": "success", + "title": "Declining Spending Trend", + "message": "Your expenses have decreased for 3 consecutive weeks. Keep it up!", + }) + + return insights + + +def save_digest(user_id: int, summary: dict[str, Any]) -> WeeklyDigest: + """Persist a generated digest to the database.""" + period = summary["period"] + week_start = date.fromisoformat(period["week_start"]) + week_end = date.fromisoformat(period["week_end"]) + + # Upsert: replace existing digest for the same week + existing = ( + db.session.query(WeeklyDigest) + .filter_by(user_id=user_id, week_start=week_start) + .first() + ) + if existing: + existing.week_end = week_end + existing.summary = summary + existing.generated_at = datetime.utcnow() + db.session.commit() + return existing + + digest = WeeklyDigest( + user_id=user_id, + week_start=week_start, + week_end=week_end, + summary=summary, + ) + db.session.add(digest) + db.session.commit() + return digest + + +def get_digest_history(user_id: int, limit: int = 12) -> list[dict[str, Any]]: + """Return past weekly digests ordered newest-first.""" + digests = ( + db.session.query(WeeklyDigest) + .filter_by(user_id=user_id) + .order_by(WeeklyDigest.week_start.desc()) + .limit(limit) + .all() + ) + return [ + { + "id": d.id, + "week_start": d.week_start.isoformat(), + "week_end": d.week_end.isoformat(), + "generated_at": d.generated_at.isoformat() if d.generated_at else None, + "summary": d.summary, + } + for d in digests + ] diff --git a/packages/backend/tests/test_digest.py b/packages/backend/tests/test_digest.py new file mode 100644 index 00000000..a42c6775 --- /dev/null +++ b/packages/backend/tests/test_digest.py @@ -0,0 +1,323 @@ +"""Tests for the weekly financial digest feature.""" + +from datetime import date, timedelta + + +def _seed_expenses(client, auth_header, base_date=None): + """Seed sample income and expenses for testing.""" + if base_date is None: + base_date = date.today() + + # Income + r = client.post( + "/expenses", + json={ + "amount": 5000, + "description": "Salary", + "date": base_date.isoformat(), + "expense_type": "INCOME", + }, + headers=auth_header, + ) + assert r.status_code == 201 + + # Expenses in various categories + r = client.post("/categories", json={"name": "Food"}, headers=auth_header) + assert r.status_code == 201 + food_id = r.get_json()["id"] + + r = client.post("/categories", json={"name": "Transport"}, headers=auth_header) + assert r.status_code == 201 + transport_id = r.get_json()["id"] + + r = client.post( + "/expenses", + json={ + "amount": 800, + "description": "Weekly groceries", + "date": base_date.isoformat(), + "expense_type": "EXPENSE", + "category_id": food_id, + }, + headers=auth_header, + ) + assert r.status_code == 201 + + r = client.post( + "/expenses", + json={ + "amount": 200, + "description": "Bus pass", + "date": base_date.isoformat(), + "expense_type": "EXPENSE", + "category_id": transport_id, + }, + headers=auth_header, + ) + assert r.status_code == 201 + + r = client.post( + "/expenses", + json={ + "amount": 1500, + "description": "New headphones", + "date": (base_date + timedelta(days=1)).isoformat() + if base_date.weekday() < 6 + else base_date.isoformat(), + "expense_type": "EXPENSE", + }, + headers=auth_header, + ) + assert r.status_code == 201 + + return food_id, transport_id + + +def test_weekly_digest_returns_comprehensive_data(client, auth_header): + """The weekly digest should return all expected sections.""" + _seed_expenses(client, auth_header) + + r = client.get("/digest/weekly", headers=auth_header) + assert r.status_code == 200 + data = r.get_json() + + # Top-level keys + assert "id" in data + assert "week_start" in data + assert "week_end" in data + assert "generated_at" in data + assert "summary" in data + + summary = data["summary"] + assert "period" in summary + assert "overview" in summary + assert "comparison" in summary + assert "category_breakdown" in summary + assert "daily_breakdown" in summary + assert "trend" in summary + assert "insights" in summary + + # Overview fields + overview = summary["overview"] + assert overview["total_income"] >= 5000 + assert overview["total_expenses"] >= 2500 + assert overview["net_flow"] is not None + assert overview["transaction_count"] >= 4 + assert overview["avg_daily_spending"] >= 0 + + # Category breakdown should be a list + assert isinstance(summary["category_breakdown"], list) + assert len(summary["category_breakdown"]) >= 1 + + # Daily breakdown should have 7 entries (Mon-Sun) + assert isinstance(summary["daily_breakdown"], list) + assert len(summary["daily_breakdown"]) == 7 + + # Trend should have 3 weeks + assert isinstance(summary["trend"], list) + assert len(summary["trend"]) == 3 + + # Insights should be a list + assert isinstance(summary["insights"], list) + + +def test_weekly_digest_with_specific_week(client, auth_header): + """Pass a specific week parameter to generate a digest for that week.""" + today = date.today() + monday = today - timedelta(days=today.weekday()) + _seed_expenses(client, auth_header, base_date=monday) + + r = client.get( + f"/digest/weekly?week={monday.isoformat()}", headers=auth_header + ) + assert r.status_code == 200 + data = r.get_json() + assert data["summary"]["period"]["week_start"] == monday.isoformat() + + +def test_weekly_digest_invalid_week_param(client, auth_header): + """Invalid week parameter should return 400.""" + r = client.get("/digest/weekly?week=not-a-date", headers=auth_header) + assert r.status_code == 400 + assert "invalid" in r.get_json()["error"].lower() + + +def test_weekly_digest_empty_week(client, auth_header): + """A week with no data should still return a valid (zero) digest.""" + far_past = date(2020, 1, 6) # a Monday + r = client.get( + f"/digest/weekly?week={far_past.isoformat()}", headers=auth_header + ) + assert r.status_code == 200 + data = r.get_json() + overview = data["summary"]["overview"] + assert overview["total_income"] == 0 + assert overview["total_expenses"] == 0 + assert overview["net_flow"] == 0 + assert overview["transaction_count"] == 0 + + +def test_weekly_digest_comparison_with_previous_week(client, auth_header): + """Seed two weeks of data and verify the comparison section.""" + today = date.today() + monday = today - timedelta(days=today.weekday()) + prev_monday = monday - timedelta(days=7) + + # Previous week: small expenses + client.post( + "/expenses", + json={ + "amount": 100, + "description": "Small expense last week", + "date": prev_monday.isoformat(), + "expense_type": "EXPENSE", + }, + headers=auth_header, + ) + + # Current week: larger expenses + client.post( + "/expenses", + json={ + "amount": 500, + "description": "Big expense this week", + "date": monday.isoformat(), + "expense_type": "EXPENSE", + }, + headers=auth_header, + ) + + r = client.get( + f"/digest/weekly?week={monday.isoformat()}", headers=auth_header + ) + assert r.status_code == 200 + data = r.get_json() + comparison = data["summary"]["comparison"] + assert comparison["prev_week_expenses"] == 100.0 + assert comparison["spending_change_pct"] is not None + assert comparison["spending_change_pct"] > 0 # spending went up + + +def test_weekly_digest_savings_rate(client, auth_header): + """Verify savings rate calculation.""" + today = date.today() + monday = today - timedelta(days=today.weekday()) + + client.post( + "/expenses", + json={ + "amount": 1000, + "description": "Weekly pay", + "date": monday.isoformat(), + "expense_type": "INCOME", + }, + headers=auth_header, + ) + client.post( + "/expenses", + json={ + "amount": 600, + "description": "Spending", + "date": monday.isoformat(), + "expense_type": "EXPENSE", + }, + headers=auth_header, + ) + + r = client.get( + f"/digest/weekly?week={monday.isoformat()}", headers=auth_header + ) + assert r.status_code == 200 + overview = r.get_json()["summary"]["overview"] + # savings_rate = (1000 - 600) / 1000 * 100 = 40% + assert overview["savings_rate"] == 40.0 + + +def test_biggest_expense_identified(client, auth_header): + """The biggest expense should be correctly identified.""" + today = date.today() + monday = today - timedelta(days=today.weekday()) + + client.post( + "/expenses", + json={ + "amount": 50, + "description": "Coffee", + "date": monday.isoformat(), + "expense_type": "EXPENSE", + }, + headers=auth_header, + ) + client.post( + "/expenses", + json={ + "amount": 9999, + "description": "Rent payment", + "date": monday.isoformat(), + "expense_type": "EXPENSE", + }, + headers=auth_header, + ) + + r = client.get( + f"/digest/weekly?week={monday.isoformat()}", headers=auth_header + ) + assert r.status_code == 200 + biggest = r.get_json()["summary"]["biggest_expense"] + assert biggest is not None + assert biggest["description"] == "Rent payment" + assert biggest["amount"] == 9999.0 + + +def test_digest_history_endpoint(client, auth_header): + """After generating a digest, it should appear in history.""" + # Generate a digest first + r = client.get("/digest/weekly", headers=auth_header) + assert r.status_code == 200 + + # Now check history + r = client.get("/digest/history", headers=auth_header) + assert r.status_code == 200 + history = r.get_json() + assert isinstance(history, list) + assert len(history) >= 1 + assert "week_start" in history[0] + assert "summary" in history[0] + + +def test_digest_history_limit(client, auth_header): + """History should respect the limit parameter.""" + # Generate digest for current week + client.get("/digest/weekly", headers=auth_header) + + r = client.get("/digest/history?limit=1", headers=auth_header) + assert r.status_code == 200 + assert len(r.get_json()) <= 1 + + +def test_digest_idempotent_regeneration(client, auth_header): + """Generating a digest for the same week twice should update, not duplicate.""" + r1 = client.get("/digest/weekly", headers=auth_header) + assert r1.status_code == 200 + id1 = r1.get_json()["id"] + + r2 = client.get("/digest/weekly", headers=auth_header) + assert r2.status_code == 200 + id2 = r2.get_json()["id"] + + # Same record updated, not a new one + assert id1 == id2 + + # History should show exactly one entry for this week + r = client.get("/digest/history", headers=auth_header) + week_starts = [d["week_start"] for d in r.get_json()] + assert len(set(week_starts)) == len(week_starts) # no duplicates + + +def test_digest_requires_auth(client): + """Endpoints should require authentication.""" + r = client.get("/digest/weekly") + assert r.status_code == 401 + + r = client.get("/digest/history") + assert r.status_code == 401