From 27bb8df513fa4d434adc061b07be019d4034c7df Mon Sep 17 00:00:00 2001 From: Vijayakanth Manoharan Date: Mon, 23 Mar 2026 17:20:03 -0400 Subject: [PATCH] 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 --- src/components/home-dashboard.tsx | 104 +++++++++++++++++++++++++++--- src/lib/insights.test.ts | 3 +- src/lib/insights.ts | 73 ++++++++++++++++----- 3 files changed, 154 insertions(+), 26 deletions(-) diff --git a/src/components/home-dashboard.tsx b/src/components/home-dashboard.tsx index cd96632..8d01128 100644 --- a/src/components/home-dashboard.tsx +++ b/src/components/home-dashboard.tsx @@ -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() { {snapshot?.insight ? ( -
-
-

Summary

-

{snapshot.insight.summary}

-
-
-

Next month guidance

-

{snapshot.insight.recommendations}

+
+ {/* AI summary */} +
+
+ AI Summary + ✦ Offline +
+

{snapshot.insight.summary}

Generated {new Date(snapshot.insight.generatedAt).toLocaleString()}

-
+
+ + {/* Spend vs income + category bars */} +
+
+

Spend vs Income

+
+
+ + {formatCurrencyFromCents(snapshot.totals.expensesCents)} + + + of {formatCurrencyFromCents(snapshot.totals.paychecksCents)} + +
+
+
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)}%`, + }} + /> +
+

+ {snapshot.totals.paychecksCents > 0 + ? `${Math.round((snapshot.totals.expensesCents / snapshot.totals.paychecksCents) * 100)}% of income spent` + : "No income tracked this month"} +

+
+
+ +
+

Category flow

+
+ {snapshot.categoryBreakdown.slice(0, 4).map((item) => { + const pct = + snapshot.totals.expensesCents > 0 + ? (item.amountCents / snapshot.totals.expensesCents) * 100 + : 0; + return ( +
+
+ {getCategoryLabel(item.category as never)} + {formatCurrencyFromCents(item.amountCents)} +
+
+
+
+
+ ); + })} + {snapshot.categoryBreakdown.length === 0 && ( +

No categories recorded yet.

+ )} +
+
+
+ + {/* Recommendations */} +
+

Next month guidance

+
+ {(() => { + 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) => ( +
+ + {i + 1} + +

{item}

+
+ )); + })()} +
+
) : (
diff --git a/src/lib/insights.test.ts b/src/lib/insights.test.ts index 5f9176a..2acfb87 100644 --- a/src/lib/insights.test.ts +++ b/src/lib/insights.test.ts @@ -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 () => { diff --git a/src/lib/insights.ts b/src/lib/insights.ts index e251706..ffb1af2 100644 --- a/src/lib/insights.ts +++ b/src/lib/insights.ts @@ -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>) { + 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), });