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:
@@ -28,6 +28,7 @@ type DashboardSnapshot = {
|
|||||||
categoryBreakdown: Array<{ category: string; amountCents: number }>;
|
categoryBreakdown: Array<{ category: string; amountCents: number }>;
|
||||||
recentExpenses: Array<{ id: string; title: string; amountCents: number; date: string; category: string }>;
|
recentExpenses: Array<{ id: string; title: string; amountCents: number; date: string; category: string }>;
|
||||||
chart: Array<{ date: string; expensesCents: number; paychecksCents: number }>;
|
chart: Array<{ date: string; expensesCents: number; paychecksCents: number }>;
|
||||||
|
paySchedule: { amountCents: number; anchorDate: string; projectedDates: string[] } | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
type OllamaStatus = {
|
type OllamaStatus = {
|
||||||
@@ -49,7 +50,7 @@ export function HomeDashboard() {
|
|||||||
|
|
||||||
async function loadDashboard(month: string) {
|
async function loadDashboard(month: string) {
|
||||||
const response = await fetch(`/dashboard?month=${month}`, { cache: "no-store" });
|
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) {
|
if (!response.ok) {
|
||||||
setError(payload.error ?? "Could not load the dashboard.");
|
setError(payload.error ?? "Could not load the dashboard.");
|
||||||
@@ -261,18 +262,101 @@ export function HomeDashboard() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{snapshot?.insight ? (
|
{snapshot?.insight ? (
|
||||||
<div className="mt-6 grid gap-4 lg:grid-cols-[1.2fr_0.8fr]">
|
<div className="mt-6 space-y-4">
|
||||||
<article className="rounded-3xl border border-stone-200 bg-[#fffcf7] px-5 py-5">
|
{/* AI summary */}
|
||||||
<p className="text-xs uppercase tracking-[0.2em] text-stone-500">Summary</p>
|
<div className="rounded-3xl border border-amber-100 bg-gradient-to-br from-[#fffdf8] to-[#fff8ec] px-6 py-6">
|
||||||
<p className="mt-3 text-base leading-7 text-stone-700">{snapshot.insight.summary}</p>
|
<div className="flex items-center gap-2">
|
||||||
</article>
|
<span className="text-xs font-semibold uppercase tracking-[0.2em] text-amber-700">AI Summary</span>
|
||||||
<article className="rounded-3xl border border-stone-200 bg-[#f8faf7] px-5 py-5">
|
<span className="rounded-full bg-amber-100 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-amber-600">✦ Offline</span>
|
||||||
<p className="text-xs uppercase tracking-[0.2em] text-stone-500">Next month guidance</p>
|
</div>
|
||||||
<p className="mt-3 text-base leading-7 text-stone-700">{snapshot.insight.recommendations}</p>
|
<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">
|
<p className="mt-4 text-xs uppercase tracking-[0.2em] text-stone-400">
|
||||||
Generated {new Date(snapshot.insight.generatedAt).toLocaleString()}
|
Generated {new Date(snapshot.insight.generatedAt).toLocaleString()}
|
||||||
</p>
|
</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>
|
||||||
) : (
|
) : (
|
||||||
<div className="mt-6 rounded-3xl border border-dashed border-stone-300 px-5 py-8 text-stone-600">
|
<div className="mt-6 rounded-3xl border border-dashed border-stone-300 px-5 py-8 text-stone-600">
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ vi.mock("@/lib/db", () => {
|
|||||||
db: {
|
db: {
|
||||||
expense: { findMany: vi.fn() },
|
expense: { findMany: vi.fn() },
|
||||||
paycheck: { findMany: vi.fn() },
|
paycheck: { findMany: vi.fn() },
|
||||||
|
paySchedule: { findFirst: vi.fn().mockResolvedValue(null) },
|
||||||
monthlyInsight,
|
monthlyInsight,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -86,7 +87,7 @@ describe("generateMonthlyInsight", () => {
|
|||||||
|
|
||||||
expect(result.source).toBe("model");
|
expect(result.source).toBe("model");
|
||||||
expect(result.insight.summary).toBe("Spending is stable.");
|
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 () => {
|
it("coerces array recommendations from the local model", async () => {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { db } from "@/lib/db";
|
|||||||
import { getCurrentMonthKey } from "@/lib/date";
|
import { getCurrentMonthKey } from "@/lib/date";
|
||||||
import { getDashboardSnapshot } from "@/lib/dashboard";
|
import { getDashboardSnapshot } from "@/lib/dashboard";
|
||||||
import { generateOllamaJson, OllamaUnavailableError } from "@/lib/ollama";
|
import { generateOllamaJson, OllamaUnavailableError } from "@/lib/ollama";
|
||||||
|
import { getCategoryLabel, type CategoryValue } from "@/lib/categories";
|
||||||
|
|
||||||
export type InsightResult = {
|
export type InsightResult = {
|
||||||
insight: MonthlyInsight;
|
insight: MonthlyInsight;
|
||||||
@@ -12,7 +13,7 @@ export type InsightResult = {
|
|||||||
|
|
||||||
type GeneratedInsightPayload = {
|
type GeneratedInsightPayload = {
|
||||||
summary?: string;
|
summary?: string;
|
||||||
recommendations?: string;
|
recommendations?: string | string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
function coerceInsightText(value: unknown) {
|
function coerceInsightText(value: unknown) {
|
||||||
@@ -39,6 +40,16 @@ function coerceInsightText(value: unknown) {
|
|||||||
return "";
|
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") {
|
function buildFallbackInsight(month: string, reason: "sparse" | "unavailable") {
|
||||||
if (reason === "unavailable") {
|
if (reason === "unavailable") {
|
||||||
return {
|
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>>) {
|
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 [
|
return [
|
||||||
"You are a private offline financial summarizer for a single-user expense tracker.",
|
"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 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.",
|
"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}`,
|
`Month: ${snapshot.month}`,
|
||||||
`Total expenses cents: ${snapshot.totals.expensesCents}`,
|
`Total expenses: ${centsToDisplay(expensesCents)}`,
|
||||||
`Total paychecks cents: ${snapshot.totals.paychecksCents}`,
|
`Total paychecks: ${centsToDisplay(paychecksCents)}`,
|
||||||
`Net cash flow cents: ${snapshot.totals.netCashFlowCents}`,
|
`Net cash flow: ${centsToDisplay(netCashFlowCents)}`,
|
||||||
`Average daily spend cents: ${snapshot.totals.averageDailySpendCents}`,
|
`Average daily spend: ${centsToDisplay(averageDailySpendCents)}`,
|
||||||
`Highest category: ${snapshot.comparisons.highestCategory ? `${snapshot.comparisons.highestCategory.category} ${snapshot.comparisons.highestCategory.amountCents}` : "none"}`,
|
savingsRatePct !== null ? `Savings rate: ${savingsRatePct}%` : "Savings rate: no income recorded",
|
||||||
`Largest expense: ${snapshot.comparisons.largestExpense ? `${snapshot.comparisons.largestExpense.title} ${snapshot.comparisons.largestExpense.amountCents}` : "none"}`,
|
spendToIncomePct !== null ? `Spend-to-income ratio: ${spendToIncomePct}%` : null,
|
||||||
`Category breakdown: ${JSON.stringify(snapshot.categoryBreakdown)}`,
|
`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)}`,
|
`Recent expenses: ${JSON.stringify(snapshot.recentExpenses)}`,
|
||||||
`Daily chart points: ${JSON.stringify(snapshot.chart)}`,
|
`Daily chart points: ${JSON.stringify(snapshot.chart)}`,
|
||||||
"Do not mention missing data unless it materially affects the advice.",
|
"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 }) {
|
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 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.");
|
throw new OllamaUnavailableError("The local model returned an incomplete insight response.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const insight = await upsertMonthlyInsight(month, {
|
const insight = await upsertMonthlyInsight(month, {
|
||||||
summary,
|
summary,
|
||||||
recommendations,
|
recommendations: JSON.stringify(recommendationsList),
|
||||||
inputSnapshot: JSON.stringify(snapshot),
|
inputSnapshot: JSON.stringify(snapshot),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user