Improve monthly insights: structured prompt, bullet recommendations, visual cards

- Strengthen prompt with required observations (savings rate, spend-to-income,
  top category, anomaly), dollar-formatted amounts, and pay schedule context
- Preserve recommendation array structure; store as JSON array string in DB
- Render recommendations as numbered cards with icons in home dashboard
- Add spend-vs-income progress bar and category flow mini bar chart
- Fix test assertions for new JSON array recommendation format

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-23 17:20:03 -04:00
parent 5a6d03f5c8
commit 27bb8df513
3 changed files with 154 additions and 26 deletions

View File

@@ -28,6 +28,7 @@ type DashboardSnapshot = {
categoryBreakdown: Array<{ category: string; amountCents: number }>;
recentExpenses: Array<{ id: string; title: string; amountCents: number; date: string; category: string }>;
chart: Array<{ date: string; expensesCents: number; paychecksCents: number }>;
paySchedule: { amountCents: number; anchorDate: string; projectedDates: string[] } | null;
};
type OllamaStatus = {
@@ -49,7 +50,7 @@ export function HomeDashboard() {
async function loadDashboard(month: string) {
const response = await fetch(`/dashboard?month=${month}`, { cache: "no-store" });
const payload = (await response.json()) as DashboardSnapshot & { error?: string };
const payload = (await response.json().catch(() => ({ error: "Could not load the dashboard." }))) as DashboardSnapshot & { error?: string };
if (!response.ok) {
setError(payload.error ?? "Could not load the dashboard.");
@@ -261,18 +262,101 @@ export function HomeDashboard() {
</div>
{snapshot?.insight ? (
<div className="mt-6 grid gap-4 lg:grid-cols-[1.2fr_0.8fr]">
<article className="rounded-3xl border border-stone-200 bg-[#fffcf7] px-5 py-5">
<p className="text-xs uppercase tracking-[0.2em] text-stone-500">Summary</p>
<p className="mt-3 text-base leading-7 text-stone-700">{snapshot.insight.summary}</p>
</article>
<article className="rounded-3xl border border-stone-200 bg-[#f8faf7] px-5 py-5">
<p className="text-xs uppercase tracking-[0.2em] text-stone-500">Next month guidance</p>
<p className="mt-3 text-base leading-7 text-stone-700">{snapshot.insight.recommendations}</p>
<div className="mt-6 space-y-4">
{/* AI summary */}
<div className="rounded-3xl border border-amber-100 bg-gradient-to-br from-[#fffdf8] to-[#fff8ec] px-6 py-6">
<div className="flex items-center gap-2">
<span className="text-xs font-semibold uppercase tracking-[0.2em] text-amber-700">AI Summary</span>
<span className="rounded-full bg-amber-100 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-amber-600"> Offline</span>
</div>
<p className="mt-3 text-lg leading-8 text-stone-700">{snapshot.insight.summary}</p>
<p className="mt-4 text-xs uppercase tracking-[0.2em] text-stone-400">
Generated {new Date(snapshot.insight.generatedAt).toLocaleString()}
</p>
</article>
</div>
{/* Spend vs income + category bars */}
<div className="grid gap-4 sm:grid-cols-2">
<div className="rounded-3xl border border-stone-200 bg-stone-50 px-5 py-5">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-stone-500">Spend vs Income</p>
<div className="mt-4">
<div className="flex items-baseline justify-between gap-2">
<span className="text-2xl font-semibold text-stone-950">
{formatCurrencyFromCents(snapshot.totals.expensesCents)}
</span>
<span className="text-sm text-stone-500">
of {formatCurrencyFromCents(snapshot.totals.paychecksCents)}
</span>
</div>
<div className="mt-3 h-2.5 overflow-hidden rounded-full bg-stone-200">
<div
className={`h-2.5 rounded-full transition-all ${snapshot.totals.expensesCents > snapshot.totals.paychecksCents ? "bg-rose-500" : "bg-amber-500"}`}
style={{
width: `${Math.min(100, snapshot.totals.paychecksCents > 0 ? (snapshot.totals.expensesCents / snapshot.totals.paychecksCents) * 100 : 100).toFixed(1)}%`,
}}
/>
</div>
<p className="mt-2 text-xs text-stone-500">
{snapshot.totals.paychecksCents > 0
? `${Math.round((snapshot.totals.expensesCents / snapshot.totals.paychecksCents) * 100)}% of income spent`
: "No income tracked this month"}
</p>
</div>
</div>
<div className="rounded-3xl border border-stone-200 bg-stone-50 px-5 py-5">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-stone-500">Category flow</p>
<div className="mt-4 space-y-3">
{snapshot.categoryBreakdown.slice(0, 4).map((item) => {
const pct =
snapshot.totals.expensesCents > 0
? (item.amountCents / snapshot.totals.expensesCents) * 100
: 0;
return (
<div key={item.category}>
<div className="flex items-center justify-between gap-2 text-xs">
<span className="text-stone-600">{getCategoryLabel(item.category as never)}</span>
<span className="font-semibold text-stone-900">{formatCurrencyFromCents(item.amountCents)}</span>
</div>
<div className="mt-1 h-1.5 overflow-hidden rounded-full bg-stone-200">
<div
className="h-1.5 rounded-full bg-stone-700 transition-all"
style={{ width: `${pct.toFixed(1)}%` }}
/>
</div>
</div>
);
})}
{snapshot.categoryBreakdown.length === 0 && (
<p className="text-xs text-stone-400">No categories recorded yet.</p>
)}
</div>
</div>
</div>
{/* Recommendations */}
<div className="rounded-3xl border border-stone-200 bg-[#f8faf7] px-5 py-5">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-stone-500">Next month guidance</p>
<div className="mt-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{(() => {
let items: string[];
try {
const parsed = JSON.parse(snapshot.insight.recommendations);
items = Array.isArray(parsed) ? parsed : [snapshot.insight.recommendations];
} catch {
items = [snapshot.insight.recommendations];
}
return items.map((item, i) => (
<div key={i} className="flex gap-3 rounded-2xl border border-stone-200 bg-white px-4 py-4">
<span className="mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-stone-950 text-[10px] font-bold text-white">
{i + 1}
</span>
<p className="text-sm leading-6 text-stone-700">{item}</p>
</div>
));
})()}
</div>
</div>
</div>
) : (
<div className="mt-6 rounded-3xl border border-dashed border-stone-300 px-5 py-8 text-stone-600">

View File

@@ -18,6 +18,7 @@ vi.mock("@/lib/db", () => {
db: {
expense: { findMany: vi.fn() },
paycheck: { findMany: vi.fn() },
paySchedule: { findFirst: vi.fn().mockResolvedValue(null) },
monthlyInsight,
},
};
@@ -86,7 +87,7 @@ describe("generateMonthlyInsight", () => {
expect(result.source).toBe("model");
expect(result.insight.summary).toBe("Spending is stable.");
expect(result.insight.recommendations).toBe("Keep food spending under watch.");
expect(result.insight.recommendations).toBe('["Keep food spending under watch."]');
});
it("coerces array recommendations from the local model", async () => {

View File

@@ -4,6 +4,7 @@ import { db } from "@/lib/db";
import { getCurrentMonthKey } from "@/lib/date";
import { getDashboardSnapshot } from "@/lib/dashboard";
import { generateOllamaJson, OllamaUnavailableError } from "@/lib/ollama";
import { getCategoryLabel, type CategoryValue } from "@/lib/categories";
export type InsightResult = {
insight: MonthlyInsight;
@@ -12,7 +13,7 @@ export type InsightResult = {
type GeneratedInsightPayload = {
summary?: string;
recommendations?: string;
recommendations?: string | string[];
};
function coerceInsightText(value: unknown) {
@@ -39,6 +40,16 @@ function coerceInsightText(value: unknown) {
return "";
}
function coerceRecommendationsList(value: unknown): string[] {
if (Array.isArray(value)) {
return value.map((i) => (typeof i === "string" ? i.trim() : "")).filter(Boolean);
}
if (typeof value === "string" && value.trim()) {
return [value.trim()];
}
return [];
}
function buildFallbackInsight(month: string, reason: "sparse" | "unavailable") {
if (reason === "unavailable") {
return {
@@ -55,26 +66,58 @@ function buildFallbackInsight(month: string, reason: "sparse" | "unavailable") {
};
}
function centsToDisplay(cents: number) {
return `$${(cents / 100).toFixed(2)}`;
}
function buildInsightPrompt(snapshot: Awaited<ReturnType<typeof getDashboardSnapshot>>) {
const { expensesCents, paychecksCents, netCashFlowCents, averageDailySpendCents } = snapshot.totals;
const savingsRatePct =
paychecksCents > 0 ? Math.round(((paychecksCents - expensesCents) / paychecksCents) * 100) : null;
const spendToIncomePct =
paychecksCents > 0 ? Math.round((expensesCents / paychecksCents) * 100) : null;
const highestCategory = snapshot.comparisons.highestCategory
? `${getCategoryLabel(snapshot.comparisons.highestCategory.category as CategoryValue)} (${centsToDisplay(snapshot.comparisons.highestCategory.amountCents)})`
: "none";
const largestExpense = snapshot.comparisons.largestExpense
? `${snapshot.comparisons.largestExpense.title}${centsToDisplay(snapshot.comparisons.largestExpense.amountCents)} (${getCategoryLabel(snapshot.comparisons.largestExpense.category as CategoryValue)})`
: "none";
const categoryBreakdown = snapshot.categoryBreakdown.map((c) => ({
category: getCategoryLabel(c.category as CategoryValue),
amount: centsToDisplay(c.amountCents),
}));
return [
"You are a private offline financial summarizer for a single-user expense tracker.",
"Return strict JSON with keys summary and recommendations.",
"Return strict JSON with exactly two keys: summary and recommendations.",
"The summary must be a single compact paragraph of at most 3 sentences.",
"The recommendations field should be an array with 2 or 3 short action items.",
"The recommendations field must be a JSON array with 2 or 3 short action items.",
"Your summary MUST address: (1) whether spending exceeded income this month, (2) the single largest spending category and whether it dominates the budget, (3) one trend or anomaly visible from the daily chart or recent expenses.",
"Each recommendation MUST reference a specific category or expense by name.",
"Keep the tone practical, concise, specific, and non-judgmental.",
"Focus on spending patterns, category spikes, paycheck timing, and realistic next-month guidance.",
`Month: ${snapshot.month}`,
`Total expenses cents: ${snapshot.totals.expensesCents}`,
`Total paychecks cents: ${snapshot.totals.paychecksCents}`,
`Net cash flow cents: ${snapshot.totals.netCashFlowCents}`,
`Average daily spend cents: ${snapshot.totals.averageDailySpendCents}`,
`Highest category: ${snapshot.comparisons.highestCategory ? `${snapshot.comparisons.highestCategory.category} ${snapshot.comparisons.highestCategory.amountCents}` : "none"}`,
`Largest expense: ${snapshot.comparisons.largestExpense ? `${snapshot.comparisons.largestExpense.title} ${snapshot.comparisons.largestExpense.amountCents}` : "none"}`,
`Category breakdown: ${JSON.stringify(snapshot.categoryBreakdown)}`,
`Total expenses: ${centsToDisplay(expensesCents)}`,
`Total paychecks: ${centsToDisplay(paychecksCents)}`,
`Net cash flow: ${centsToDisplay(netCashFlowCents)}`,
`Average daily spend: ${centsToDisplay(averageDailySpendCents)}`,
savingsRatePct !== null ? `Savings rate: ${savingsRatePct}%` : "Savings rate: no income recorded",
spendToIncomePct !== null ? `Spend-to-income ratio: ${spendToIncomePct}%` : null,
`Highest category: ${highestCategory}`,
`Largest expense: ${largestExpense}`,
snapshot.paySchedule
? `Pay schedule: biweekly, ${centsToDisplay(snapshot.paySchedule.amountCents)} per paycheck, projected pay dates this month: ${snapshot.paySchedule.projectedDates.join(", ") || "none"}`
: null,
`Category breakdown: ${JSON.stringify(categoryBreakdown)}`,
`Recent expenses: ${JSON.stringify(snapshot.recentExpenses)}`,
`Daily chart points: ${JSON.stringify(snapshot.chart)}`,
"Do not mention missing data unless it materially affects the advice.",
].join("\n");
]
.filter(Boolean)
.join("\n");
}
async function upsertMonthlyInsight(month: string, payload: { summary: string; recommendations: string; inputSnapshot: string }) {
@@ -123,15 +166,15 @@ export async function generateMonthlyInsight(month = getCurrentMonthKey()): Prom
});
const summary = coerceInsightText(generated.summary);
const recommendations = coerceInsightText(generated.recommendations);
const recommendationsList = coerceRecommendationsList(generated.recommendations);
if (!summary || !recommendations) {
if (!summary || recommendationsList.length === 0) {
throw new OllamaUnavailableError("The local model returned an incomplete insight response.");
}
const insight = await upsertMonthlyInsight(month, {
summary,
recommendations,
recommendations: JSON.stringify(recommendationsList),
inputSnapshot: JSON.stringify(snapshot),
});