diff --git a/README.md b/README.md index 49592bff..28291110 100644 --- a/README.md +++ b/README.md @@ -55,8 +55,9 @@ See `backend/app/db/schema.sql`. Key tables: - `user:{id}:categories` — 24h TTL - `user:{id}:upcoming_bills` — 15 min TTL - `insights:{id}` — 24h TTL (invalidate on new expense/bill) + - `user:{id}:weekly_digest:{yyyy-mm-dd}` — 1h TTL (invalidate on expense changes) - Invalidation - - On expense/bill create/update/delete -> delete affected monthly_summary, upcoming_bills, insights + - On expense/bill create/update/delete -> delete affected monthly_summary, upcoming_bills, insights, weekly_digest - Rate limiting (optional): `rl:{userId}:{endpoint}:{minute}` with short TTL ## API Endpoints @@ -66,6 +67,32 @@ OpenAPI: `backend/app/openapi.yaml` - Bills: CRUD `/bills`, pay/mark `/bills/{id}/pay` - Reminders: CRUD `/reminders`, trigger `/reminders/run` - Insights: `/insights/monthly`, `/insights/budget-suggestion` +- Digest: `/digest` (weekly summary), `/digest/weeks` (available weeks) + +## Weekly Digest Feature +The Smart Digest provides weekly financial summaries with trends and insights: + +### Backend (`/digest`) +- **`GET /digest?week_start=YYYY-MM-DD`** — Returns a comprehensive weekly summary including: + - Period info (week start/end dates) + - Summary totals (income, expenses, net flow, transaction count) + - Week-over-week comparison (percentage change vs. previous week) + - Category breakdown with share percentages + - Daily spending breakdown (all 7 days, zero-filled) + - Top 5 largest transactions + - Auto-generated insights (spending trends, savings, peak days, top categories) +- **`GET /digest/weeks?count=N`** — Lists weeks that have expense data for navigation +- Results are cached in Redis (1h TTL) and auto-invalidated on expense changes +- Service layer in `packages/backend/app/services/digest.py` + +### Frontend (`/digest`) +- Full-page digest view accessible via the "Digest" nav link +- Week navigation with prev/next controls +- Summary cards for net flow, income, expenses, and week comparison +- Visual daily spending bar chart +- Top transactions list +- Category breakdown with progress bars +- Numbered insights panel ## MVP UI/UX Plan - Auth screens: register/login. @@ -108,6 +135,7 @@ finmind/ __init__.py ai.py cache.py + digest.py reminders.py db/ schema.sql diff --git a/app/src/App.tsx b/app/src/App.tsx index f0dc5942..9202091d 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 = () => ( } /> + + + + } + /> + + {ui} + + , + ); +} + +describe('Digest page', () => { + beforeEach(() => { + jest.clearAllMocks(); + (digestApi.getAvailableWeeks as jest.Mock).mockResolvedValue(mockWeeks); + (digestApi.getWeeklyDigest as jest.Mock).mockResolvedValue(mockDigest); + }); + + it('renders the page title', async () => { + renderWithProviders(); + expect(screen.getByText('Weekly Digest')).toBeInTheDocument(); + }); + + it('displays summary cards after loading', async () => { + renderWithProviders(); + await waitFor(() => { + expect(screen.getByText('Net Flow')).toBeInTheDocument(); + }); + expect(screen.getByText('Income')).toBeInTheDocument(); + expect(screen.getByText('Expenses')).toBeInTheDocument(); + expect(screen.getByText('Prev Week')).toBeInTheDocument(); + }); + + it('displays insights', async () => { + renderWithProviders(); + await waitFor(() => { + expect(screen.getByText('Insights')).toBeInTheDocument(); + }); + expect(screen.getByText(/Spending is up 21.4%/)).toBeInTheDocument(); + expect(screen.getByText(/saved/i)).toBeInTheDocument(); + }); + + it('displays category breakdown', async () => { + renderWithProviders(); + await waitFor(() => { + expect(screen.getByText('Food')).toBeInTheDocument(); + }); + expect(screen.getByText('Transport')).toBeInTheDocument(); + }); + + it('displays top transactions', async () => { + renderWithProviders(); + await waitFor(() => { + expect(screen.getByText('Restaurant dinner')).toBeInTheDocument(); + }); + expect(screen.getByText('Uber rides')).toBeInTheDocument(); + }); + + it('shows week navigation controls', async () => { + renderWithProviders(); + await waitFor(() => { + expect(screen.getByLabelText('Older week')).toBeInTheDocument(); + }); + expect(screen.getByLabelText('Newer week')).toBeInTheDocument(); + }); + + it('handles API error gracefully', async () => { + (digestApi.getWeeklyDigest as jest.Mock).mockRejectedValue(new Error('Network error')); + renderWithProviders(); + await waitFor(() => { + expect(screen.getByText(/Network error/)).toBeInTheDocument(); + }); + }); +}); diff --git a/app/src/api/digest.ts b/app/src/api/digest.ts new file mode 100644 index 00000000..2699758c --- /dev/null +++ b/app/src/api/digest.ts @@ -0,0 +1,52 @@ +import { api } from './client'; + +export type WeeklyDigest = { + period: { + week_start: string; + week_end: string; + }; + summary: { + total_income: number; + total_expenses: number; + net_flow: number; + transaction_count: number; + }; + comparison: { + prev_week_expenses: number; + prev_week_income: number; + week_over_week_change_pct: number; + }; + category_breakdown: Array<{ + category_id: number | null; + category_name: string; + amount: number; + share_pct: number; + }>; + daily_spending: Array<{ + date: string; + amount: number; + }>; + top_transactions: Array<{ + id: number; + amount: number; + description: string; + date: string; + category_id: number | null; + }>; + insights: string[]; +}; + +export type DigestWeek = { + week_start: string; + week_end: string; +}; + +export async function getWeeklyDigest(weekStart?: string): Promise { + const query = weekStart ? `?week_start=${encodeURIComponent(weekStart)}` : ''; + return api(`/digest${query}`); +} + +export async function getAvailableWeeks(count?: number): Promise { + const query = count ? `?count=${encodeURIComponent(count)}` : ''; + return api(`/digest/weeks${query}`); +} diff --git a/app/src/components/layout/Navbar.tsx b/app/src/components/layout/Navbar.tsx index c7593b70..ff882e19 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: 'Digest', href: '/digest' }, ]; export function Navbar() { diff --git a/app/src/pages/Digest.tsx b/app/src/pages/Digest.tsx new file mode 100644 index 00000000..0a5a9b1f --- /dev/null +++ b/app/src/pages/Digest.tsx @@ -0,0 +1,372 @@ +import { useEffect, useState } from 'react'; +import { + FinancialCard, + FinancialCardContent, + FinancialCardDescription, + FinancialCardHeader, + FinancialCardTitle, +} from '@/components/ui/financial-card'; +import { Button } from '@/components/ui/button'; +import { + ArrowDownRight, + ArrowUpRight, + Calendar, + ChevronLeft, + ChevronRight, + Lightbulb, + TrendingDown, + TrendingUp, + Wallet, + BarChart3, +} from 'lucide-react'; +import { + getWeeklyDigest, + getAvailableWeeks, + type WeeklyDigest, + type DigestWeek, +} from '@/api/digest'; +import { formatMoney } from '@/lib/currency'; + +function currency(n: number, code?: string) { + return formatMoney(Number(n || 0), code); +} + +function formatDateRange(start: string, end: string): string { + const s = new Date(start + 'T00:00:00'); + const e = new Date(end + 'T00:00:00'); + const opts: Intl.DateTimeFormatOptions = { month: 'short', day: 'numeric' }; + return `${s.toLocaleDateString(undefined, opts)} – ${e.toLocaleDateString(undefined, { ...opts, year: 'numeric' })}`; +} + +function dayLabel(iso: string): string { + return new Date(iso + 'T00:00:00').toLocaleDateString(undefined, { weekday: 'short' }); +} + +export default function Digest() { + const [digest, setDigest] = useState(null); + const [weeks, setWeeks] = useState([]); + const [selectedIdx, setSelectedIdx] = useState(0); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + (async () => { + try { + const available = await getAvailableWeeks(24); + setWeeks(available); + } catch { + // Non-critical — week picker just won't show + } + })(); + }, []); + + useEffect(() => { + (async () => { + setLoading(true); + setError(null); + try { + const weekStart = weeks.length > 0 ? weeks[selectedIdx]?.week_start : undefined; + const res = await getWeeklyDigest(weekStart); + setDigest(res); + } catch (err: unknown) { + setError(err instanceof Error ? err.message : 'Failed to load digest'); + } finally { + setLoading(false); + } + })(); + }, [weeks, selectedIdx]); + + const canGoNewer = selectedIdx > 0; + const canGoOlder = selectedIdx < weeks.length - 1; + + const summary = digest?.summary; + const comparison = digest?.comparison; + const wowPct = comparison?.week_over_week_change_pct ?? 0; + const wowUp = wowPct > 0; + + return ( +
+
+
+
+

