diff --git a/openspec/changes/monthly-expense-tracker-v1/tasks.md b/openspec/changes/monthly-expense-tracker-v1/tasks.md index bbe9fe7..3715e31 100644 --- a/openspec/changes/monthly-expense-tracker-v1/tasks.md +++ b/openspec/changes/monthly-expense-tracker-v1/tasks.md @@ -22,8 +22,8 @@ - [x] 4.1 Implement monthly dashboard aggregation services for totals, category breakdowns, and derived comparisons. - [x] 4.2 Implement the dashboard API route and render dashboard sections for month-to-date metrics and comparisons. -- [ ] 4.3 Implement the offline `Ollama` insight service with structured monthly snapshot input and sparse-month fallback logic. -- [ ] 4.4 Implement insight generation and display in the dashboard, including persisted monthly insight records and offline-runtime fallback messaging. +- [x] 4.3 Implement the offline `Ollama` insight service with structured monthly snapshot input and sparse-month fallback logic. +- [x] 4.4 Implement insight generation and display in the dashboard, including persisted monthly insight records and offline-runtime fallback messaging. ## 5. Offline categorization @@ -35,4 +35,4 @@ ## 6. Verification - [ ] 6.1 Add automated tests for validation, persistence, dashboard aggregates, offline insight fallback behavior, and category suggestion rules. -- [ ] 6.2 Verify the primary user flows in the browser, including expense entry, paycheck entry, dashboard updates, category suggestion, and insight generation. +- [x] 6.2 Verify the primary user flows in the browser, including expense entry, paycheck entry, dashboard updates, category suggestion, and insight generation. diff --git a/package-lock.json b/package-lock.json index 434ad2c..7b1aa08 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,6 @@ "dependencies": { "@prisma/client": "^6.6.0", "next": "16.2.1", - "openai": "^5.10.2", "react": "19.2.4", "react-dom": "19.2.4", "recharts": "^2.15.4", @@ -6854,27 +6853,6 @@ "devOptional": true, "license": "MIT" }, - "node_modules/openai": { - "version": "5.23.2", - "resolved": "https://registry.npmjs.org/openai/-/openai-5.23.2.tgz", - "integrity": "sha512-MQBzmTulj+MM5O8SKEk/gL8a7s5mktS9zUtAkU257WjvobGc9nKcBuVwjyEEcb9SI8a8Y2G/mzn3vm9n1Jlleg==", - "license": "Apache-2.0", - "bin": { - "openai": "bin/cli" - }, - "peerDependencies": { - "ws": "^8.18.0", - "zod": "^3.23.8" - }, - "peerDependenciesMeta": { - "ws": { - "optional": true - }, - "zod": { - "optional": true - } - } - }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -8435,6 +8413,7 @@ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", diff --git a/package.json b/package.json index 23d994a..35a070e 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,6 @@ "dependencies": { "@prisma/client": "^6.6.0", "next": "16.2.1", - "openai": "^5.10.2", "react": "19.2.4", "react-dom": "19.2.4", "recharts": "^2.15.4", diff --git a/prisma/migrations/20260323133000_add_monthly_insight_unique/migration.sql b/prisma/migrations/20260323133000_add_monthly_insight_unique/migration.sql new file mode 100644 index 0000000..1f7ff3d --- /dev/null +++ b/prisma/migrations/20260323133000_add_monthly_insight_unique/migration.sql @@ -0,0 +1,3 @@ +-- CreateIndex +CREATE UNIQUE INDEX "MonthlyInsight_month_key" ON "MonthlyInsight"("month"); + diff --git a/prisma/schema.prisma b/prisma/schema.prisma index c0afe0c..a870cf9 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -36,7 +36,7 @@ model Paycheck { model MonthlyInsight { id String @id @default(cuid()) - month String + month String @unique year Int generatedAt DateTime @default(now()) summary String diff --git a/src/app/insights/generate/route.ts b/src/app/insights/generate/route.ts new file mode 100644 index 0000000..4223d76 --- /dev/null +++ b/src/app/insights/generate/route.ts @@ -0,0 +1,21 @@ +import { NextResponse } from "next/server"; + +import { getCurrentMonthKey } from "@/lib/date"; +import { generateMonthlyInsight } from "@/lib/insights"; +import { monthQuerySchema } from "@/lib/validation"; + +export async function POST(request: Request) { + const url = new URL(request.url); + const rawMonth = url.searchParams.get("month") ?? getCurrentMonthKey(); + const parsed = monthQuerySchema.safeParse({ month: rawMonth }); + + if (!parsed.success) { + return NextResponse.json( + { error: parsed.error.issues[0]?.message ?? "Invalid insight month." }, + { status: 400 }, + ); + } + + const result = await generateMonthlyInsight(parsed.data.month); + return NextResponse.json(result); +} diff --git a/src/components/home-dashboard.tsx b/src/components/home-dashboard.tsx index 24d82ca..b634bac 100644 --- a/src/components/home-dashboard.tsx +++ b/src/components/home-dashboard.tsx @@ -9,6 +9,11 @@ import { formatCurrencyFromCents, formatPercent } from "@/lib/money"; type DashboardSnapshot = { month: string; + insight: { + summary: string; + recommendations: string; + generatedAt: string; + } | null; totals: { expensesCents: number; paychecksCents: number; @@ -29,22 +34,27 @@ export function HomeDashboard() { const [selectedMonth, setSelectedMonth] = useState(getCurrentMonthKey()); const [snapshot, setSnapshot] = useState(null); const [error, setError] = useState(null); + const [insightBusy, setInsightBusy] = useState(false); - useEffect(() => { - async function loadDashboard() { - const response = await fetch(`/dashboard?month=${selectedMonth}`, { cache: "no-store" }); - const payload = (await response.json()) as DashboardSnapshot & { error?: string }; + async function loadDashboard(month: string) { + const response = await fetch(`/dashboard?month=${month}`, { cache: "no-store" }); + const payload = (await response.json()) as DashboardSnapshot & { error?: string }; - if (!response.ok) { - setError(payload.error ?? "Could not load the dashboard."); - return; - } - - setError(null); - setSnapshot(payload); + if (!response.ok) { + setError(payload.error ?? "Could not load the dashboard."); + return; } - void loadDashboard(); + setError(null); + setSnapshot(payload); + } + + useEffect(() => { + const timeoutId = window.setTimeout(() => { + void loadDashboard(selectedMonth); + }, 0); + + return () => window.clearTimeout(timeoutId); }, [selectedMonth]); const topCategoryLabel = useMemo(() => { @@ -60,6 +70,22 @@ export function HomeDashboard() { ? "No spend yet" : formatPercent(snapshot.totals.paycheckCoverageRatio); + async function handleGenerateInsights() { + setInsightBusy(true); + + const response = await fetch(`/insights/generate?month=${selectedMonth}`, { method: "POST" }); + const payload = (await response.json().catch(() => null)) as { error?: string } | null; + + setInsightBusy(false); + + if (!response.ok) { + setError(payload?.error ?? "Could not generate insights."); + return; + } + + await loadDashboard(selectedMonth); + } + return (
@@ -119,6 +145,43 @@ export function HomeDashboard() {
+
+
+
+

Private monthly insight

+

Offline guidance for this month

+
+ +
+ + {snapshot?.insight ? ( +
+
+

Summary

+

{snapshot.insight.summary}

+
+
+

Next month guidance

+

{snapshot.insight.recommendations}

+

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

+
+
+ ) : ( +
+ No saved insight for this month yet. Generate one to get a private offline summary. +
+ )} +
+
diff --git a/src/lib/category-suggestion.ts b/src/lib/category-suggestion.ts index f7cb249..cfe508b 100644 --- a/src/lib/category-suggestion.ts +++ b/src/lib/category-suggestion.ts @@ -1,4 +1,5 @@ import { CATEGORY_VALUES, type CategoryValue } from "@/lib/categories"; +import { generateOllamaJson } from "@/lib/ollama"; type SuggestionSource = "rule" | "model" | "unavailable"; @@ -75,25 +76,11 @@ export async function suggestCategoryForMerchant(merchantName: string): Promise< } try { - const response = await fetch(`${process.env.OLLAMA_URL ?? "http://127.0.0.1:11434"}/api/generate`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - model: process.env.OLLAMA_MODEL ?? "qwen2.5:7b", - format: "json", - stream: false, - prompt: - "You categorize personal expense merchants. Return JSON with one key named category. Allowed values only: RENT, FOOD, TRANSPORT, BILLS, SHOPPING, HEALTH, ENTERTAINMENT, MISC. Merchant: " + - normalized, - }), + const parsed = await generateOllamaJson<{ category?: string }>({ + prompt: + "You categorize personal expense merchants. Return JSON with one key named category. Allowed values only: RENT, FOOD, TRANSPORT, BILLS, SHOPPING, HEALTH, ENTERTAINMENT, MISC. Merchant: " + + normalized, }); - - if (!response.ok) { - throw new Error(`Ollama request failed with ${response.status}`); - } - - const payload = (await response.json()) as { response?: string }; - const parsed = payload.response ? JSON.parse(payload.response) : null; const category = parseSuggestedCategory(parsed?.category); if (!category) { diff --git a/src/lib/dashboard.test.ts b/src/lib/dashboard.test.ts index 7fdd5c8..6982787 100644 --- a/src/lib/dashboard.test.ts +++ b/src/lib/dashboard.test.ts @@ -32,6 +32,7 @@ describe("buildDashboardSnapshot", () => { createdAt: new Date("2026-03-01T10:00:00Z"), }, ], + insight: null, }); expect(snapshot.totals.expensesCents).toBe(123200); @@ -48,6 +49,7 @@ describe("buildDashboardSnapshot", () => { month: "2026-03", expenses: [], paychecks: [], + insight: null, }); expect(snapshot.totals.expensesCents).toBe(0); @@ -55,5 +57,6 @@ describe("buildDashboardSnapshot", () => { expect(snapshot.totals.paycheckCoverageRatio).toBeNull(); expect(snapshot.comparisons.highestCategory).toBeNull(); expect(snapshot.comparisons.largestExpense).toBeNull(); + expect(snapshot.insight).toBeNull(); }); }); diff --git a/src/lib/dashboard.ts b/src/lib/dashboard.ts index fef5266..56c01ac 100644 --- a/src/lib/dashboard.ts +++ b/src/lib/dashboard.ts @@ -1,4 +1,4 @@ -import type { Expense, Paycheck } from "@prisma/client"; +import type { Expense, MonthlyInsight, Paycheck } from "@prisma/client"; import { db } from "@/lib/db"; import { @@ -12,6 +12,11 @@ import { export type DashboardSnapshot = { month: string; + insight: { + summary: string; + recommendations: string; + generatedAt: string; + } | null; totals: { expensesCents: number; paychecksCents: number; @@ -32,6 +37,7 @@ export function buildDashboardSnapshot(input: { month: string; expenses: Expense[]; paychecks: Paycheck[]; + insight?: MonthlyInsight | null; }): DashboardSnapshot { const monthExpenses = input.expenses.filter((expense) => isDateInMonth(expense.date, input.month)); const monthPaychecks = input.paychecks.filter((paycheck) => isDateInMonth(paycheck.payDate, input.month)); @@ -80,6 +86,13 @@ export function buildDashboardSnapshot(input: { return { month: input.month, + insight: input.insight + ? { + summary: input.insight.summary, + recommendations: input.insight.recommendations, + generatedAt: input.insight.generatedAt.toISOString(), + } + : null, totals: { expensesCents, paychecksCents, @@ -115,10 +128,11 @@ export function buildDashboardSnapshot(input: { } export async function getDashboardSnapshot(month = getCurrentMonthKey()) { - const [expenses, paychecks] = await Promise.all([ + const [expenses, paychecks, insight] = await Promise.all([ db.expense.findMany({ orderBy: [{ date: "desc" }, { createdAt: "desc" }] }), db.paycheck.findMany({ orderBy: [{ payDate: "desc" }, { createdAt: "desc" }] }), + db.monthlyInsight.findUnique({ where: { month } }), ]); - return buildDashboardSnapshot({ month, expenses, paychecks }); + return buildDashboardSnapshot({ month, expenses, paychecks, insight }); } diff --git a/src/lib/insights.test.ts b/src/lib/insights.test.ts new file mode 100644 index 0000000..bc9d943 --- /dev/null +++ b/src/lib/insights.test.ts @@ -0,0 +1,91 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/db", () => { + const monthlyInsight = { + upsert: vi.fn(async ({ where, update, create }: { where: { month: string }; update: Record; create: Record }) => ({ + id: "insight-1", + month: where.month, + year: (update.year ?? create.year) as number, + summary: (update.summary ?? create.summary) as string, + recommendations: (update.recommendations ?? create.recommendations) as string, + inputSnapshot: (update.inputSnapshot ?? create.inputSnapshot) as string, + generatedAt: new Date("2026-03-23T12:00:00.000Z"), + })), + findUnique: vi.fn(), + }; + + return { + db: { + expense: { findMany: vi.fn() }, + paycheck: { findMany: vi.fn() }, + monthlyInsight, + }, + }; +}); + +describe("generateMonthlyInsight", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("stores a fallback for sparse months", async () => { + const { db } = await import("@/lib/db"); + const { generateMonthlyInsight } = await import("@/lib/insights"); + + vi.mocked(db.expense.findMany).mockResolvedValue([]); + vi.mocked(db.paycheck.findMany).mockResolvedValue([]); + + const result = await generateMonthlyInsight("2026-03"); + + expect(result.source).toBe("fallback"); + expect(result.insight.summary).toContain("not enough activity"); + }); + + it("stores model output when Ollama responds", async () => { + const { db } = await import("@/lib/db"); + const { generateMonthlyInsight } = await import("@/lib/insights"); + + vi.mocked(db.expense.findMany).mockResolvedValue([ + { + id: "expense-1", + title: "Groceries", + date: "2026-03-23", + amountCents: 3200, + category: "FOOD", + createdAt: new Date("2026-03-23T10:00:00.000Z"), + }, + { + id: "expense-2", + title: "Rent", + date: "2026-03-02", + amountCents: 120000, + category: "RENT", + createdAt: new Date("2026-03-02T10:00:00.000Z"), + }, + ]); + vi.mocked(db.paycheck.findMany).mockResolvedValue([ + { + id: "paycheck-1", + payDate: "2026-03-01", + amountCents: 180000, + createdAt: new Date("2026-03-01T10:00:00.000Z"), + }, + ]); + + vi.spyOn(globalThis, "fetch").mockResolvedValue({ + ok: true, + json: async () => ({ + response: JSON.stringify({ + summary: "Spending is stable.", + recommendations: "Keep food spending under watch.", + }), + }), + } as Response); + + const result = await generateMonthlyInsight("2026-03"); + + expect(result.source).toBe("model"); + expect(result.insight.summary).toBe("Spending is stable."); + expect(result.insight.recommendations).toBe("Keep food spending under watch."); + }); +}); diff --git a/src/lib/insights.ts b/src/lib/insights.ts new file mode 100644 index 0000000..2136d98 --- /dev/null +++ b/src/lib/insights.ts @@ -0,0 +1,149 @@ +import type { MonthlyInsight } from "@prisma/client"; + +import { db } from "@/lib/db"; +import { getCurrentMonthKey } from "@/lib/date"; +import { getDashboardSnapshot } from "@/lib/dashboard"; +import { generateOllamaJson, OllamaUnavailableError } from "@/lib/ollama"; + +export type InsightResult = { + insight: MonthlyInsight; + source: "model" | "fallback"; +}; + +type GeneratedInsightPayload = { + summary?: string; + recommendations?: string; +}; + +function coerceInsightText(value: unknown) { + if (typeof value === "string") { + return value.trim(); + } + + if (Array.isArray(value)) { + return value + .map((item) => (typeof item === "string" ? item.trim() : "")) + .filter(Boolean) + .join(" ") + .trim(); + } + + if (value && typeof value === "object") { + return Object.values(value) + .map((item) => (typeof item === "string" ? item.trim() : "")) + .filter(Boolean) + .join(" ") + .trim(); + } + + return ""; +} + +function buildFallbackInsight(month: string, reason: "sparse" | "unavailable") { + if (reason === "unavailable") { + return { + summary: `Local insights are unavailable for ${month} because Ollama or the selected model is not reachable right now.`, + recommendations: + "Keep tracking manually for now. Once Ollama is running again, regenerate this month to get a private offline summary.", + }; + } + + return { + summary: `There is not enough activity in ${month} yet to generate a useful insight summary.`, + recommendations: + "Add more expenses or paychecks this month, then generate insights again for stronger spending and timing guidance.", + }; +} + +function buildInsightPrompt(snapshot: Awaited>) { + return [ + "You are a private offline financial summarizer for a single-user expense tracker.", + "Return strict JSON with keys summary and recommendations.", + "Keep the tone practical, concise, and non-judgmental.", + "Focus on spending patterns, category spikes, paycheck timing, and 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)}`, + `Recent expenses: ${JSON.stringify(snapshot.recentExpenses)}`, + `Daily chart points: ${JSON.stringify(snapshot.chart)}`, + ].join("\n"); +} + +async function upsertMonthlyInsight(month: string, payload: { summary: string; recommendations: string; inputSnapshot: string }) { + const year = Number.parseInt(month.slice(0, 4), 10); + + return db.monthlyInsight.upsert({ + where: { month }, + update: { + year, + summary: payload.summary, + recommendations: payload.recommendations, + inputSnapshot: payload.inputSnapshot, + generatedAt: new Date(), + }, + create: { + month, + year, + summary: payload.summary, + recommendations: payload.recommendations, + inputSnapshot: payload.inputSnapshot, + }, + }); +} + +export async function getStoredMonthlyInsight(month = getCurrentMonthKey()) { + return db.monthlyInsight.findUnique({ where: { month } }); +} + +export async function generateMonthlyInsight(month = getCurrentMonthKey()): Promise { + const snapshot = await getDashboardSnapshot(month); + const totalEvents = snapshot.recentExpenses.length + snapshot.chart.filter((point) => point.paychecksCents > 0).length; + + if (totalEvents < 2) { + const fallback = buildFallbackInsight(month, "sparse"); + const insight = await upsertMonthlyInsight(month, { + ...fallback, + inputSnapshot: JSON.stringify(snapshot), + }); + + return { insight, source: "fallback" }; + } + + try { + const generated = await generateOllamaJson({ + prompt: buildInsightPrompt(snapshot), + }); + + const summary = coerceInsightText(generated.summary); + const recommendations = coerceInsightText(generated.recommendations); + + if (!summary || !recommendations) { + throw new OllamaUnavailableError("The local model returned an incomplete insight response."); + } + + const insight = await upsertMonthlyInsight(month, { + summary, + recommendations, + inputSnapshot: JSON.stringify(snapshot), + }); + + return { insight, source: "model" }; + } catch (error) { + if (!(error instanceof OllamaUnavailableError)) { + throw error; + } + + const fallback = buildFallbackInsight(month, "unavailable"); + const insight = await upsertMonthlyInsight(month, { + ...fallback, + inputSnapshot: JSON.stringify(snapshot), + }); + + return { insight, source: "fallback" }; + } +} diff --git a/src/lib/ollama.ts b/src/lib/ollama.ts new file mode 100644 index 0000000..69fdacf --- /dev/null +++ b/src/lib/ollama.ts @@ -0,0 +1,46 @@ +export class OllamaUnavailableError extends Error { + constructor(message = "Local AI runtime is unavailable.") { + super(message); + this.name = "OllamaUnavailableError"; + } +} + +type GenerateJsonInput = { + prompt: string; + model?: string; +}; + +export async function generateOllamaJson({ prompt, model }: GenerateJsonInput): Promise { + const baseUrl = (process.env.OLLAMA_URL ?? "http://127.0.0.1:11434").replace(/\/$/, ""); + const selectedModel = model ?? process.env.OLLAMA_MODEL ?? "qwen3.5:9b"; + + let response: Response; + + try { + response = await fetch(`${baseUrl}/api/generate`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + model: selectedModel, + format: "json", + stream: false, + prompt, + }), + }); + } catch { + throw new OllamaUnavailableError("Ollama is not reachable at the configured URL."); + } + + if (!response.ok) { + throw new OllamaUnavailableError(`Ollama request failed with status ${response.status}.`); + } + + const payload = (await response.json()) as { response?: string; thinking?: string }; + const jsonText = payload.response?.trim() ? payload.response : payload.thinking; + + if (!jsonText) { + throw new OllamaUnavailableError("Ollama returned an empty response."); + } + + return JSON.parse(jsonText) as T; +}