diff --git a/app/src/api/insights.ts b/app/src/api/insights.ts index 031d1e531..0e34cc0a0 100644 --- a/app/src/api/insights.ts +++ b/app/src/api/insights.ts @@ -21,6 +21,32 @@ export type BudgetSuggestion = { net_flow?: number; }; +export type WeeklyDigest = { + week_start: string; + week_end: string; + summary?: string; + highlighted_trend?: string; + action_items?: string[]; + score?: number; + suggested_total?: number; + breakdown?: { + needs: number; + wants: number; + savings: number; + }; + tips?: string[]; + analytics: { + week_over_week_change_pct: number; + current_week_expenses: number; + previous_week_expenses: number; + top_categories: Array<{ category_id: string; amount: number }>; + }; + persona?: string; + method: 'gemini' | 'heuristic' | string; + warnings?: string[]; + net_flow?: number; +}; + export async function getBudgetSuggestion(params?: { month?: string; geminiApiKey?: string; @@ -32,3 +58,23 @@ export async function getBudgetSuggestion(params?: { if (params?.persona) headers['X-Insight-Persona'] = params.persona; return api(`/insights/budget-suggestion${monthQuery}`, { headers }); } + +export async function getWeeklyDigest(params?: { + weekStart?: string; + weekEnd?: string; + geminiApiKey?: string; + persona?: string; +}): Promise { + const queryParts = []; + if (params?.weekStart) queryParts.push(`week_start=${encodeURIComponent(params.weekStart)}`); + if (params?.weekEnd) queryParts.push(`week_end=${encodeURIComponent(params.weekEnd)}`); + + const query = queryParts.length > 0 ? `?${queryParts.join('&')}` : ''; + const headers: Record = {}; + + if (params?.geminiApiKey) headers['X-Gemini-Api-Key'] = params.geminiApiKey; + if (params?.persona) headers['X-Insight-Persona'] = params.persona; + + return api(`/insights/weekly-digest${query}`, { headers }); +} + diff --git a/app/src/pages/Analytics.tsx b/app/src/pages/Analytics.tsx index 3efc8acc6..d4ddc4b76 100644 --- a/app/src/pages/Analytics.tsx +++ b/app/src/pages/Analytics.tsx @@ -1,5 +1,6 @@ import { useEffect, useMemo, useState } from 'react'; import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { @@ -10,7 +11,7 @@ import { FinancialCardTitle, } from '@/components/ui/financial-card'; import { useToast } from '@/hooks/use-toast'; -import { getBudgetSuggestion, type BudgetSuggestion } from '@/api/insights'; +import { getBudgetSuggestion, getWeeklyDigest, type BudgetSuggestion, type WeeklyDigest } from '@/api/insights'; import { formatMoney } from '@/lib/currency'; const PERSONAS = [ @@ -21,23 +22,45 @@ const PERSONAS = [ export function Analytics() { const { toast } = useToast(); + const [viewType, setViewType] = useState<'monthly' | 'weekly'>('monthly'); const [month, setMonth] = useState(() => new Date().toISOString().slice(0, 7)); + + // Weekly dates + const today = new Date(); + const startOfWeek = new Date(today); + startOfWeek.setDate(today.getDate() - today.getDay()); + const [weekStart, setWeekStart] = useState(() => startOfWeek.toISOString().slice(0, 10)); + + const endOfWeek = new Date(startOfWeek); + endOfWeek.setDate(startOfWeek.getDate() + 6); + const [weekEnd, setWeekEnd] = useState(() => endOfWeek.toISOString().slice(0, 10)); + const [persona, setPersona] = useState(PERSONAS[0]); const [geminiKey, setGeminiKey] = useState(''); const [loading, setLoading] = useState(true); - const [data, setData] = useState(null); + const [data, setData] = useState(null); const [error, setError] = useState(null); async function load() { setLoading(true); setError(null); try { - const payload = await getBudgetSuggestion({ - month, - persona, - geminiApiKey: geminiKey.trim() || undefined, - }); - setData(payload); + if (viewType === 'monthly') { + const payload = await getBudgetSuggestion({ + month, + persona, + geminiApiKey: geminiKey.trim() || undefined, + }); + setData(payload); + } else { + const payload = await getWeeklyDigest({ + weekStart, + weekEnd, + persona, + geminiApiKey: geminiKey.trim() || undefined, + }); + setData(payload); + } } catch (err: unknown) { const message = err instanceof Error ? err.message : 'Failed to load insights'; setError(message); @@ -50,10 +73,10 @@ export function Analytics() { useEffect(() => { void load(); // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + }, [viewType]); const breakdown = useMemo(() => { - if (!data) return []; + if (!data || !data.breakdown) return []; return [ { label: 'Needs', value: data.breakdown.needs }, { label: 'Wants', value: data.breakdown.wants }, @@ -61,6 +84,9 @@ export function Analytics() { ]; }, [data]); + const isWeekly = viewType === 'weekly'; + const analyticsData = data?.analytics as any; + return (
@@ -71,17 +97,57 @@ export function Analytics() { Live spending analytics with Gemini-powered budget coaching.

+ +
+ + +
+
-
- - setMonth(e.target.value)} - /> -
+ {isWeekly ? ( + <> +
+ + setWeekStart(e.target.value)} + /> +
+
+ + setWeekEnd(e.target.value)} + /> +
+ + ) : ( +
+ + setMonth(e.target.value)} + /> +
+ )} +
{error}
) : data ? (
+ {isWeekly && (data as WeeklyDigest).summary && ( + + + Weekly Summary + {data.score && Health Score: {data.score}/100} + + +

{(data as WeeklyDigest).summary}

+ {(data as WeeklyDigest).highlighted_trend && ( +

+ 💡 Trend: {(data as WeeklyDigest).highlighted_trend} +

+ )} +
+
+ )} +
@@ -133,22 +216,30 @@ export function Analytics() { Suggested Budget - {formatMoney(data.suggested_total)} + {formatMoney(data.suggested_total || 0)} - MoM Expense Change + + {isWeekly ? 'WoW Expense Change' : 'MoM Expense Change'} + - {data.analytics.month_over_month_change_pct.toFixed(2)}% + {isWeekly + ? analyticsData?.week_over_week_change_pct?.toFixed(2) + : analyticsData?.month_over_month_change_pct?.toFixed(2)}% - Current Month Expenses + + {isWeekly ? 'Current Week Expenses' : 'Current Month Expenses'} + - {formatMoney(data.analytics.current_month_expenses)} + {formatMoney(isWeekly + ? analyticsData?.current_week_expenses + : analyticsData?.current_month_expenses)}
@@ -172,17 +263,32 @@ export function Analytics() { - Coach Tips + {isWeekly ? 'Action Items & Tips' : 'Coach Tips'} + {isWeekly && (data as WeeklyDigest).action_items?.length ? ( +
+

Action Items:

+
    + {(data as WeeklyDigest).action_items?.map((item, i) => ( +
  • {item}
  • + ))} +
+
+ ) : null} + {data.tips?.length ? ( -
    - {data.tips.map((tip) => ( -
  • {tip}
  • - ))} -
+
+ {isWeekly &&

General Tips:

} +
    + {data.tips.map((tip) => ( +
  • {tip}
  • + ))} +
+
) : ( -
No tips available for this month.
+ !((data as WeeklyDigest).action_items?.length) && +
No tips available for this period.
)} {data.warnings?.length ? (
diff --git a/packages/backend/app/routes/insights.py b/packages/backend/app/routes/insights.py index bfc02e43c..c82450d7d 100644 --- a/packages/backend/app/routes/insights.py +++ b/packages/backend/app/routes/insights.py @@ -1,7 +1,7 @@ -from datetime import date +from datetime import date, timedelta 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.ai import monthly_budget_suggestion, weekly_financial_summary import logging bp = Blueprint("insights", __name__) @@ -23,3 +23,33 @@ 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()) + + today = date.today() + week_start = request.args.get("week_start") + week_end = request.args.get("week_end") + + if not week_start or not week_end: + start_of_week = today - timedelta(days=today.weekday()) + end_of_week = start_of_week + timedelta(days=6) + week_start = start_of_week.isoformat() + week_end = end_of_week.isoformat() + + user_gemini_key = (request.headers.get("X-Gemini-Api-Key") or "").strip() or None + persona = (request.headers.get("X-Insight-Persona") or "").strip() or None + + summary = weekly_financial_summary( + uid, + week_start=week_start, + week_end=week_end, + gemini_api_key=user_gemini_key, + persona=persona, + ) + logger.info("Weekly digest served user=%s week_start=%s week_end=%s", uid, week_start, week_end) + return jsonify(summary) + diff --git a/packages/backend/app/services/ai.py b/packages/backend/app/services/ai.py index 951fbd000..85242af62 100644 --- a/packages/backend/app/services/ai.py +++ b/packages/backend/app/services/ai.py @@ -1,5 +1,6 @@ import json from urllib import request +from datetime import datetime, timedelta from sqlalchemy import extract, func @@ -166,9 +167,156 @@ def _gemini_budget_suggestion( return parsed -def monthly_budget_suggestion( +def _weekly_totals(uid: int, week_start: str, week_end: str) -> tuple[float, float]: + start_date = datetime.strptime(week_start, "%Y-%m-%d").date() + end_date = datetime.strptime(week_end, "%Y-%m-%d").date() + + income = ( + db.session.query(func.coalesce(func.sum(Expense.amount), 0)) + .filter( + Expense.user_id == uid, + Expense.spent_at >= start_date, + Expense.spent_at <= end_date, + 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_date, + Expense.spent_at <= end_date, + Expense.expense_type != "INCOME", + ) + .scalar() + ) + return float(income or 0), float(expenses or 0) + + +def _category_spend_weekly(uid: int, week_start: str, week_end: str) -> dict[str, float]: + start_date = datetime.strptime(week_start, "%Y-%m-%d").date() + end_date = datetime.strptime(week_end, "%Y-%m-%d").date() + + rows = ( + db.session.query( + Expense.category_id, func.coalesce(func.sum(Expense.amount), 0) + ) + .filter( + Expense.user_id == uid, + Expense.spent_at >= start_date, + Expense.spent_at <= end_date, + Expense.expense_type != "INCOME", + ) + .group_by(Expense.category_id) + .all() + ) + return {str(k or "uncat"): float(v) for k, v in rows} + + +def _build_weekly_analytics(uid: int, week_start: str, week_end: str) -> dict: + _, current_expenses = _weekly_totals(uid, week_start, week_end) + + start_date = datetime.strptime(week_start, "%Y-%m-%d").date() + prev_week_start = (start_date - timedelta(days=7)).isoformat() + prev_week_end = (start_date - timedelta(days=1)).isoformat() + + _, prev_expenses = _weekly_totals(uid, prev_week_start, prev_week_end) + + if prev_expenses > 0: + wow = round(((current_expenses - prev_expenses) / prev_expenses) * 100, 2) + else: + wow = 0.0 + cats = _category_spend_weekly(uid, week_start, week_end) + top = sorted(cats.items(), key=lambda x: x[1], reverse=True)[:3] + return { + "week_over_week_change_pct": wow, + "current_week_expenses": round(current_expenses, 2), + "previous_week_expenses": round(prev_expenses, 2), + "top_categories": [{"category_id": k, "amount": round(v, 2)} for k, v in top], + } + + +def _heuristic_weekly_digest( + uid: int, week_start: str, week_end: str, persona: str, warnings: list[str] | None = None +): + income, expenses = _weekly_totals(uid, week_start, week_end) + target = round((expenses * 0.9) if expenses else 150.0, 2) + payload = { + "week_start": week_start, + "week_end": week_end, + "suggested_total": target, + "breakdown": { + "needs": round(target * 0.5, 2), + "wants": round(target * 0.3, 2), + "savings": round(target * 0.2, 2), + }, + "tips": [ + "Review your subscriptions for any unused services.", + "Avoid eating out for the next 2 days to save on dining.", + ], + "analytics": _build_weekly_analytics(uid, week_start, week_end), + "persona": persona, + "method": "heuristic", + } + if warnings: + payload["warnings"] = warnings + payload["net_flow"] = round(income - expenses, 2) + return payload + + +def _gemini_weekly_digest( + uid: int, week_start: str, week_end: str, api_key: str, model: str, persona: str +) -> dict: + categories = _category_spend_weekly(uid, week_start, week_end) + analytics = _build_weekly_analytics(uid, week_start, week_end) + prompt = ( + f"{persona}\n" + "Use this week data and return strict JSON only with keys: " + "summary (short 2 sentences string), highlighted_trend (string), action_items(list of 3 strings), " + "score (integer 1-100 rating financial health).\n" + f"week_start={week_start}\n" + f"week_end={week_end}\n" + f"category_spend={categories}\n" + f"analytics={analytics}" + ) + url = ( + "https://generativelanguage.googleapis.com/v1beta/models/" + f"{model}:generateContent?key={api_key}" + ) + body = json.dumps( + { + "contents": [{"parts": [{"text": prompt}]}], + "generationConfig": {"temperature": 0.2}, + } + ).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: # nosec B310 + payload = json.loads(resp.read().decode("utf-8")) + text = ( + payload.get("candidates", [{}])[0] + .get("content", {}) + .get("parts", [{}])[0] + .get("text", "") + ) + parsed = _extract_json_object(text) + parsed["week_start"] = week_start + parsed["week_end"] = week_end + parsed["analytics"] = analytics + parsed["persona"] = persona + parsed["method"] = "gemini" + return parsed + + +def weekly_financial_summary( uid: int, - ym: str, + week_start: str, + week_end: str, gemini_api_key: str | None = None, gemini_model: str | None = None, persona: str | None = None, @@ -179,9 +327,10 @@ def monthly_budget_suggestion( if key: try: - return _gemini_budget_suggestion(uid, ym, key, model, persona_text) - except Exception: - return _heuristic_budget( - uid, ym, persona_text, warnings=["gemini_unavailable"] + return _gemini_weekly_digest(uid, week_start, week_end, key, model, persona_text) + except Exception as e: + return _heuristic_weekly_digest( + uid, week_start, week_end, persona_text, warnings=[f"gemini_unavailable: {str(e)}"] ) - return _heuristic_budget(uid, ym, persona_text) + return _heuristic_weekly_digest(uid, week_start, week_end, persona_text) +