Skip to content
This repository was archived by the owner on Jun 19, 2026. It is now read-only.
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
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,14 +65,15 @@ OpenAPI: `backend/app/openapi.yaml`
- Expenses: CRUD `/expenses`
- Bills: CRUD `/bills`, pay/mark `/bills/{id}/pay`
- Reminders: CRUD `/reminders`, trigger `/reminders/run`
- Insights: `/insights/monthly`, `/insights/budget-suggestion`
- Insights: `/insights/monthly`, `/insights/budget-suggestion`, `/insights/weekly-digest`

## MVP UI/UX Plan
- Auth screens: register/login.
- Dashboard:
- Monthly spend chart, category breakdown donut.
- Upcoming bills list with due dates and pay status.
- AI budget suggestion card.
- Weekly smart digest with spend trend, category drivers, upcoming bills, and recommendations.
- Expenses page: add expense (amount, category, notes, date), list & filter.
- Bills page: create bill (name, amount, cadence, due date, channel), toggle WhatsApp/email.
- Settings: profile, categories, reminders default channel, export (premium).
Expand Down
51 changes: 51 additions & 0 deletions app/src/api/insights.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,46 @@ export type BudgetSuggestion = {
net_flow?: number;
};

export type WeeklyDigest = {
week_start: string;
week_end: string;
currency?: string | null;
summary: {
income: number;
expenses: number;
net_flow: number;
transaction_count: number;
category_breakdown: Array<{ category: string; amount: number }>;
daily_breakdown: Array<{ date: string; amount: number }>;
top_expenses: Array<{
id: number;
date: string;
description: string;
amount: number;
category: string;
}>;
};
previous_week: {
week_start: string;
week_end: string;
income: number;
expenses: number;
net_flow: number;
};
week_over_week_change_pct: number;
upcoming_bills: Array<{
id: number;
name: string;
amount: number;
currency: string;
due_date: string;
autopay_enabled: boolean;
}>;
insights: string[];
recommendations: string[];
method: string;
};