Weekly Digest

+

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

+
+ {weeks.length > 0 && ( +
+ +
+ + {digest + ? formatDateRange(digest.period.week_start, digest.period.week_end) + : 'Loading...'} +
+ +
+ )} +
+
+ + {error && ( +
{error}
+ )} + + {loading && !digest && ( +
Loading digest...
+ )} + + {digest && ( + <> + {/* Summary cards */} +
+ + +
+ Net Flow + +
+
+ +
+ {currency(summary?.net_flow ?? 0)} +
+
+ {(summary?.net_flow ?? 0) >= 0 ? ( + + ) : ( + + )} + This week +
+
+
+ + + +
+ Income + +
+
+ +
+ {currency(summary?.total_income ?? 0)} +
+
+ {summary?.transaction_count ?? 0} transactions +
+
+
+ + + +
+ Expenses + +
+
+ +
+ {currency(summary?.total_expenses ?? 0)} +
+
+ {wowUp ? ( + + ) : ( + + )} + + {wowPct > 0 ? '+' : ''}{wowPct.toFixed(1)}% + + vs last week +
+
+
+ + + +
+ Prev Week + +
+
+ +
+ {currency(comparison?.prev_week_expenses ?? 0)} +
+
Previous week expenses
+
+
+
+ +
+ {/* Left column: Daily chart + Top transactions */} +
+ {/* Daily spending bar chart */} + + + Daily Spending + Day-by-day expense breakdown + + + {digest.daily_spending.length > 0 ? ( +
+ {(() => { + const maxAmt = Math.max(...digest.daily_spending.map((d) => d.amount), 1); + return digest.daily_spending.map((day) => ( +
+ + {day.amount > 0 ? currency(day.amount) : ''} + +
+ {dayLabel(day.date)} +
+ )); + })()} +
+ ) : ( +
No spending data.
+ )} + + + + {/* Top transactions */} + + + Top Transactions + Largest expenses this week + + + {digest.top_transactions.length === 0 ? ( +
No transactions this week.
+ ) : ( +
+ {digest.top_transactions.map((txn) => ( +
+
+
+ +
+
+
{txn.description}
+
+ {new Date(txn.date + 'T00:00:00').toLocaleDateString()} +
+
+
+
+ -{currency(txn.amount)} +
+
+ ))} +
+ )} +
+
+
+ + {/* Right column: Categories + Insights */} +
+ {/* Category breakdown */} + + + Category Breakdown + Where your money went + + + {digest.category_breakdown.length === 0 ? ( +
No category data this week.
+ ) : ( +
+ {digest.category_breakdown.slice(0, 8).map((row) => ( +
+
+ {row.category_name} + + {currency(row.amount)} ({row.share_pct.toFixed(0)}%) + +
+
+
+
+
+ ))} +
+ )} + + + + {/* Insights */} + + +
+ + Insights +
+ AI-powered observations +
+ + {digest.insights.length === 0 ? ( +
+ Add more transactions to unlock insights. +
+ ) : ( +
+ {digest.insights.map((insight, i) => ( +
+
+ {i + 1} +
+

{insight}

+
+ ))} +
+ )} +
+
+
+
+ + )} + + {!loading && !digest && !error && ( +
+ +

No digest available

+

+ Start tracking expenses to see your weekly financial summary here. +

+
+ )} +
+ ); +} diff --git a/packages/backend/app/openapi.yaml b/packages/backend/app/openapi.yaml index 3f8ec3f0..a2627e22 100644 --- a/packages/backend/app/openapi.yaml +++ b/packages/backend/app/openapi.yaml @@ -12,6 +12,7 @@ tags: - name: Bills - name: Reminders - name: Insights + - name: Digest paths: /auth/register: post: @@ -481,6 +482,108 @@ paths: application/json: schema: { $ref: '#/components/schemas/Error' } + /digest: + get: + summary: Get weekly financial digest + tags: [Digest] + security: + - bearerAuth: [] + parameters: + - name: week_start + in: query + required: false + description: ISO date of the Monday to report on. Defaults to last completed week. + schema: { type: string, format: date, example: '2026-03-16' } + responses: + '200': + description: Weekly digest payload + content: + application/json: + schema: + type: object + properties: + period: + type: object + properties: + week_start: { type: string, format: date } + week_end: { type: string, format: date } + summary: + type: object + properties: + total_income: { type: number } + total_expenses: { type: number } + net_flow: { type: number } + transaction_count: { type: integer } + comparison: + type: object + properties: + prev_week_expenses: { type: number } + prev_week_income: { type: number } + week_over_week_change_pct: { type: number } + category_breakdown: + type: array + items: + type: object + properties: + category_id: { type: integer, nullable: true } + category_name: { type: string } + amount: { type: number } + share_pct: { type: number } + daily_spending: + type: array + items: + type: object + properties: + date: { type: string, format: date } + amount: { type: number } + top_transactions: + type: array + items: + type: object + properties: + id: { type: integer } + amount: { type: number } + description: { type: string } + date: { type: string, format: date } + category_id: { type: integer, nullable: true } + insights: + type: array + items: { type: string } + '401': + description: Unauthorized + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + /digest/weeks: + get: + summary: List available digest weeks + tags: [Digest] + security: + - bearerAuth: [] + parameters: + - name: count + in: query + required: false + description: Number of past weeks to scan (max 52) + schema: { type: integer, default: 12 } + responses: + '200': + description: List of weeks with data + content: + application/json: + schema: + type: array + items: + type: object + properties: + week_start: { type: string, format: date } + week_end: { type: string, format: date } + '401': + description: Unauthorized + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + components: securitySchemes: bearerAuth: 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..ab7e0f7f --- /dev/null +++ b/packages/backend/app/routes/digest.py @@ -0,0 +1,49 @@ +"""Routes for weekly financial digest.""" + +from flask import Blueprint, jsonify, request +from flask_jwt_extended import jwt_required, get_jwt_identity + +from ..services.digest import generate_weekly_digest, list_available_digests +import logging + +bp = Blueprint("digest", __name__) +logger = logging.getLogger("finmind.digest") + + +@bp.get("") +@jwt_required() +def get_weekly_digest(): + """Return the weekly financial digest for a given week. + + Query params: + week_start (str, optional): ISO date of the Monday to report on. + Defaults to the last completed week. + """ + uid = int(get_jwt_identity()) + week_start = (request.args.get("week_start") or "").strip() or None + + try: + digest = generate_weekly_digest(uid, week_start) + except ValueError as exc: + return jsonify(error=str(exc)), 400 + + logger.info( + "Weekly digest served user=%s week=%s", + uid, + digest["period"]["week_start"], + ) + return jsonify(digest) + + +@bp.get("/weeks") +@jwt_required() +def get_available_weeks(): + """Return a list of weeks with expense data for digest navigation.""" + uid = int(get_jwt_identity()) + try: + count = min(52, max(1, int(request.args.get("count", "12")))) + except ValueError: + count = 12 + + weeks = list_available_digests(uid, count) + return jsonify(weeks) diff --git a/packages/backend/app/routes/expenses.py b/packages/backend/app/routes/expenses.py index 1376d46f..6a2f8a60 100644 --- a/packages/backend/app/routes/expenses.py +++ b/packages/backend/app/routes/expenses.py @@ -6,7 +6,7 @@ from flask_jwt_extended import jwt_required, get_jwt_identity from ..extensions import db from ..models import Expense, RecurringCadence, RecurringExpense, User -from ..services.cache import cache_delete_patterns, monthly_summary_key +from ..services.cache import cache_delete_patterns, monthly_summary_key, weekly_digest_pattern from ..services import expense_import import logging @@ -391,5 +391,6 @@ def _invalidate_expense_cache(uid: int, at: str): monthly_summary_key(uid, ym), f"insights:{uid}:*", f"user:{uid}:dashboard_summary:*", + weekly_digest_pattern(uid), ] ) diff --git a/packages/backend/app/services/cache.py b/packages/backend/app/services/cache.py index cc5eb9a1..4db4b5c8 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_pattern(user_id: int) -> str: + return f"user:{user_id}:weekly_digest:*" + + 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..62bcbdae --- /dev/null +++ b/packages/backend/app/services/digest.py @@ -0,0 +1,347 @@ +"""Weekly financial digest service. + +Generates structured weekly summaries from transaction data, highlighting +spending trends, category insights, and actionable observations. +""" + +from datetime import date, timedelta +from decimal import Decimal +from typing import Any + +from sqlalchemy import extract, func + +from ..extensions import db +from ..models import Category, Expense +from ..services.cache import cache_get, cache_set + + +def weekly_digest_key(user_id: int, week_start: str) -> str: + """Cache key for a user's weekly digest.""" + return f"user:{user_id}:weekly_digest:{week_start}" + + +def _week_bounds(ref: date | None = None) -> tuple[date, date]: + """Return (Monday, Sunday) of the week containing *ref* (default: last full week).""" + today = ref or date.today() + # Last completed week: previous Monday through previous Sunday + days_since_monday = today.weekday() # Monday=0 + last_monday = today - timedelta(days=days_since_monday + 7) + last_sunday = last_monday + timedelta(days=6) + return last_monday, last_sunday + + +def _week_bounds_for(week_start_iso: str) -> tuple[date, date]: + """Parse an ISO date string and return its Mon–Sun range.""" + start = date.fromisoformat(week_start_iso) + # Snap to Monday + start = start - timedelta(days=start.weekday()) + return start, start + timedelta(days=6) + + +def _prior_week(week_start: date) -> tuple[date, date]: + """Return bounds for the week before *week_start*.""" + prior_monday = week_start - timedelta(days=7) + return prior_monday, prior_monday + timedelta(days=6) + + +def _query_totals( + uid: int, start: date, end: date +) -> tuple[float, float]: + """Return (income, expenses) for a date range.""" + income = ( + db.session.query(func.coalesce(func.sum(Expense.amount), 0)) + .filter( + Expense.user_id == uid, + 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 == uid, + Expense.spent_at >= start, + Expense.spent_at <= end, + Expense.expense_type != "INCOME", + ) + .scalar() + ) + return float(income or 0), float(expenses or 0) + + +def _category_breakdown( + uid: int, start: date, end: date +) -> list[dict[str, Any]]: + """Return per-category spend for the 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, + (Category.id == Expense.category_id) & (Category.user_id == uid), + ) + .filter( + Expense.user_id == uid, + 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_spend = 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_spend) * 100, 2) + if total_spend > 0 + else 0 + ), + } + for r in rows + ] + + +def _daily_spending(uid: int, start: date, end: date) -> list[dict[str, Any]]: + """Return daily expense totals across the date range.""" + rows = ( + db.session.query( + Expense.spent_at, + func.coalesce(func.sum(Expense.amount), 0).label("total"), + ) + .filter( + Expense.user_id == uid, + Expense.spent_at >= start, + Expense.spent_at <= end, + Expense.expense_type != "INCOME", + ) + .group_by(Expense.spent_at) + .order_by(Expense.spent_at.asc()) + .all() + ) + # Fill in zero-spend days + result = [] + current = start + row_map = {r.spent_at: float(r.total or 0) for r in rows} + while current <= end: + result.append({ + "date": current.isoformat(), + "amount": round(row_map.get(current, 0.0), 2), + }) + current += timedelta(days=1) + return result + + +def _top_transactions(uid: int, start: date, end: date, limit: int = 5) -> list[dict]: + """Return the largest expense transactions in the period.""" + rows = ( + db.session.query(Expense) + .filter( + Expense.user_id == uid, + Expense.spent_at >= start, + Expense.spent_at <= end, + Expense.expense_type != "INCOME", + ) + .order_by(Expense.amount.desc()) + .limit(limit) + .all() + ) + return [ + { + "id": e.id, + "amount": float(e.amount), + "description": e.notes or "", + "date": e.spent_at.isoformat(), + "category_id": e.category_id, + } + for e in rows + ] + + +def _generate_insights( + current_income: float, + current_expenses: float, + prev_income: float, + prev_expenses: float, + categories: list[dict], + daily: list[dict], +) -> list[str]: + """Generate human-readable insight strings from the digest data.""" + insights: list[str] = [] + + # Week-over-week spending change + if prev_expenses > 0: + pct = ((current_expenses - prev_expenses) / prev_expenses) * 100 + direction = "up" if pct > 0 else "down" + insights.append( + f"Spending is {direction} {abs(pct):.1f}% compared to the previous week." + ) + elif current_expenses > 0: + insights.append("This is your first week with recorded expenses.") + + # Net flow + net = current_income - current_expenses + if net > 0: + insights.append( + f"You saved {net:,.2f} this week (income exceeded expenses)." + ) + elif net < 0: + insights.append( + f"You spent {abs(net):,.2f} more than you earned this week." + ) + + # Top category + if categories: + top = categories[0] + insights.append( + f"Your biggest spending category was {top['category_name']} " + f"at {top['amount']:,.2f} ({top['share_pct']:.0f}% of total)." + ) + + # Peak spending day + if daily: + peak = max(daily, key=lambda d: d["amount"]) + if peak["amount"] > 0: + day_name = date.fromisoformat(peak["date"]).strftime("%A") + insights.append( + f"Peak spending day was {day_name} ({peak['date']}) " + f"at {peak['amount']:,.2f}." + ) + + # Low-activity flag + active_days = sum(1 for d in daily if d["amount"] > 0) + if active_days <= 2 and current_expenses > 0: + insights.append( + "Spending was concentrated in just a few days this week." + ) + + return insights + + +def generate_weekly_digest( + uid: int, + week_start: str | None = None, + *, + use_cache: bool = True, +) -> dict[str, Any]: + """Build the full weekly digest payload for a user. + + Parameters + ---------- + uid : int + The authenticated user's ID. + week_start : str | None + ISO date of the Monday to report on. Defaults to last completed week. + use_cache : bool + When True, serve from Redis if available and cache the result. + """ + if week_start: + start, end = _week_bounds_for(week_start) + else: + start, end = _week_bounds() + + cache_key = weekly_digest_key(uid, start.isoformat()) + + if use_cache: + cached = cache_get(cache_key) + if cached: + return cached + + # Current week data + income, expenses = _query_totals(uid, start, end) + categories = _category_breakdown(uid, start, end) + daily = _daily_spending(uid, start, end) + top_txns = _top_transactions(uid, start, end) + + # Previous week data for comparison + prev_start, prev_end = _prior_week(start) + prev_income, prev_expenses = _query_totals(uid, prev_start, prev_end) + + # Week-over-week change + if prev_expenses > 0: + wow_change_pct = round( + ((expenses - prev_expenses) / prev_expenses) * 100, 2 + ) + else: + wow_change_pct = 0.0 + + insights = _generate_insights( + income, expenses, prev_income, prev_expenses, categories, daily + ) + + payload: dict[str, Any] = { + "period": { + "week_start": start.isoformat(), + "week_end": end.isoformat(), + }, + "summary": { + "total_income": round(income, 2), + "total_expenses": round(expenses, 2), + "net_flow": round(income - expenses, 2), + "transaction_count": ( + db.session.query(func.count(Expense.id)) + .filter( + Expense.user_id == uid, + Expense.spent_at >= start, + Expense.spent_at <= end, + ) + .scalar() + or 0 + ), + }, + "comparison": { + "prev_week_expenses": round(prev_expenses, 2), + "prev_week_income": round(prev_income, 2), + "week_over_week_change_pct": wow_change_pct, + }, + "category_breakdown": categories, + "daily_spending": daily, + "top_transactions": top_txns, + "insights": insights, + } + + if use_cache: + # Cache for 1 hour — digest data is historical so doesn't change often + cache_set(cache_key, payload, ttl_seconds=3600) + + return payload + + +def list_available_digests(uid: int, count: int = 12) -> list[dict[str, str]]: + """Return a list of weeks that have expense data, most recent first. + + Useful for the frontend to show a week-picker for past digests. + """ + today = date.today() + digests: list[dict[str, str]] = [] + + # Walk backwards through weeks + current_monday = today - timedelta(days=today.weekday() + 7) + for _ in range(count): + sunday = current_monday + timedelta(days=6) + has_data = ( + db.session.query(Expense.id) + .filter( + Expense.user_id == uid, + Expense.spent_at >= current_monday, + Expense.spent_at <= sunday, + ) + .first() + ) + if has_data: + digests.append({ + "week_start": current_monday.isoformat(), + "week_end": sunday.isoformat(), + }) + current_monday -= timedelta(days=7) + + return digests diff --git a/packages/backend/tests/test_digest.py b/packages/backend/tests/test_digest.py new file mode 100644 index 00000000..fd3357a5 --- /dev/null +++ b/packages/backend/tests/test_digest.py @@ -0,0 +1,298 @@ +"""Tests for the weekly financial digest feature.""" + +from datetime import date, timedelta + + +def _last_monday(): + """Return the Monday of last completed week.""" + today = date.today() + return today - timedelta(days=today.weekday() + 7) + + +def _seed_week(client, auth_header, week_start, expenses=None, incomes=None): + """Helper: create expenses/incomes within a given week.""" + for i, (amount, desc) in enumerate(expenses or []): + day = week_start + timedelta(days=min(i, 6)) + r = client.post( + "/expenses", + json={ + "amount": amount, + "description": desc, + "date": day.isoformat(), + "expense_type": "EXPENSE", + }, + headers=auth_header, + ) + assert r.status_code == 201 + + for i, (amount, desc) in enumerate(incomes or []): + day = week_start + timedelta(days=min(i, 6)) + r = client.post( + "/expenses", + json={ + "amount": amount, + "description": desc, + "date": day.isoformat(), + "expense_type": "INCOME", + }, + headers=auth_header, + ) + assert r.status_code == 201 + + +class TestWeeklyDigestEndpoint: + """Tests for GET /digest""" + + def test_digest_returns_complete_payload(self, client, auth_header): + monday = _last_monday() + _seed_week( + client, + auth_header, + monday, + expenses=[(100, "Groceries"), (50, "Coffee"), (200, "Electronics")], + incomes=[(3000, "Salary")], + ) + + r = client.get( + f"/digest?week_start={monday.isoformat()}", headers=auth_header + ) + assert r.status_code == 200 + payload = r.get_json() + + # Structure checks + assert "period" in payload + assert payload["period"]["week_start"] == monday.isoformat() + assert payload["period"]["week_end"] == (monday + timedelta(days=6)).isoformat() + + assert "summary" in payload + assert payload["summary"]["total_income"] == 3000.0 + assert payload["summary"]["total_expenses"] == 350.0 + assert payload["summary"]["net_flow"] == 2650.0 + assert payload["summary"]["transaction_count"] == 4 + + assert "comparison" in payload + assert "week_over_week_change_pct" in payload["comparison"] + + assert "category_breakdown" in payload + assert isinstance(payload["category_breakdown"], list) + + assert "daily_spending" in payload + assert len(payload["daily_spending"]) == 7 # Always 7 days + + assert "top_transactions" in payload + assert len(payload["top_transactions"]) <= 5 + + assert "insights" in payload + assert isinstance(payload["insights"], list) + assert len(payload["insights"]) > 0 + + def test_digest_defaults_to_last_week(self, client, auth_header): + monday = _last_monday() + _seed_week( + client, + auth_header, + monday, + expenses=[(75, "Lunch")], + ) + + r = client.get("/digest", headers=auth_header) + assert r.status_code == 200 + payload = r.get_json() + assert payload["period"]["week_start"] == monday.isoformat() + + def test_digest_empty_week(self, client, auth_header): + # Use a far-past week with no data + r = client.get("/digest?week_start=2020-01-06", headers=auth_header) + assert r.status_code == 200 + payload = r.get_json() + assert payload["summary"]["total_expenses"] == 0.0 + assert payload["summary"]["total_income"] == 0.0 + assert len(payload["daily_spending"]) == 7 + + def test_digest_week_over_week_comparison(self, client, auth_header): + monday = _last_monday() + prev_monday = monday - timedelta(days=7) + + _seed_week( + client, auth_header, prev_monday, expenses=[(200, "Prev week spend")] + ) + _seed_week( + client, auth_header, monday, expenses=[(300, "Current week spend")] + ) + + r = client.get( + f"/digest?week_start={monday.isoformat()}", headers=auth_header + ) + assert r.status_code == 200 + payload = r.get_json() + + assert payload["comparison"]["prev_week_expenses"] == 200.0 + assert payload["comparison"]["week_over_week_change_pct"] == 50.0 + + def test_digest_top_transactions_ordered_by_amount(self, client, auth_header): + monday = _last_monday() + _seed_week( + client, + auth_header, + monday, + expenses=[(10, "Small"), (500, "Large"), (100, "Medium")], + ) + + r = client.get( + f"/digest?week_start={monday.isoformat()}", headers=auth_header + ) + assert r.status_code == 200 + top = r.get_json()["top_transactions"] + amounts = [t["amount"] for t in top] + assert amounts == sorted(amounts, reverse=True) + + def test_digest_daily_spending_has_seven_days(self, client, auth_header): + monday = _last_monday() + # Only one expense on Wednesday + wed = monday + timedelta(days=2) + client.post( + "/expenses", + json={ + "amount": 42, + "description": "Wed only", + "date": wed.isoformat(), + "expense_type": "EXPENSE", + }, + headers=auth_header, + ) + + r = client.get( + f"/digest?week_start={monday.isoformat()}", headers=auth_header + ) + assert r.status_code == 200 + daily = r.get_json()["daily_spending"] + assert len(daily) == 7 + # Wednesday should have the spend + wed_entry = next(d for d in daily if d["date"] == wed.isoformat()) + assert wed_entry["amount"] == 42.0 + # Other days should be 0 + zero_days = [d for d in daily if d["date"] != wed.isoformat()] + assert all(d["amount"] == 0.0 for d in zero_days) + + def test_digest_with_categories(self, client, auth_header): + monday = _last_monday() + # Create a category + r = client.post( + "/categories", json={"name": "Food"}, headers=auth_header + ) + assert r.status_code == 201 + food_id = r.get_json()["id"] + + client.post( + "/expenses", + json={ + "amount": 150, + "description": "Groceries", + "date": monday.isoformat(), + "expense_type": "EXPENSE", + "category_id": food_id, + }, + headers=auth_header, + ) + + r = client.get( + f"/digest?week_start={monday.isoformat()}", headers=auth_header + ) + assert r.status_code == 200 + cats = r.get_json()["category_breakdown"] + assert len(cats) >= 1 + food_cat = next(c for c in cats if c["category_name"] == "Food") + assert food_cat["amount"] == 150.0 + assert food_cat["share_pct"] == 100.0 + + def test_digest_requires_auth(self, client): + r = client.get("/digest") + assert r.status_code == 401 + + +class TestAvailableWeeksEndpoint: + """Tests for GET /digest/weeks""" + + def test_weeks_returns_list(self, client, auth_header): + monday = _last_monday() + _seed_week( + client, auth_header, monday, expenses=[(50, "Something")] + ) + + r = client.get("/digest/weeks", headers=auth_header) + assert r.status_code == 200 + weeks = r.get_json() + assert isinstance(weeks, list) + assert len(weeks) >= 1 + assert weeks[0]["week_start"] == monday.isoformat() + + def test_weeks_empty_when_no_data(self, client, auth_header): + r = client.get("/digest/weeks", headers=auth_header) + assert r.status_code == 200 + assert r.get_json() == [] + + def test_weeks_count_param(self, client, auth_header): + r = client.get("/digest/weeks?count=3", headers=auth_header) + assert r.status_code == 200 + + def test_weeks_requires_auth(self, client): + r = client.get("/digest/weeks") + assert r.status_code == 401 + + +class TestDigestInsights: + """Tests for insight generation logic.""" + + def test_insights_include_spending_change(self, client, auth_header): + monday = _last_monday() + prev_monday = monday - timedelta(days=7) + + _seed_week(client, auth_header, prev_monday, expenses=[(100, "Prev")]) + _seed_week(client, auth_header, monday, expenses=[(150, "Curr")]) + + r = client.get( + f"/digest?week_start={monday.isoformat()}", headers=auth_header + ) + insights = r.get_json()["insights"] + assert any("50.0%" in i for i in insights) + + def test_insights_include_savings(self, client, auth_header): + monday = _last_monday() + _seed_week( + client, + auth_header, + monday, + expenses=[(100, "Spend")], + incomes=[(500, "Pay")], + ) + + r = client.get( + f"/digest?week_start={monday.isoformat()}", headers=auth_header + ) + insights = r.get_json()["insights"] + assert any("saved" in i.lower() for i in insights) + + def test_insights_include_top_category(self, client, auth_header): + monday = _last_monday() + r = client.post( + "/categories", json={"name": "Transport"}, headers=auth_header + ) + cat_id = r.get_json()["id"] + + client.post( + "/expenses", + json={ + "amount": 200, + "description": "Uber", + "date": monday.isoformat(), + "expense_type": "EXPENSE", + "category_id": cat_id, + }, + headers=auth_header, + ) + + r = client.get( + f"/digest?week_start={monday.isoformat()}", headers=auth_header + ) + insights = r.get_json()["insights"] + assert any("Transport" in i for i in insights)