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
46 changes: 46 additions & 0 deletions app/src/api/insights.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -32,3 +58,23 @@ 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 getWeeklyDigest(params?: {
weekStart?: string;
weekEnd?: string;
geminiApiKey?: string;
persona?: string;
}): Promise<WeeklyDigest> {
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<string, string> = {};

if (params?.geminiApiKey) headers['X-Gemini-Api-Key'] = params.geminiApiKey;
if (params?.persona) headers['X-Insight-Persona'] = params.persona;

return api<WeeklyDigest>(`/insights/weekly-digest${query}`, { headers });
}

174 changes: 140 additions & 34 deletions app/src/pages/Analytics.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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 = [
Expand All @@ -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<BudgetSuggestion | null>(null);
const [data, setData] = useState<BudgetSuggestion | WeeklyDigest | null>(null);
const [error, setError] = useState<string | null>(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);
Expand All @@ -50,17 +73,20 @@ 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 },
{ label: 'Savings', value: data.breakdown.savings },
];
}, [data]);

const isWeekly = viewType === 'weekly';
const analyticsData = data?.analytics as any;

return (
<div className="page-wrap space-y-6">
<div className="page-header">
Expand All @@ -71,17 +97,57 @@ export function Analytics() {
Live spending analytics with Gemini-powered budget coaching.
</p>
</div>

<div className="flex gap-2">
<Button
variant={viewType === 'monthly' ? 'default' : 'outline'}
onClick={() => setViewType('monthly')}
>
Monthly Budget
</Button>
<Button
variant={viewType === 'weekly' ? 'default' : 'outline'}
onClick={() => setViewType('weekly')}
>
Weekly Digest
</Button>
</div>

<div className="grid gap-2 md:grid-cols-4">
<div>
<Label htmlFor="analytics-month">Month</Label>
<Input
id="analytics-month"
aria-label="analytics month"
type="month"
value={month}
onChange={(e) => setMonth(e.target.value)}
/>
</div>
{isWeekly ? (
<>
<div>
<Label htmlFor="analytics-week-start">Week Start</Label>
<Input
id="analytics-week-start"
type="date"
value={weekStart}
onChange={(e) => setWeekStart(e.target.value)}
/>
</div>
<div>
<Label htmlFor="analytics-week-end">Week End</Label>
<Input
id="analytics-week-end"
type="date"
value={weekEnd}
onChange={(e) => setWeekEnd(e.target.value)}
/>
</div>
</>
) : (
<div className="md:col-span-2">
<Label htmlFor="analytics-month">Month</Label>
<Input
id="analytics-month"
aria-label="analytics month"
type="month"
value={month}
onChange={(e) => setMonth(e.target.value)}
/>
</div>
)}

<div>
<Label htmlFor="analytics-persona">Persona</Label>
<select
Expand All @@ -98,8 +164,8 @@ export function Analytics() {
))}
</select>
</div>
<div className="md:col-span-2">
<Label htmlFor="analytics-key">Gemini API Key (optional BYOK)</Label>
<div>
<Label htmlFor="analytics-key">Gemini API Key</Label>
<Input
id="analytics-key"
aria-label="gemini api key"
Expand All @@ -122,6 +188,23 @@ export function Analytics() {
<div className="card text-red-600">{error}</div>
) : data ? (
<div className="space-y-6">
{isWeekly && (data as WeeklyDigest).summary && (
<FinancialCard variant="financial" className="bg-primary/5 border-primary/20">
<FinancialCardHeader>
<FinancialCardTitle className="text-xl">Weekly Summary</FinancialCardTitle>
{data.score && <Badge variant="secondary">Health Score: {data.score}/100</Badge>}
</FinancialCardHeader>
<FinancialCardContent>
<p className="text-lg">{(data as WeeklyDigest).summary}</p>
{(data as WeeklyDigest).highlighted_trend && (
<p className="mt-2 text-sm font-medium text-amber-700">
💡 Trend: {(data as WeeklyDigest).highlighted_trend}
</p>
)}
</FinancialCardContent>
</FinancialCard>
)}

<div className="grid gap-4 md:grid-cols-4">
<FinancialCard variant="financial">
<FinancialCardHeader className="pb-2">
Expand All @@ -133,22 +216,30 @@ export function Analytics() {
<FinancialCardHeader className="pb-2">
<FinancialCardTitle className="text-sm">Suggested Budget</FinancialCardTitle>
</FinancialCardHeader>
<FinancialCardContent>{formatMoney(data.suggested_total)}</FinancialCardContent>
<FinancialCardContent>{formatMoney(data.suggested_total || 0)}</FinancialCardContent>
</FinancialCard>
<FinancialCard variant="financial">
<FinancialCardHeader className="pb-2">
<FinancialCardTitle className="text-sm">MoM Expense Change</FinancialCardTitle>
<FinancialCardTitle className="text-sm">
{isWeekly ? 'WoW Expense Change' : 'MoM Expense Change'}
</FinancialCardTitle>
</FinancialCardHeader>
<FinancialCardContent>
{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)}%
</FinancialCardContent>
</FinancialCard>
<FinancialCard variant="financial">
<FinancialCardHeader className="pb-2">
<FinancialCardTitle className="text-sm">Current Month Expenses</FinancialCardTitle>
<FinancialCardTitle className="text-sm">
{isWeekly ? 'Current Week Expenses' : 'Current Month Expenses'}
</FinancialCardTitle>
</FinancialCardHeader>
<FinancialCardContent>
{formatMoney(data.analytics.current_month_expenses)}
{formatMoney(isWeekly
? analyticsData?.current_week_expenses
: analyticsData?.current_month_expenses)}
</FinancialCardContent>
</FinancialCard>
</div>
Expand All @@ -172,17 +263,32 @@ export function Analytics() {

<FinancialCard variant="financial">
<FinancialCardHeader>
<FinancialCardTitle>Coach Tips</FinancialCardTitle>
<FinancialCardTitle>{isWeekly ? 'Action Items & Tips' : 'Coach Tips'}</FinancialCardTitle>
</FinancialCardHeader>
<FinancialCardContent>
{isWeekly && (data as WeeklyDigest).action_items?.length ? (
<div className="mb-4">
<h4 className="font-semibold mb-2">Action Items:</h4>
<ul className="list-disc pl-5 space-y-1 text-primary">
{(data as WeeklyDigest).action_items?.map((item, i) => (
<li key={i}>{item}</li>
))}
</ul>
</div>
) : null}

{data.tips?.length ? (
<ul className="list-disc pl-5 space-y-1">
{data.tips.map((tip) => (
<li key={tip}>{tip}</li>
))}
</ul>
<div>
{isWeekly && <h4 className="font-semibold mb-2">General Tips:</h4>}
<ul className="list-disc pl-5 space-y-1">
{data.tips.map((tip) => (
<li key={tip}>{tip}</li>
))}
</ul>
</div>
) : (
<div className="text-sm text-muted-foreground">No tips available for this month.</div>
!((data as WeeklyDigest).action_items?.length) &&
<div className="text-sm text-muted-foreground">No tips available for this period.</div>
)}
{data.warnings?.length ? (
<div className="mt-3 text-sm text-amber-700">
Expand Down
34 changes: 32 additions & 2 deletions packages/backend/app/routes/insights.py
Original file line number Diff line number Diff line change
@@ -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__)
Expand All @@ -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)

Loading