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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ 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`
- Weekly Digest: `/weekly-digest` combines dashboard totals, upcoming bills, category concentration, and AI budget guidance into a shareable narrative summary.

## MVP UI/UX Plan
- Auth screens: register/login.
Expand Down
9 changes: 9 additions & 0 deletions app/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { Dashboard } from "./pages/Dashboard";
import { Budgets } from "./pages/Budgets";
import { Bills } from "./pages/Bills";
import { Analytics } from "./pages/Analytics";
import { WeeklyDigestPage } from "./pages/WeeklyDigest";
import Reminders from "./pages/Reminders";
import Expenses from "./pages/Expenses";
import { SignIn } from "./pages/SignIn";
Expand Down Expand Up @@ -75,6 +76,14 @@ const App = () => (
</ProtectedRoute>
}
/>
<Route
path="weekly-digest"
element={
<ProtectedRoute>
<WeeklyDigestPage />
</ProtectedRoute>
}
/>
<Route
path="reminders"
element={
Expand Down
57 changes: 57 additions & 0 deletions app/src/__tests__/weeklyDigest.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { buildWeeklyDigest } from '@/api/weeklyDigest';
import type { DashboardSummary } from '@/api/dashboard';
import type { BudgetSuggestion } from '@/api/insights';

const dashboard: DashboardSummary = {
period: { month: '2026-02' },
summary: {
net_flow: 450,
monthly_income: 2000,
monthly_expenses: 1550,
upcoming_bills_total: 120,
upcoming_bills_count: 2,
},
recent_transactions: [
{
id: 1,
description: 'Salary',
amount: 2000,
date: '2026-02-10',
type: 'INCOME',
category_id: null,
currency: 'USD',
},
],
upcoming_bills: [],
category_breakdown: [
{ category_id: 1, category_name: 'Food', amount: 500, share_pct: 32.25 },
],
errors: [],
};

const budget: BudgetSuggestion = {
month: '2026-02',
suggested_total: 1600,
breakdown: { needs: 800, wants: 480, savings: 320 },
tips: ['Pack lunch twice this week'],
analytics: {
month_over_month_change_pct: -4.5,
current_month_expenses: 1550,
previous_month_expenses: 1623,
top_categories: [],
},
method: 'heuristic',
};

describe('buildWeeklyDigest', () => {
it('generates a narrative digest with trends and action items', () => {
const digest = buildWeeklyDigest(dashboard, budget, new Date('2026-02-11T12:00:00Z'));

expect(digest.weekStart).toBe('2026-02-09');
expect(digest.weekEnd).toBe('2026-02-15');
expect(digest.headline).toContain('Positive net flow');
expect(digest.trends).toHaveLength(4);
expect(digest.insights.join(' ')).toContain('Food leads spending');
expect(digest.actionItems.join(' ')).toContain('upcoming bill');
});
});
125 changes: 125 additions & 0 deletions app/src/api/weeklyDigest.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import { getDashboardSummary, type DashboardSummary } from './dashboard';
import { getBudgetSuggestion, type BudgetSuggestion } from './insights';

export type WeeklyDigestTrend = {
label: string;
value: string;
tone: 'positive' | 'negative' | 'neutral';
};

export type WeeklyDigest = {
weekStart: string;
weekEnd: string;
generatedAt: string;
headline: string;
summary: string;
trends: WeeklyDigestTrend[];
insights: string[];
actionItems: string[];
};

function formatMoney(value: number, currency = 'USD') {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency,
maximumFractionDigits: 2,
}).format(Number.isFinite(value) ? value : 0);
}

function addDays(date: Date, days: number) {
const copy = new Date(date);
copy.setDate(copy.getDate() + days);
return copy;
}

function isoDate(date: Date) {
return date.toISOString().slice(0, 10);
}

function getWeekWindow(anchor = new Date()) {
const date = new Date(anchor);
const day = date.getDay();
const diffToMonday = (day + 6) % 7;
const start = addDays(date, -diffToMonday);
const end = addDays(start, 6);
return { start: isoDate(start), end: isoDate(end) };
}

function pickCurrency(dashboard: DashboardSummary) {
return (
dashboard.recent_transactions[0]?.currency ||
dashboard.upcoming_bills[0]?.currency ||
'USD'
);
}

export function buildWeeklyDigest(
dashboard: DashboardSummary,
budget: BudgetSuggestion,
anchor = new Date(),
): WeeklyDigest {
const { start, end } = getWeekWindow(anchor);
const currency = pickCurrency(dashboard);
const netFlow = dashboard.summary.net_flow;
const expenses = dashboard.summary.monthly_expenses;
const upcomingBills = dashboard.summary.upcoming_bills_total;
const mom = budget.analytics.month_over_month_change_pct;
const topCategory = dashboard.category_breakdown[0];
const topCategoryLabel = topCategory
? `${topCategory.category_name} leads spending at ${formatMoney(topCategory.amount, currency)} (${topCategory.share_pct.toFixed(1)}%).`
: 'No category has dominated spending yet.';

const headline = netFlow >= 0
? `Positive net flow of ${formatMoney(netFlow, currency)} this week`
: `Net flow is down ${formatMoney(Math.abs(netFlow), currency)} this week`;

const tips = budget.tips?.filter(Boolean) ?? [];
const insights = [
topCategoryLabel,
`Month-over-month expense movement is ${mom.toFixed(2)}%.`,
...tips.slice(0, 3),
];

const actionItems = [
upcomingBills > 0
? `Review ${dashboard.summary.upcoming_bills_count} upcoming bill(s) totaling ${formatMoney(upcomingBills, currency)}.`
: 'No upcoming bills are due soon; keep the buffer intact.',
expenses > budget.suggested_total
? `Spending is above the suggested budget by ${formatMoney(expenses - budget.suggested_total, currency)}; trim one flexible category.`
: `Spending is within the suggested budget by ${formatMoney(budget.suggested_total - expenses, currency)}; keep the current pace.`,
'Export or screenshot this digest for a weekly money check-in.',
];

return {
weekStart: start,
weekEnd: end,
generatedAt: new Date().toISOString(),
headline,
summary: `From ${start} to ${end}, FinMind reviewed cash flow, bills, category concentration, and AI budget guidance to create this weekly digest.`,
trends: [
{ label: 'Net flow', value: formatMoney(netFlow, currency), tone: netFlow >= 0 ? 'positive' : 'negative' },
{ label: 'Monthly expenses', value: formatMoney(expenses, currency), tone: expenses <= budget.suggested_total ? 'positive' : 'negative' },
{ label: 'Suggested budget', value: formatMoney(budget.suggested_total, currency), tone: 'neutral' },
{ label: 'Upcoming bills', value: formatMoney(upcomingBills, currency), tone: upcomingBills > 0 ? 'negative' : 'positive' },
],
insights,
actionItems,
};
}

export async function getWeeklyDigest(params?: {
month?: string;
persona?: string;
geminiApiKey?: string;
anchorDate?: Date;
}): Promise<WeeklyDigest> {
const [dashboard, budget] = await Promise.all([
getDashboardSummary(params?.month),
getBudgetSuggestion({
month: params?.month,
persona: params?.persona,
geminiApiKey: params?.geminiApiKey,
}),
]);
return buildWeeklyDigest(dashboard, budget, params?.anchorDate);
}
1 change: 1 addition & 0 deletions app/src/components/layout/Navbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ const navigation = [
{ name: 'Reminders', href: '/reminders' },
{ name: 'Expenses', href: '/expenses' },
{ name: 'Analytics', href: '/analytics' },
{ name: 'Weekly Digest', href: '/weekly-digest' },
];

export function Navbar() {
Expand Down
Loading