+ {/* 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) => (
+
+ ));
+ })()}
+
+
) : (
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),
});