diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..49f00db --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +.git +.next +node_modules +npm-debug.log +.env +.env.local +prisma/dev.db +prisma/dev.db-journal +coverage diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..2ff0bb6 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,20 @@ +FROM node:22-bookworm-slim AS builder +WORKDIR /app + +COPY package*.json ./ +COPY prisma ./prisma +RUN npm ci + +COPY . . +RUN npm run prisma:generate && npm run build + +FROM node:22-bookworm-slim AS runner +WORKDIR /app +ENV NODE_ENV=production +ENV PORT=3000 + +COPY --from=builder /app /app + +EXPOSE 3000 + +CMD ["sh", "-c", "npx prisma migrate deploy && npm run start -- --hostname 0.0.0.0"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..0fcb587 --- /dev/null +++ b/README.md @@ -0,0 +1,51 @@ +# Monthy Tracker + +Private monthly expense tracking with local-first storage, offline category suggestions, and offline monthly insights via `Ollama`. + +## Local app + +1. Install dependencies: + +```bash +npm install +``` + +2. Create env config from `.env.example` and keep your local runtime settings: + +```bash +cp .env.example .env +``` + +3. Apply migrations and start the app: + +```bash +npx prisma migrate deploy +npm run dev +``` + +4. Keep `Ollama` running with the configured model: + +```bash +ollama serve +ollama pull qwen3.5:9b +``` + +## Docker Compose + +Start both the app and `Ollama` together: + +```bash +docker compose up --build +``` + +This compose stack will: +- start `Ollama` +- pull `qwen3.5:9b` through the `ollama-init` service +- start the Next.js app on `http://localhost:3000` +- persist the SQLite database and pulled model with named Docker volumes + +## Environment + +- `DATABASE_URL` - Prisma SQLite connection string +- `OLLAMA_URL` - local or container Ollama base URL +- `OLLAMA_MODEL` - selected model tag, default `qwen3.5:9b` diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..f352b7f --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,50 @@ +services: + ollama: + image: ollama/ollama:latest + container_name: monthytracker-ollama + ports: + - "11434:11434" + volumes: + - ollama_data:/root/.ollama + healthcheck: + test: ["CMD", "ollama", "list"] + interval: 15s + timeout: 10s + retries: 20 + start_period: 20s + + ollama-init: + image: ollama/ollama:latest + depends_on: + ollama: + condition: service_healthy + environment: + OLLAMA_HOST: http://ollama:11434 + OLLAMA_MODEL: ${OLLAMA_MODEL:-qwen3.5:9b} + entrypoint: ["/bin/sh", "-c"] + command: "ollama pull ${OLLAMA_MODEL:-qwen3.5:9b}" + volumes: + - ollama_data:/root/.ollama + restart: "no" + + app: + build: + context: . + container_name: monthytracker-app + depends_on: + ollama: + condition: service_healthy + ollama-init: + condition: service_completed_successfully + environment: + DATABASE_URL: file:/data/dev.db + OLLAMA_URL: http://ollama:11434/ + OLLAMA_MODEL: ${OLLAMA_MODEL:-qwen3.5:9b} + ports: + - "3000:3000" + volumes: + - app_data:/data + +volumes: + ollama_data: + app_data: diff --git a/openspec/changes/monthly-expense-tracker-v1/tasks.md b/openspec/changes/monthly-expense-tracker-v1/tasks.md index 3715e31..ff494f3 100644 --- a/openspec/changes/monthly-expense-tracker-v1/tasks.md +++ b/openspec/changes/monthly-expense-tracker-v1/tasks.md @@ -1,7 +1,7 @@ ## 1. Project setup - [x] 1.1 Scaffold the `Next.js` app with TypeScript, linting, and baseline project configuration. -- [x] 1.2 Add runtime dependencies for Prisma, SQLite, validation, charts, and `OpenAI` integration. +- [x] 1.2 Add runtime dependencies for Prisma, SQLite, validation, charts, and offline AI integration. - [x] 1.3 Add development dependencies and scripts for testing, Prisma generation, and local development. - [x] 1.4 Add base environment and ignore-file setup for local database and API key configuration. @@ -34,5 +34,5 @@ ## 6. Verification -- [ ] 6.1 Add automated tests for validation, persistence, dashboard aggregates, offline insight fallback behavior, and category suggestion rules. +- [x] 6.1 Add automated tests for validation, persistence, dashboard aggregates, offline insight fallback behavior, and category suggestion rules. - [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/src/app/ollama/status/route.ts b/src/app/ollama/status/route.ts new file mode 100644 index 0000000..6d83feb --- /dev/null +++ b/src/app/ollama/status/route.ts @@ -0,0 +1,8 @@ +import { NextResponse } from "next/server"; + +import { getOllamaStatus } from "@/lib/ollama"; + +export async function GET() { + const status = await getOllamaStatus(); + return NextResponse.json(status); +} diff --git a/src/components/home-dashboard.tsx b/src/components/home-dashboard.tsx index b634bac..90c48f8 100644 --- a/src/components/home-dashboard.tsx +++ b/src/components/home-dashboard.tsx @@ -30,11 +30,21 @@ type DashboardSnapshot = { chart: Array<{ date: string; expensesCents: number; paychecksCents: number }>; }; +type OllamaStatus = { + available: boolean; + configuredModel: string; + configuredUrl: string; + installedModels: string[]; + modelReady: boolean; + message: string; +}; + export function HomeDashboard() { const [selectedMonth, setSelectedMonth] = useState(getCurrentMonthKey()); const [snapshot, setSnapshot] = useState(null); const [error, setError] = useState(null); const [insightBusy, setInsightBusy] = useState(false); + const [ollamaStatus, setOllamaStatus] = useState(null); async function loadDashboard(month: string) { const response = await fetch(`/dashboard?month=${month}`, { cache: "no-store" }); @@ -57,6 +67,16 @@ export function HomeDashboard() { return () => window.clearTimeout(timeoutId); }, [selectedMonth]); + useEffect(() => { + const timeoutId = window.setTimeout(async () => { + const response = await fetch("/ollama/status", { cache: "no-store" }); + const payload = (await response.json()) as OllamaStatus; + setOllamaStatus(payload); + }, 0); + + return () => window.clearTimeout(timeoutId); + }, []); + const topCategoryLabel = useMemo(() => { if (!snapshot?.comparisons.highestCategory) { return "No category leader yet"; @@ -161,6 +181,38 @@ export function HomeDashboard() { +
+
+
+

Ollama runtime

+

+ {ollamaStatus?.message ?? "Checking local runtime status..."} +

+
+
+ + {ollamaStatus?.available && ollamaStatus?.modelReady ? "Ready" : "Needs attention"} + +
+
+
+

+ Model: {ollamaStatus?.configuredModel ?? "-"} +

+

+ URL: {ollamaStatus?.configuredUrl ?? "-"} +

+
+
+ {snapshot?.insight ? (
diff --git a/src/lib/insights.test.ts b/src/lib/insights.test.ts index bc9d943..5f9176a 100644 --- a/src/lib/insights.test.ts +++ b/src/lib/insights.test.ts @@ -88,4 +88,51 @@ describe("generateMonthlyInsight", () => { expect(result.insight.summary).toBe("Spending is stable."); expect(result.insight.recommendations).toBe("Keep food spending under watch."); }); + + it("coerces array recommendations from the local model", 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 remains manageable.", + recommendations: ["Keep groceries planned.", "Move surplus to savings."], + }), + }), + } as Response); + + const result = await generateMonthlyInsight("2026-03"); + + expect(result.insight.recommendations).toContain("Keep groceries planned."); + expect(result.insight.recommendations).toContain("Move surplus to savings."); + }); }); diff --git a/src/lib/insights.ts b/src/lib/insights.ts index 2136d98..e251706 100644 --- a/src/lib/insights.ts +++ b/src/lib/insights.ts @@ -59,8 +59,10 @@ function buildInsightPrompt(snapshot: Awaited { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("reports model readiness when the configured model is installed", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValue({ + ok: true, + json: async () => ({ models: [{ name: "qwen3.5:9b" }] }), + } as Response); + + const status = await getOllamaStatus(); + + expect(status.available).toBe(true); + expect(status.modelReady).toBe(true); + }); +}); + +describe("generateOllamaJson", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("parses json from the thinking field when response is empty", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValue({ + ok: true, + json: async () => ({ response: "", thinking: '{"summary":"ok","recommendations":"ok"}' }), + } as Response); + + const result = await generateOllamaJson<{ summary: string; recommendations: string }>({ prompt: "test" }); + + expect(result.summary).toBe("ok"); + }); +}); diff --git a/src/lib/ollama.ts b/src/lib/ollama.ts index 69fdacf..d723084 100644 --- a/src/lib/ollama.ts +++ b/src/lib/ollama.ts @@ -5,14 +5,75 @@ export class OllamaUnavailableError extends Error { } } +export type OllamaStatus = { + available: boolean; + configuredModel: string; + configuredUrl: string; + installedModels: string[]; + modelReady: boolean; + message: string; +}; + type GenerateJsonInput = { prompt: string; model?: string; }; +function getOllamaConfig() { + return { + baseUrl: (process.env.OLLAMA_URL ?? "http://127.0.0.1:11434").replace(/\/$/, ""), + model: process.env.OLLAMA_MODEL ?? "qwen3.5:9b", + }; +} + +export async function getOllamaStatus(): Promise { + const { baseUrl, model } = getOllamaConfig(); + + try { + const response = await fetch(`${baseUrl}/api/tags`, { + method: "GET", + headers: { "Content-Type": "application/json" }, + cache: "no-store", + }); + + if (!response.ok) { + throw new OllamaUnavailableError(`Ollama status request failed with status ${response.status}.`); + } + + const payload = (await response.json()) as { models?: Array<{ name?: string }> }; + const installedModels = (payload.models ?? []).map((entry) => entry.name).filter((name): name is string => Boolean(name)); + const modelReady = installedModels.includes(model); + + return { + available: true, + configuredModel: model, + configuredUrl: baseUrl, + installedModels, + modelReady, + message: modelReady + ? `Ollama is reachable and ${model} is ready.` + : `Ollama is reachable, but ${model} is not pulled yet.`, + }; + } catch (error) { + const message = + error instanceof OllamaUnavailableError + ? error.message + : "Ollama is not reachable at the configured URL."; + + return { + available: false, + configuredModel: model, + configuredUrl: baseUrl, + installedModels: [], + modelReady: false, + message, + }; + } +} + 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"; + const { baseUrl, model: configuredModel } = getOllamaConfig(); + const selectedModel = model ?? configuredModel; let response: Response;