export async function getBudgetSuggestion(params?: {
month?: string;
geminiApiKey?: string;
Expand All @@ -32,3 +72,14 @@ 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?: {
week?: string;
currency?: string;
}): Promise<WeeklyDigest> {
const query = new URLSearchParams();
if (params?.week) query.set('week', params.week);
if (params?.currency) query.set('currency', params.currency);
const suffix = query.toString() ? `?${query.toString()}` : '';
return api<WeeklyDigest>(`/insights/weekly-digest${suffix}`);
}
108 changes: 107 additions & 1 deletion app/src/pages/Analytics.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,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 @@ -22,10 +22,13 @@ const PERSONAS = [
export function Analytics() {
const { toast } = useToast();
const [month, setMonth] = useState(() => new Date().toISOString().slice(0, 7));
const [week, setWeek] = useState(() => new Date().toISOString().slice(0, 10));
const [currency, setCurrency] = useState('');
const [persona, setPersona] = useState(PERSONAS[0]);
const [geminiKey, setGeminiKey] = useState('');
const [loading, setLoading] = useState(true);
const [data, setData] = useState<BudgetSuggestion | null>(null);
const [weeklyDigest, setWeeklyDigest] = useState<WeeklyDigest | null>(null);
const [error, setError] = useState<string | null>(null);

async function load() {
Expand All @@ -37,7 +40,12 @@ export function Analytics() {
persona,
geminiApiKey: geminiKey.trim() || undefined,
});
const digest = await getWeeklyDigest({
week,
currency: currency.trim() || undefined,
});
setData(payload);
setWeeklyDigest(digest);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Failed to load insights';
setError(message);
Expand Down Expand Up @@ -109,6 +117,27 @@ export function Analytics() {
placeholder="AIza..."
/>
</div>
<div>
<Label htmlFor="analytics-week">Digest Week</Label>
<Input
id="analytics-week"
aria-label="digest week"
type="date"
value={week}
onChange={(e) => setWeek(e.target.value)}
/>
</div>
<div>
<Label htmlFor="analytics-currency">Currency Filter</Label>
<Input
id="analytics-currency"
aria-label="digest currency"
value={currency}
onChange={(e) => setCurrency(e.target.value.toUpperCase())}
placeholder="USD"
maxLength={10}
/>
</div>
</div>
<Button onClick={load} disabled={loading}>
Refresh Insights
Expand Down Expand Up @@ -191,6 +220,83 @@ export function Analytics() {
) : null}
</FinancialCardContent>
</FinancialCard>

{weeklyDigest ? (
<FinancialCard variant="financial">
<FinancialCardHeader>
<FinancialCardTitle>Weekly Smart Digest</FinancialCardTitle>
<FinancialCardDescription>
{weeklyDigest.week_start} to {weeklyDigest.week_end}
</FinancialCardDescription>
</FinancialCardHeader>
<FinancialCardContent>
<div className="grid gap-3 md:grid-cols-4">
<div className="rounded-lg border p-3">
<div className="text-sm text-muted-foreground">Expenses</div>
<div className="font-semibold">
{formatMoney(weeklyDigest.summary.expenses)}
</div>
</div>
<div className="rounded-lg border p-3">
<div className="text-sm text-muted-foreground">Income</div>
<div className="font-semibold">
{formatMoney(weeklyDigest.summary.income)}
</div>
</div>
<div className="rounded-lg border p-3">
<div className="text-sm text-muted-foreground">Net Flow</div>
<div className="font-semibold">
{formatMoney(weeklyDigest.summary.net_flow)}
</div>
</div>
<div className="rounded-lg border p-3">
<div className="text-sm text-muted-foreground">WoW Change</div>
<div className="font-semibold">
{weeklyDigest.week_over_week_change_pct.toFixed(2)}%
</div>
</div>
</div>

<div className="mt-4 grid gap-4 md:grid-cols-3">
<div>
<h3 className="font-semibold">Insights</h3>
<ul className="mt-2 list-disc pl-5 text-sm space-y-1">
{weeklyDigest.insights.map((insight) => (
<li key={insight}>{insight}</li>
))}
</ul>
</div>
<div>
<h3 className="font-semibold">Recommendations</h3>
<ul className="mt-2 list-disc pl-5 text-sm space-y-1">
{weeklyDigest.recommendations.map((recommendation) => (
<li key={recommendation}>{recommendation}</li>
))}
</ul>
</div>
<div>
<h3 className="font-semibold">Upcoming Bills</h3>
{weeklyDigest.upcoming_bills.length ? (
<ul className="mt-2 text-sm space-y-2">
{weeklyDigest.upcoming_bills.map((bill) => (
<li key={bill.id} className="rounded-md border p-2">
<div className="font-medium">{bill.name}</div>
<div className="text-muted-foreground">
{formatMoney(bill.amount)} due {bill.due_date}
</div>
</li>
))}
</ul>
) : (
<div className="mt-2 text-sm text-muted-foreground">
No bills due during this week.
</div>
)}
</div>
</div>
</FinancialCardContent>
</FinancialCard>
) : null}
</div>
) : null}
</div>
Expand Down
59 changes: 59 additions & 0 deletions packages/backend/app/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -481,6 +481,65 @@ paths:
application/json:
schema: { $ref: '#/components/schemas/Error' }

/insights/weekly-digest:
get:
summary: Get weekly smart financial digest
tags: [Insights]
security: [{ bearerAuth: [] }]
parameters:
- in: query
name: week
required: false
schema: { type: string, format: date }
description: Any date in the requested week. The API normalizes it to Monday.
- in: query
name: currency
required: false
schema: { type: string }
responses:
'200':
description: Weekly digest
content:
application/json:
schema:
type: object
additionalProperties: true
example:
week_start: 2026-06-01
week_end: 2026-06-07
currency: USD
summary:
income: 0
expenses: 180
net_flow: -180
transaction_count: 2
category_breakdown:
- category: Groceries
amount: 180
previous_week:
week_start: 2026-05-25
week_end: 2026-05-31
expenses: 200
week_over_week_change_pct: -10
upcoming_bills:
- name: Internet
amount: 75
currency: USD
due_date: 2026-06-05
insights: ["Weekly expenses are broadly stable versus last week."]
recommendations: ["Keep monitoring category mix for one-off spikes."]
method: deterministic
'400':
description: Invalid week
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'401':
description: Unauthorized
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }

components:
securitySchemes:
bearerAuth:
Expand Down
20 changes: 19 additions & 1 deletion 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 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_digest
import logging

bp = Blueprint("insights", __name__)
Expand All @@ -23,3 +23,21 @@ 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())
week = (request.args.get("week") or "").strip() or None
currency = (request.args.get("currency") or "").strip() or None
try:
digest = weekly_financial_digest(uid, week=week, currency=currency)
except ValueError:
return jsonify(error="invalid week"), 400
logger.info(
"Weekly digest served user=%s week_start=%s",
uid,
digest["week_start"],
)
return jsonify(digest)
Loading