Compare commits
10 Commits
12c72ddcad
...
3e6231b654
| Author | SHA1 | Date | |
|---|---|---|---|
| 3e6231b654 | |||
| 1015e24e69 | |||
| 83d6891023 | |||
| e8c23405e7 | |||
| 27bb8df513 | |||
| 5a6d03f5c8 | |||
| 28c5ad959f | |||
| 3bc8550f12 | |||
| a745c0ca1e | |||
| 696d393fca |
9
.dockerignore
Normal file
9
.dockerignore
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
.git
|
||||||
|
.next
|
||||||
|
node_modules
|
||||||
|
npm-debug.log
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
prisma/dev.db
|
||||||
|
prisma/dev.db-journal
|
||||||
|
coverage
|
||||||
24
Dockerfile
Normal file
24
Dockerfile
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
FROM node:22-bookworm-slim AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN apt-get update -y && apt-get install -y openssl && rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
RUN apt-get update -y && apt-get install -y openssl && rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY --from=builder /app /app
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
CMD ["sh", "-c", "npx prisma migrate deploy && npm run start -- --hostname 0.0.0.0"]
|
||||||
69
README.md
Normal file
69
README.md
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
# 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
|
||||||
|
|
||||||
|
Run the app in Docker while keeping `Ollama` on the host:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up --build
|
||||||
|
```
|
||||||
|
|
||||||
|
This compose stack will:
|
||||||
|
- start only the Next.js app on `http://localhost:3000`
|
||||||
|
- keep SQLite data in a named Docker volume
|
||||||
|
- connect to host `Ollama` through `host.docker.internal`
|
||||||
|
|
||||||
|
Before running Docker Compose, make sure host `Ollama` is already up:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ollama serve
|
||||||
|
ollama pull qwen3.5:9b
|
||||||
|
```
|
||||||
|
|
||||||
|
If you run the app outside Docker, keep using:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
OLLAMA_URL=http://127.0.0.1:11434/
|
||||||
|
```
|
||||||
|
|
||||||
|
## In-app helpers
|
||||||
|
|
||||||
|
- Use the dashboard runtime panel to refresh Ollama status.
|
||||||
|
- If the configured model is missing, use `Pull configured model` from the UI.
|
||||||
|
- Use `Download backup` to export the current SQLite database file.
|
||||||
|
|
||||||
|
## Environment
|
||||||
|
|
||||||
|
- `DATABASE_URL` - Prisma SQLite connection string
|
||||||
|
- `OLLAMA_URL` - Ollama base URL; in Docker Compose this defaults to `http://host.docker.internal:11434/`
|
||||||
|
- `OLLAMA_MODEL` - selected model tag, default `qwen3.5:9b`
|
||||||
18
docker-compose.yml
Normal file
18
docker-compose.yml
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
services:
|
||||||
|
app:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
container_name: monthytracker-app
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: file:/data/dev.db
|
||||||
|
OLLAMA_URL: ${OLLAMA_URL:-http://host.docker.internal:11434/}
|
||||||
|
OLLAMA_MODEL: ${OLLAMA_MODEL:-qwen3.5:9b}
|
||||||
|
extra_hosts:
|
||||||
|
- "host.docker.internal:host-gateway"
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
volumes:
|
||||||
|
- app_data:/data
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
app_data:
|
||||||
2
next-env.d.ts
vendored
2
next-env.d.ts
vendored
@@ -1,6 +1,6 @@
|
|||||||
/// <reference types="next" />
|
/// <reference types="next" />
|
||||||
/// <reference types="next/image-types/global" />
|
/// <reference types="next/image-types/global" />
|
||||||
import "./.next/types/routes.d.ts";
|
import "./.next/dev/types/routes.d.ts";
|
||||||
|
|
||||||
// NOTE: This file should not be edited
|
// NOTE: This file should not be edited
|
||||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
## Context
|
## Context
|
||||||
|
|
||||||
The repository starts with a product plan and OpenSpec configuration but no application code. The first version needs a complete local-first implementation using `Next.js`, `Prisma`, `SQLite`, and `OpenAI`, while keeping scope intentionally narrow: one user, manual data entry, fixed categories, and dashboard-only insights. Month boundaries are based on the local machine timezone, which affects date parsing, monthly aggregation, and paycheck coverage calculations.
|
The repository starts with a product plan and OpenSpec configuration but no application code. The first version needs a complete local-first implementation using `Next.js`, `Prisma`, `SQLite`, and a fully offline local LLM runtime, while keeping scope intentionally narrow: one user, manual data entry, fixed categories, merchant-assisted categorization, and dashboard-only insights. Month boundaries are based on the local machine timezone, which affects date parsing, monthly aggregation, and paycheck coverage calculations.
|
||||||
|
|
||||||
## Goals / Non-Goals
|
## Goals / Non-Goals
|
||||||
|
|
||||||
@@ -8,12 +8,13 @@ The repository starts with a product plan and OpenSpec configuration but no appl
|
|||||||
- Build a single deployable `Next.js` app with UI views and server routes in one codebase.
|
- Build a single deployable `Next.js` app with UI views and server routes in one codebase.
|
||||||
- Persist expenses, paychecks, and generated monthly insights in a local SQLite database managed by Prisma.
|
- Persist expenses, paychecks, and generated monthly insights in a local SQLite database managed by Prisma.
|
||||||
- Centralize monthly aggregation logic so dashboard reads and AI generation use the same numbers.
|
- Centralize monthly aggregation logic so dashboard reads and AI generation use the same numbers.
|
||||||
- Keep AI integration isolated behind a small service layer that prepares structured monthly context and calls `OpenAI`.
|
- Keep AI integration isolated behind a small service layer that prepares structured monthly context and calls a fully offline local inference runtime.
|
||||||
- Make v1 testable with deterministic validation, aggregation, and safe fallback behavior for sparse data.
|
- Make v1 testable with deterministic validation, aggregation, and safe fallback behavior for sparse data.
|
||||||
|
- Add privacy-preserving merchant category suggestion with deterministic merchant mappings before model inference.
|
||||||
|
|
||||||
**Non-Goals:**
|
**Non-Goals:**
|
||||||
- Authentication, multi-user support, bank sync, receipt scanning, background jobs, or email delivery.
|
- Authentication, multi-user support, bank sync, receipt scanning, background jobs, or email delivery.
|
||||||
- Automatic categorization, editing data through AI, or free-form custom categories in v1.
|
- Fully automatic uncapped categorization without user review for ambiguous merchants, editing data through AI, or free-form custom categories in v1.
|
||||||
- Complex financial forecasting beyond simple next-month guidance derived from recent activity.
|
- Complex financial forecasting beyond simple next-month guidance derived from recent activity.
|
||||||
|
|
||||||
## Decisions
|
## Decisions
|
||||||
@@ -34,16 +35,25 @@ The repository starts with a product plan and OpenSpec configuration but no appl
|
|||||||
- Rationale: dashboard totals, paycheck coverage, category breakdowns, and AI snapshots must stay consistent across endpoints.
|
- Rationale: dashboard totals, paycheck coverage, category breakdowns, and AI snapshots must stay consistent across endpoints.
|
||||||
- Alternative considered: separate logic per route. Rejected because it risks drift between dashboard and insight generation.
|
- Alternative considered: separate logic per route. Rejected because it risks drift between dashboard and insight generation.
|
||||||
|
|
||||||
|
### Use `Ollama` with a local Qwen-class instruct model
|
||||||
|
- Rationale: privacy is a primary product requirement, and the target machine can comfortably run a recent local model for lightweight categorization and summary generation.
|
||||||
|
- Alternative considered: hosted `OpenAI`. Rejected because it violates the privacy-first goal for personal financial data.
|
||||||
|
|
||||||
### Add an AI service boundary with structured prompt input and fallback responses
|
### Add an AI service boundary with structured prompt input and fallback responses
|
||||||
- Rationale: the app needs provider isolation, predictable prompt shape, and safe messaging when data is too sparse for useful advice.
|
- Rationale: the app needs runtime isolation, predictable prompt shape, and safe messaging when local inference is unavailable or data is too sparse for useful advice.
|
||||||
- Alternative considered: calling `OpenAI` directly from a route handler with raw records. Rejected because it couples prompting, aggregation, and transport too tightly.
|
- Alternative considered: calling the local model directly from a route handler with raw records. Rejected because it couples prompting, aggregation, and transport too tightly.
|
||||||
|
|
||||||
|
### Use merchant rules first and local-model fallback second for category suggestion
|
||||||
|
- Rationale: most repeated merchants can be categorized deterministically and faster than model inference, while unknown merchants still benefit from local AI assistance.
|
||||||
|
- Alternative considered: model-only categorization. Rejected because it is slower, less predictable, and unnecessary for common merchants.
|
||||||
|
|
||||||
## Risks / Trade-offs
|
## Risks / Trade-offs
|
||||||
|
|
||||||
- [Local timezone handling differs by machine] -> Normalize month calculations around stored local-date strings and test month edges explicitly.
|
- [Local timezone handling differs by machine] -> Normalize month calculations around stored local-date strings and test month edges explicitly.
|
||||||
- [SQLite limits concurrency] -> Acceptable for single-user local-first v1; no mitigation beyond keeping writes simple.
|
- [SQLite limits concurrency] -> Acceptable for single-user local-first v1; no mitigation beyond keeping writes simple.
|
||||||
- [AI output quality varies with sparse or noisy data] -> Add minimum-data fallback logic and keep prompts grounded in structured aggregates.
|
- [AI output quality varies with sparse or noisy data] -> Add minimum-data fallback logic and keep prompts grounded in structured aggregates.
|
||||||
- [OpenAI dependency requires API key management] -> Read configuration from environment variables and keep failure messages explicit in the UI/API.
|
- [Local model may be unavailable or not yet pulled] -> Detect runtime/model readiness and return explicit offline setup guidance in the UI/API.
|
||||||
|
- [Merchant names can be ambiguous] -> Use auto-fill only for known deterministic mappings and require user confirmation for fallback suggestions.
|
||||||
|
|
||||||
## Migration Plan
|
## Migration Plan
|
||||||
|
|
||||||
@@ -51,13 +61,13 @@ The repository starts with a product plan and OpenSpec configuration but no appl
|
|||||||
2. Add the Prisma schema, create the initial SQLite migration, and generate the client.
|
2. Add the Prisma schema, create the initial SQLite migration, and generate the client.
|
||||||
3. Implement CRUD routes and UI forms for expenses and paychecks.
|
3. Implement CRUD routes and UI forms for expenses and paychecks.
|
||||||
4. Implement dashboard aggregation and month filtering.
|
4. Implement dashboard aggregation and month filtering.
|
||||||
5. Add the AI insight service and persistence for generated monthly insights.
|
5. Add the offline AI service, merchant-category suggestion flow, and persistence for generated monthly insights.
|
||||||
6. Run automated tests, then exercise the main flows in the browser.
|
6. Run automated tests, then exercise the main flows in the browser.
|
||||||
|
|
||||||
Rollback is straightforward in early development: revert the code change and reset the local SQLite database if schema changes become invalid.
|
Rollback is straightforward in early development: revert the code change and reset the local SQLite database if schema changes become invalid.
|
||||||
|
|
||||||
## Open Questions
|
## Open Questions
|
||||||
|
|
||||||
- Which `OpenAI` model should be the initial default for monthly insight generation?
|
- Which exact local Qwen model tag should be the initial default in `Ollama`?
|
||||||
- Should generated monthly insights overwrite prior insights for the same month or create a historical trail of regenerated summaries?
|
- Should generated monthly insights overwrite prior insights for the same month or create a historical trail of regenerated summaries?
|
||||||
- Do we want soft confirmation in the UI before deleting expenses or paychecks, or is immediate deletion acceptable for v1?
|
- Do we want soft confirmation in the UI before deleting expenses or paychecks, or is immediate deletion acceptable for v1?
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
## Why
|
## Why
|
||||||
|
|
||||||
The project currently has a product plan but no runnable application, spec artifacts, or implementation scaffold. Formalizing the first version now creates a clear contract for building a local-first expense tracker with reliable monthly summaries and AI-generated guidance.
|
The project currently has a product plan but no runnable application, spec artifacts, or implementation scaffold. Formalizing the first version now creates a clear contract for building a local-first expense tracker with reliable monthly summaries, private offline AI assistance, and no dependency on hosted model providers.
|
||||||
|
|
||||||
## What Changes
|
## What Changes
|
||||||
|
|
||||||
- Add a local-first web app for tracking expenses and biweekly paychecks without authentication.
|
- Add a local-first web app for tracking expenses and biweekly paychecks without authentication.
|
||||||
- Add dashboard capabilities for month-to-date totals, category breakdowns, cash flow, and spending comparisons.
|
- Add dashboard capabilities for month-to-date totals, category breakdowns, cash flow, and spending comparisons.
|
||||||
- Add manual AI insight generation for a selected month using structured aggregates and transaction samples.
|
- Add fully offline AI insight generation for a selected month using structured aggregates and transaction samples.
|
||||||
|
- Add merchant-name-based category suggestion using deterministic rules plus local-model fallback.
|
||||||
- Add local persistence, validation, and API routes for expenses, paychecks, dashboard data, and insight generation.
|
- Add local persistence, validation, and API routes for expenses, paychecks, dashboard data, and insight generation.
|
||||||
|
|
||||||
## Capabilities
|
## Capabilities
|
||||||
@@ -15,7 +16,8 @@ The project currently has a product plan but no runnable application, spec artif
|
|||||||
- `expense-tracking`: Record, list, and delete categorized expenses for a given date.
|
- `expense-tracking`: Record, list, and delete categorized expenses for a given date.
|
||||||
- `paycheck-tracking`: Record, list, and delete paycheck entries based on actual pay dates.
|
- `paycheck-tracking`: Record, list, and delete paycheck entries based on actual pay dates.
|
||||||
- `monthly-dashboard`: View month-specific spending, income, and derived financial summaries.
|
- `monthly-dashboard`: View month-specific spending, income, and derived financial summaries.
|
||||||
- `monthly-insights`: Generate read-only AI insights from monthly financial activity.
|
- `monthly-insights`: Generate private offline AI insights from monthly financial activity.
|
||||||
|
- `category-suggestion`: Suggest expense categories from merchant/shop names without cloud calls.
|
||||||
|
|
||||||
### Modified Capabilities
|
### Modified Capabilities
|
||||||
- None.
|
- None.
|
||||||
@@ -24,5 +26,5 @@ The project currently has a product plan but no runnable application, spec artif
|
|||||||
|
|
||||||
- Affected code: new `Next.js` application, server routes, UI views, Prisma schema, and AI integration service.
|
- Affected code: new `Next.js` application, server routes, UI views, Prisma schema, and AI integration service.
|
||||||
- APIs: `POST/GET/DELETE` routes for expenses and paychecks, `GET /dashboard`, and `POST /insights/generate`.
|
- APIs: `POST/GET/DELETE` routes for expenses and paychecks, `GET /dashboard`, and `POST /insights/generate`.
|
||||||
- Dependencies: `Next.js`, `Prisma`, `SQLite`, and `OpenAI` SDK.
|
- Dependencies: `Next.js`, `Prisma`, `SQLite`, `Ollama`, and a local Qwen-class instruct model.
|
||||||
- Systems: local machine timezone handling for month boundaries and persisted local database storage.
|
- Systems: local machine timezone handling for month boundaries and persisted local database storage.
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: System suggests categories from merchant names
|
||||||
|
The system SHALL support merchant-name-based category suggestion for expense entry while keeping all suggestion logic fully offline.
|
||||||
|
|
||||||
|
#### Scenario: Known merchant resolves from deterministic rules
|
||||||
|
- **WHEN** the user enters a merchant or shop name that matches a known merchant rule
|
||||||
|
- **THEN** the system assigns the mapped category without needing model inference
|
||||||
|
|
||||||
|
#### Scenario: Unknown merchant falls back to local model
|
||||||
|
- **WHEN** the user enters a merchant or shop name that does not match a known merchant rule
|
||||||
|
- **THEN** the system asks the local AI service for a category suggestion and returns the suggested category
|
||||||
|
|
||||||
|
### Requirement: Ambiguous suggestions remain user-controlled
|
||||||
|
The system SHALL keep the final saved category under user control for ambiguous or model-generated suggestions.
|
||||||
|
|
||||||
|
#### Scenario: User confirms model suggestion before save
|
||||||
|
- **WHEN** the category suggestion comes from model inference instead of a deterministic rule
|
||||||
|
- **THEN** the user can review and confirm or change the category before the expense is saved
|
||||||
|
|
||||||
|
#### Scenario: No cloud fallback is used
|
||||||
|
- **WHEN** the local suggestion service is unavailable
|
||||||
|
- **THEN** the system continues to allow manual category selection and does not send merchant data to a hosted provider
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
## ADDED Requirements
|
## ADDED Requirements
|
||||||
|
|
||||||
### Requirement: User can generate monthly AI insights on demand
|
### Requirement: User can generate monthly AI insights on demand
|
||||||
The system SHALL allow the user to manually generate AI insights for any month with existing or sparse data by sending structured monthly context to the configured `OpenAI` provider.
|
The system SHALL allow the user to manually generate monthly AI insights for any month with existing or sparse data by sending structured monthly context to a fully offline local inference runtime.
|
||||||
|
|
||||||
#### Scenario: Insights are generated for a month with data
|
#### Scenario: Insights are generated for a month with data
|
||||||
- **WHEN** the user requests insight generation for a month with recorded activity
|
- **WHEN** the user requests insight generation for a month with recorded activity
|
||||||
- **THEN** the system sends monthly aggregates plus transaction samples to the AI service and returns a rendered narrative summary with structured supporting totals
|
- **THEN** the system sends monthly aggregates plus transaction samples to the local AI service and returns a rendered narrative summary with structured supporting totals
|
||||||
|
|
||||||
#### Scenario: Prior month insights can be generated
|
#### Scenario: Prior month insights can be generated
|
||||||
- **WHEN** the user requests insight generation for a previous month that has recorded data
|
- **WHEN** the user requests insight generation for a previous month that has recorded data
|
||||||
@@ -21,3 +21,10 @@ The system SHALL keep AI insight generation read-only and return a safe fallback
|
|||||||
#### Scenario: AI does not mutate financial records
|
#### Scenario: AI does not mutate financial records
|
||||||
- **WHEN** the system generates or stores monthly insights
|
- **WHEN** the system generates or stores monthly insights
|
||||||
- **THEN** no expense or paycheck records are created, updated, or deleted as part of that request
|
- **THEN** no expense or paycheck records are created, updated, or deleted as part of that request
|
||||||
|
|
||||||
|
### Requirement: Insight generation remains private and resilient offline
|
||||||
|
The system SHALL keep monthly insight generation fully offline and provide a clear fallback response when the local model runtime or selected model is unavailable.
|
||||||
|
|
||||||
|
#### Scenario: Local runtime is unavailable
|
||||||
|
- **WHEN** the user requests monthly insights while the local AI runtime is not running or the configured model is unavailable
|
||||||
|
- **THEN** the system returns a clear setup or availability message instead of attempting a cloud fallback
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
## 1. Project setup
|
## 1. Project setup
|
||||||
|
|
||||||
- [x] 1.1 Scaffold the `Next.js` app with TypeScript, linting, and baseline project configuration.
|
- [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.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.
|
- [x] 1.4 Add base environment and ignore-file setup for local database and API key configuration.
|
||||||
|
|
||||||
@@ -22,10 +22,17 @@
|
|||||||
|
|
||||||
- [x] 4.1 Implement monthly dashboard aggregation services for totals, category breakdowns, and derived comparisons.
|
- [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.
|
- [x] 4.2 Implement the dashboard API route and render dashboard sections for month-to-date metrics and comparisons.
|
||||||
- [ ] 4.3 Implement the `OpenAI` insight service with structured monthly snapshot input and sparse-month fallback logic.
|
- [x] 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.
|
- [x] 4.4 Implement insight generation and display in the dashboard, including persisted monthly insight records and offline-runtime fallback messaging.
|
||||||
|
|
||||||
## 5. Verification
|
## 5. Offline categorization
|
||||||
|
|
||||||
- [ ] 5.1 Add automated tests for validation, persistence, dashboard aggregates, and insight fallback behavior.
|
- [x] 5.1 Implement deterministic merchant-to-category mapping for known merchants.
|
||||||
- [ ] 5.2 Verify the primary user flows in the browser, including expense entry, paycheck entry, dashboard updates, and insight generation.
|
- [x] 5.2 Implement a local-model category suggestion endpoint for unknown merchants.
|
||||||
|
- [x] 5.3 Update the expense entry flow to auto-fill known merchants and require confirmation for model-generated suggestions.
|
||||||
|
- [x] 5.4 Add local runtime availability handling so category suggestion falls back to manual selection without cloud calls.
|
||||||
|
|
||||||
|
## 6. Verification
|
||||||
|
|
||||||
|
- [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.
|
||||||
|
|||||||
23
package-lock.json
generated
23
package-lock.json
generated
@@ -10,7 +10,6 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/client": "^6.6.0",
|
"@prisma/client": "^6.6.0",
|
||||||
"next": "16.2.1",
|
"next": "16.2.1",
|
||||||
"openai": "^5.10.2",
|
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-dom": "19.2.4",
|
"react-dom": "19.2.4",
|
||||||
"recharts": "^2.15.4",
|
"recharts": "^2.15.4",
|
||||||
@@ -6854,27 +6853,6 @@
|
|||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/optionator": {
|
||||||
"version": "0.9.4",
|
"version": "0.9.4",
|
||||||
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
||||||
@@ -8435,6 +8413,7 @@
|
|||||||
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
|
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.27.0",
|
"esbuild": "^0.27.0",
|
||||||
"fdir": "^6.5.0",
|
"fdir": "^6.5.0",
|
||||||
|
|||||||
@@ -15,7 +15,6 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/client": "^6.6.0",
|
"@prisma/client": "^6.6.0",
|
||||||
"next": "16.2.1",
|
"next": "16.2.1",
|
||||||
"openai": "^5.10.2",
|
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-dom": "19.2.4",
|
"react-dom": "19.2.4",
|
||||||
"recharts": "^2.15.4",
|
"recharts": "^2.15.4",
|
||||||
|
|||||||
26
plan.md
26
plan.md
@@ -1,9 +1,9 @@
|
|||||||
# Monthly Expense Tracker With AI Insights
|
# Monthly Expense Tracker With AI Insights
|
||||||
|
|
||||||
## Summary
|
## Summary
|
||||||
Build a single-user, local-first web app for manually recording daily expenses and biweekly paychecks, then generating month-to-date and end-of-month spending insights with next-month guidance.
|
Build a single-user, local-first web app for manually recording daily expenses and biweekly paychecks, then generating month-to-date and end-of-month spending insights with next-month guidance while keeping all AI features fully offline.
|
||||||
|
|
||||||
The first version is optimized for fast daily entry and a dashboard-first review flow. It uses fixed starter categories, a simple local database, and an in-app AI summary rather than email or exports.
|
The first version is optimized for fast daily entry and a dashboard-first review flow. It uses fixed starter categories, a simple local database, a fully offline local LLM for private AI features, and an in-app AI summary rather than email or exports.
|
||||||
|
|
||||||
## Implementation Changes
|
## Implementation Changes
|
||||||
- App shape:
|
- App shape:
|
||||||
@@ -16,29 +16,33 @@ The first version is optimized for fast daily entry and a dashboard-first review
|
|||||||
- Categories:
|
- Categories:
|
||||||
- Ship with fixed starter categories such as `Rent`, `Food`, `Transport`, `Bills`, `Shopping`, `Health`, `Entertainment`, `Misc`.
|
- Ship with fixed starter categories such as `Rent`, `Food`, `Transport`, `Bills`, `Shopping`, `Health`, `Entertainment`, `Misc`.
|
||||||
- Store category as a controlled value so monthly summaries can group reliably.
|
- Store category as a controlled value so monthly summaries can group reliably.
|
||||||
|
- Support merchant-name-based category suggestion: apply deterministic merchant rules first, then use the local LLM only for unknown merchants.
|
||||||
|
- Treat AI categorization as assistive: known merchants may auto-fill a category, but unknown-merchant suggestions should be confirmed before save.
|
||||||
- Dashboard behavior:
|
- Dashboard behavior:
|
||||||
- Show current month totals for expenses, category breakdown, paycheck total, and net cash flow.
|
- Show current month totals for expenses, category breakdown, paycheck total, and net cash flow.
|
||||||
- Include month-to-date charts and simple comparisons like highest category, largest single expense, average daily spend, and spend vs paycheck coverage.
|
- Include month-to-date charts and simple comparisons like highest category, largest single expense, average daily spend, and spend vs paycheck coverage.
|
||||||
- Provide a `Generate Insights` action that works any time during the month, not only at month-end.
|
- Provide a `Generate Insights` action that works any time during the month, not only at month-end.
|
||||||
- AI insight generation:
|
- AI insight generation:
|
||||||
- Build a summarization pipeline that prepares structured monthly aggregates plus recent transaction samples, then sends that context to the AI model.
|
- Build a summarization pipeline that prepares structured monthly aggregates plus recent transaction samples, then sends that context to a fully offline local model.
|
||||||
- Ask the model to return:
|
- Ask the model to return:
|
||||||
- spending pattern summary
|
- spending pattern summary
|
||||||
- unusual categories or spikes
|
- unusual categories or spikes
|
||||||
- paycheck-to-spend timing observations
|
- paycheck-to-spend timing observations
|
||||||
- practical next-month suggestions
|
- practical next-month suggestions
|
||||||
- Keep AI read-only in v1: it does not edit data or auto-categorize entries.
|
- Use AI for merchant-category suggestion as well as monthly summaries, but keep the final saved category under user control for ambiguous merchants.
|
||||||
- Storage and architecture:
|
- Storage and architecture:
|
||||||
- Use a simple embedded database for local-first persistence, preferably SQLite.
|
- Use a simple embedded database for local-first persistence, preferably SQLite.
|
||||||
- Implement the app with `Next.js` for the web UI and server routes.
|
- Implement the app with `Next.js` for the web UI and server routes.
|
||||||
- Use `Prisma` for the data layer and migrations.
|
- Use `Prisma` for the data layer and migrations.
|
||||||
- Keep the AI integration behind a small service boundary so the model/provider can be swapped later without changing UI code.
|
- Keep the AI integration behind a small service boundary so the model/provider can be swapped later without changing UI code.
|
||||||
- Use `OpenAI` for insight generation in v1.
|
- Use `Ollama` with a local Qwen-class instruct model for offline inference in v1.
|
||||||
|
- Keep the app functional when the local model is unavailable by returning a clear fallback message instead of failing silently.
|
||||||
- Public interfaces / APIs:
|
- Public interfaces / APIs:
|
||||||
- `POST /expenses`, `GET /expenses`, `DELETE /expenses/:id`
|
- `POST /expenses`, `GET /expenses`, `DELETE /expenses/:id`
|
||||||
- `POST /paychecks`, `GET /paychecks`, `DELETE /paychecks/:id`
|
- `POST /paychecks`, `GET /paychecks`, `DELETE /paychecks/:id`
|
||||||
- `GET /dashboard?month=YYYY-MM`
|
- `GET /dashboard?month=YYYY-MM`
|
||||||
- `POST /insights/generate?month=YYYY-MM`
|
- `POST /insights/generate?month=YYYY-MM`
|
||||||
|
- `POST /categories/suggest` with merchant/shop name input for local category suggestion
|
||||||
- Insight response should include structured fields for totals and a rendered narrative summary for the dashboard.
|
- Insight response should include structured fields for totals and a rendered narrative summary for the dashboard.
|
||||||
|
|
||||||
## Implementation Checklist
|
## Implementation Checklist
|
||||||
@@ -48,9 +52,10 @@ The first version is optimized for fast daily entry and a dashboard-first review
|
|||||||
- [ ] Implement expense CRUD routes and forms.
|
- [ ] Implement expense CRUD routes and forms.
|
||||||
- [ ] Implement paycheck CRUD routes and forms.
|
- [ ] Implement paycheck CRUD routes and forms.
|
||||||
- [ ] Build dashboard aggregation logic for totals, categories, cash flow, and comparisons.
|
- [ ] Build dashboard aggregation logic for totals, categories, cash flow, and comparisons.
|
||||||
- [ ] Add the insight generation service boundary and `OpenAI` integration.
|
- [ ] Add the insight generation service boundary and offline `Ollama` integration.
|
||||||
|
- [ ] Add merchant-name category suggestion using merchant rules first and local-model fallback second.
|
||||||
- [ ] Render AI insight output in the dashboard with fallback behavior for sparse months.
|
- [ ] Render AI insight output in the dashboard with fallback behavior for sparse months.
|
||||||
- [ ] Add tests for validation, aggregates, persistence, and insight generation.
|
- [ ] Add tests for validation, aggregates, persistence, local-model fallback behavior, and category suggestion.
|
||||||
- [ ] Verify all month-boundary behavior using local timezone dates.
|
- [ ] Verify all month-boundary behavior using local timezone dates.
|
||||||
|
|
||||||
## Test Plan
|
## Test Plan
|
||||||
@@ -67,6 +72,11 @@ The first version is optimized for fast daily entry and a dashboard-first review
|
|||||||
- AI request uses aggregated monthly inputs plus transaction samples.
|
- AI request uses aggregated monthly inputs plus transaction samples.
|
||||||
- Manual generation works for current month and prior months with existing data.
|
- Manual generation works for current month and prior months with existing data.
|
||||||
- Empty or near-empty months return a safe fallback message instead of low-quality advice.
|
- Empty or near-empty months return a safe fallback message instead of low-quality advice.
|
||||||
|
- App returns a clear fallback message when `Ollama` or the local model is unavailable.
|
||||||
|
- Category suggestion:
|
||||||
|
- Known merchants resolve deterministically to the expected category.
|
||||||
|
- Unknown merchants fall back to the local model and return a suggested category.
|
||||||
|
- Ambiguous suggestions require user confirmation before save.
|
||||||
- Persistence:
|
- Persistence:
|
||||||
- Data remains available after app restart.
|
- Data remains available after app restart.
|
||||||
- Deleting an expense or paycheck updates dashboard and future insight results correctly.
|
- Deleting an expense or paycheck updates dashboard and future insight results correctly.
|
||||||
@@ -79,3 +89,5 @@ The first version is optimized for fast daily entry and a dashboard-first review
|
|||||||
- Fixed starter categories are sufficient for v1; custom categories can be added later.
|
- Fixed starter categories are sufficient for v1; custom categories can be added later.
|
||||||
- Income is modeled as discrete biweekly paychecks because that materially affects next-month guidance and intra-month cash-flow interpretation.
|
- Income is modeled as discrete biweekly paychecks because that materially affects next-month guidance and intra-month cash-flow interpretation.
|
||||||
- Month and paycheck boundaries use the local machine timezone.
|
- Month and paycheck boundaries use the local machine timezone.
|
||||||
|
- Privacy matters more than hosted-model quality for this app, so AI features should stay fully offline.
|
||||||
|
- A recent local Qwen instruct model running through `Ollama` is the default model family for v1.
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "MonthlyInsight_month_key" ON "MonthlyInsight"("month");
|
||||||
|
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "PaySchedule" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"amountCents" INTEGER NOT NULL,
|
||||||
|
"anchorDate" TEXT NOT NULL,
|
||||||
|
"active" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "MerchantCorrection" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"merchantName" TEXT NOT NULL,
|
||||||
|
"category" TEXT NOT NULL,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" DATETIME NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "MerchantCorrection_merchantName_key" ON "MerchantCorrection"("merchantName");
|
||||||
@@ -34,9 +34,25 @@ model Paycheck {
|
|||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model PaySchedule {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
amountCents Int
|
||||||
|
anchorDate String
|
||||||
|
active Boolean @default(true)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
}
|
||||||
|
|
||||||
|
model MerchantCorrection {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
merchantName String @unique
|
||||||
|
category Category
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
model MonthlyInsight {
|
model MonthlyInsight {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
month String
|
month String @unique
|
||||||
year Int
|
year Int
|
||||||
generatedAt DateTime @default(now())
|
generatedAt DateTime @default(now())
|
||||||
summary String
|
summary String
|
||||||
|
|||||||
87
prisma/seed.ts
Normal file
87
prisma/seed.ts
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import { PrismaClient } from '@prisma/client'
|
||||||
|
|
||||||
|
const prisma = new PrismaClient()
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
// Clear all data
|
||||||
|
await prisma.monthlyInsight.deleteMany()
|
||||||
|
await prisma.expense.deleteMany()
|
||||||
|
await prisma.paycheck.deleteMany()
|
||||||
|
await prisma.paySchedule.deleteMany()
|
||||||
|
|
||||||
|
// ── Pay schedule ────────────────────────────────────────────────
|
||||||
|
// Biweekly $2,850. Anchor on 2026-03-14 (last received paycheck).
|
||||||
|
// This projects 2026-03-28 as the upcoming payday this weekend.
|
||||||
|
await prisma.paySchedule.create({
|
||||||
|
data: {
|
||||||
|
amountCents: 285000,
|
||||||
|
anchorDate: '2026-03-14',
|
||||||
|
active: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── February 2026 paychecks ──────────────────────────────────────
|
||||||
|
// Biweekly back from 2026-03-14: Feb 14, Feb 28 (same cycle −2 periods, −1 period)
|
||||||
|
await prisma.paycheck.createMany({
|
||||||
|
data: [
|
||||||
|
{ payDate: '2026-02-14', amountCents: 285000 },
|
||||||
|
{ payDate: '2026-02-28', amountCents: 285000 },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── March 2026 paychecks ─────────────────────────────────────────
|
||||||
|
// March 14 received; March 28 is upcoming → covered by projected schedule
|
||||||
|
await prisma.paycheck.create({
|
||||||
|
data: { payDate: '2026-03-14', amountCents: 285000 },
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── February 2026 expenses ───────────────────────────────────────
|
||||||
|
await prisma.expense.createMany({
|
||||||
|
data: [
|
||||||
|
{ date: '2026-02-02', title: 'Rent', amountCents: 243000, category: 'RENT' },
|
||||||
|
{ date: '2026-02-04', title: 'Grocery Run', amountCents: 11500, category: 'FOOD' },
|
||||||
|
{ date: '2026-02-07', title: 'Coffee & Snacks', amountCents: 3200, category: 'FOOD' },
|
||||||
|
{ date: '2026-02-09', title: 'Electric Bill', amountCents: 9200, category: 'BILLS' },
|
||||||
|
{ date: '2026-02-09', title: 'Internet', amountCents: 6500, category: 'BILLS' },
|
||||||
|
{ date: '2026-02-11', title: 'Gas Station', amountCents: 5500, category: 'TRANSPORT' },
|
||||||
|
{ date: '2026-02-13', title: 'Pharmacy', amountCents: 4200, category: 'HEALTH' },
|
||||||
|
{ date: '2026-02-15', title: 'Movie Tickets', amountCents: 2800, category: 'ENTERTAINMENT' },
|
||||||
|
{ date: '2026-02-17', title: 'Grocery Run', amountCents: 9800, category: 'FOOD' },
|
||||||
|
{ date: '2026-02-19', title: 'Gym Membership', amountCents: 5000, category: 'HEALTH' },
|
||||||
|
{ date: '2026-02-21', title: 'Dinner Out', amountCents: 6800, category: 'FOOD' },
|
||||||
|
{ date: '2026-02-22', title: 'New Jeans', amountCents: 12000, category: 'SHOPPING' },
|
||||||
|
{ date: '2026-02-24', title: 'Rideshare', amountCents: 2200, category: 'TRANSPORT' },
|
||||||
|
{ date: '2026-02-26', title: 'Phone Bill', amountCents: 6500, category: 'BILLS' },
|
||||||
|
{ date: '2026-02-28', title: 'Misc Supplies', amountCents: 1800, category: 'MISC' },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── March 2026 expenses (through March 23 — month is ongoing) ───
|
||||||
|
await prisma.expense.createMany({
|
||||||
|
data: [
|
||||||
|
{ date: '2026-03-02', title: 'Rent', amountCents: 243000, category: 'RENT' },
|
||||||
|
{ date: '2026-03-04', title: 'Grocery Run', amountCents: 10800, category: 'FOOD' },
|
||||||
|
{ date: '2026-03-06', title: 'Coffee & Snacks', amountCents: 2900, category: 'FOOD' },
|
||||||
|
{ date: '2026-03-08', title: 'Gas Station', amountCents: 6000, category: 'TRANSPORT' },
|
||||||
|
{ date: '2026-03-09', title: 'Electric Bill', amountCents: 8800, category: 'BILLS' },
|
||||||
|
{ date: '2026-03-11', title: 'Streaming Services', amountCents: 5500, category: 'BILLS' },
|
||||||
|
{ date: '2026-03-12', title: 'Lunch Out', amountCents: 4500, category: 'FOOD' },
|
||||||
|
{ date: '2026-03-14', title: 'Pharmacy', amountCents: 3500, category: 'HEALTH' },
|
||||||
|
{ date: '2026-03-16', title: 'Grocery Run', amountCents: 9500, category: 'FOOD' },
|
||||||
|
{ date: '2026-03-17', title: 'Cinema', amountCents: 3200, category: 'ENTERTAINMENT' },
|
||||||
|
{ date: '2026-03-19', title: 'Clothing Online', amountCents: 7500, category: 'SHOPPING' },
|
||||||
|
{ date: '2026-03-20', title: 'Phone Bill', amountCents: 6500, category: 'BILLS' },
|
||||||
|
{ date: '2026-03-21', title: 'Rideshare', amountCents: 1800, category: 'TRANSPORT' },
|
||||||
|
{ date: '2026-03-23', title: 'Dinner Out', amountCents: 5500, category: 'FOOD' },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log('✓ Cleared old data')
|
||||||
|
console.log('✓ Pay schedule: biweekly $2,850 (anchor 2026-03-14, next: 2026-03-28)')
|
||||||
|
console.log('✓ Paychecks: Feb 14, Feb 28, Mar 14 → Mar 28 projected')
|
||||||
|
console.log('✓ Expenses: Feb 2026 (15 items) + Mar 2026 (14 items, through Mar 23)')
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch(console.error)
|
||||||
|
.finally(() => prisma.$disconnect())
|
||||||
@@ -12,7 +12,7 @@ export default function AddExpensePage() {
|
|||||||
<p className="text-sm font-semibold uppercase tracking-[0.28em] text-amber-700">Add Expense</p>
|
<p className="text-sm font-semibold uppercase tracking-[0.28em] text-amber-700">Add Expense</p>
|
||||||
<h1 className="text-4xl font-semibold text-stone-950">Capture spending while it still feels fresh.</h1>
|
<h1 className="text-4xl font-semibold text-stone-950">Capture spending while it still feels fresh.</h1>
|
||||||
<p className="text-lg leading-8 text-stone-600">
|
<p className="text-lg leading-8 text-stone-600">
|
||||||
This first slice focuses on fast local entry. Each saved expense appears immediately in your running history.
|
Enter the shop name and the app can auto-fill a category locally for known merchants, with offline AI help for unfamiliar ones.
|
||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|||||||
36
src/app/backup/database/route.ts
Normal file
36
src/app/backup/database/route.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
import { getDatabaseBackupFileName } from "@/lib/storage";
|
||||||
|
|
||||||
|
function resolveDbPath(): string {
|
||||||
|
const dbUrl = process.env.DATABASE_URL || "file:./prisma/dev.db";
|
||||||
|
const rawPath = dbUrl.slice("file:".length);
|
||||||
|
if (path.isAbsolute(rawPath)) {
|
||||||
|
return rawPath;
|
||||||
|
}
|
||||||
|
return path.resolve(process.cwd(), rawPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const dbPath = resolveDbPath();
|
||||||
|
console.error("[backup] resolved path:", dbPath);
|
||||||
|
const file = await readFile(dbPath);
|
||||||
|
console.error("[backup] read bytes:", file.length);
|
||||||
|
|
||||||
|
return new NextResponse(file, {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/x-sqlite3",
|
||||||
|
"Content-Disposition": `attachment; filename="${getDatabaseBackupFileName()}"`,
|
||||||
|
"Cache-Control": "no-store",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[backup] error:", error);
|
||||||
|
return new NextResponse(String(error), { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
25
src/app/categories/correct/route.ts
Normal file
25
src/app/categories/correct/route.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { Category } from "@prisma/client";
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { saveMerchantCorrection } from "@/lib/merchant-corrections";
|
||||||
|
|
||||||
|
const correctionSchema = z.object({
|
||||||
|
merchantName: z.string().trim().min(1).max(80),
|
||||||
|
category: z.nativeEnum(Category, { message: "Choose a valid category." }),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const payload = await request.json().catch(() => null);
|
||||||
|
const parsed = correctionSchema.safeParse(payload);
|
||||||
|
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: parsed.error.issues[0]?.message ?? "Invalid correction." },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await saveMerchantCorrection(parsed.data.merchantName, parsed.data.category);
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
23
src/app/categories/suggest/route.ts
Normal file
23
src/app/categories/suggest/route.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { suggestCategoryForMerchant } from "@/lib/category-suggestion";
|
||||||
|
|
||||||
|
const categorySuggestionSchema = z.object({
|
||||||
|
merchantName: z.string().trim().min(1, "Merchant name is required.").max(80, "Keep merchant names under 80 characters."),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const payload = await request.json();
|
||||||
|
const parsed = categorySuggestionSchema.safeParse(payload);
|
||||||
|
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: parsed.error.issues[0]?.message ?? "Invalid merchant name." },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const suggestion = await suggestCategoryForMerchant(parsed.data.merchantName);
|
||||||
|
return NextResponse.json(suggestion);
|
||||||
|
}
|
||||||
@@ -16,6 +16,11 @@ export async function GET(request: Request) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const dashboard = await getDashboardSnapshot(parsed.data.month);
|
try {
|
||||||
return NextResponse.json(dashboard);
|
const dashboard = await getDashboardSnapshot(parsed.data.month);
|
||||||
|
return NextResponse.json(dashboard);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[dashboard] snapshot error:", error);
|
||||||
|
return NextResponse.json({ error: "Could not load the dashboard." }, { status: 500 });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,44 @@
|
|||||||
import { Prisma } from "@prisma/client";
|
import { Prisma } from "@prisma/client";
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
import { removeExpense } from "@/lib/expenses";
|
import { removeExpense, updateExpense } from "@/lib/expenses";
|
||||||
|
import { expenseInputSchema } from "@/lib/validation";
|
||||||
|
|
||||||
type RouteContext = {
|
type RouteContext = {
|
||||||
params: Promise<{ id: string }>;
|
params: Promise<{ id: string }>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export async function PATCH(request: Request, context: RouteContext) {
|
||||||
|
const { id } = await context.params;
|
||||||
|
const payload = await request.json();
|
||||||
|
const parsed = expenseInputSchema.safeParse(payload);
|
||||||
|
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: parsed.error.issues[0]?.message ?? "Invalid expense payload." },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const expense = await updateExpense(id, {
|
||||||
|
title: parsed.data.title,
|
||||||
|
amountCents: parsed.data.amount,
|
||||||
|
date: parsed.data.date,
|
||||||
|
category: parsed.data.category,
|
||||||
|
});
|
||||||
|
return NextResponse.json({ expense });
|
||||||
|
} catch (error) {
|
||||||
|
if (
|
||||||
|
error instanceof Prisma.PrismaClientKnownRequestError &&
|
||||||
|
error.code === "P2025"
|
||||||
|
) {
|
||||||
|
return NextResponse.json({ error: "Expense not found." }, { status: 404 });
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function DELETE(_: Request, context: RouteContext) {
|
export async function DELETE(_: Request, context: RouteContext) {
|
||||||
const { id } = await context.params;
|
const { id } = await context.params;
|
||||||
|
|
||||||
|
|||||||
21
src/app/insights/generate/route.ts
Normal file
21
src/app/insights/generate/route.ts
Normal file
@@ -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);
|
||||||
|
}
|
||||||
16
src/app/ollama/pull/route.ts
Normal file
16
src/app/ollama/pull/route.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
import { OllamaUnavailableError, pullConfiguredOllamaModel } from "@/lib/ollama";
|
||||||
|
|
||||||
|
export async function POST() {
|
||||||
|
try {
|
||||||
|
const result = await pullConfiguredOllamaModel();
|
||||||
|
return NextResponse.json(result);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof OllamaUnavailableError) {
|
||||||
|
return NextResponse.json({ error: error.message }, { status: 503 });
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
8
src/app/ollama/status/route.ts
Normal file
8
src/app/ollama/status/route.ts
Normal file
@@ -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);
|
||||||
|
}
|
||||||
45
src/app/pay-schedule/route.ts
Normal file
45
src/app/pay-schedule/route.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { isValidLocalDate } from "@/lib/date";
|
||||||
|
import { getActiveSchedule, saveSchedule, clearSchedule } from "@/lib/pay-schedule";
|
||||||
|
import { parseAmountToCents } from "@/lib/money";
|
||||||
|
|
||||||
|
const scheduleInputSchema = z.object({
|
||||||
|
amount: z
|
||||||
|
.union([z.string(), z.number()])
|
||||||
|
.transform((value, ctx) => {
|
||||||
|
const cents = parseAmountToCents(value);
|
||||||
|
if (!cents) {
|
||||||
|
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Enter a valid amount." });
|
||||||
|
return z.NEVER;
|
||||||
|
}
|
||||||
|
return cents;
|
||||||
|
}),
|
||||||
|
anchorDate: z.string().refine(isValidLocalDate, "Enter a valid date."),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const schedule = await getActiveSchedule();
|
||||||
|
return NextResponse.json({ schedule });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const payload = await request.json();
|
||||||
|
const parsed = scheduleInputSchema.safeParse(payload);
|
||||||
|
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: parsed.error.issues[0]?.message ?? "Invalid schedule payload." },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const schedule = await saveSchedule(parsed.data.amount, parsed.data.anchorDate);
|
||||||
|
return NextResponse.json({ schedule }, { status: 201 });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE() {
|
||||||
|
await clearSchedule();
|
||||||
|
return new NextResponse(null, { status: 204 });
|
||||||
|
}
|
||||||
@@ -5,6 +5,14 @@ import { useEffect, useMemo, useState, type FormEvent } from "react";
|
|||||||
import { getCategoryLabel, type CategoryValue } from "@/lib/categories";
|
import { getCategoryLabel, type CategoryValue } from "@/lib/categories";
|
||||||
import { formatCurrencyFromCents } from "@/lib/money";
|
import { formatCurrencyFromCents } from "@/lib/money";
|
||||||
|
|
||||||
|
type SuggestionResponse = {
|
||||||
|
category: CategoryValue | null;
|
||||||
|
message: string;
|
||||||
|
merchantName: string;
|
||||||
|
requiresConfirmation: boolean;
|
||||||
|
source: "rule" | "model" | "unavailable" | "learned";
|
||||||
|
};
|
||||||
|
|
||||||
type ExpenseRecord = {
|
type ExpenseRecord = {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -24,14 +32,24 @@ type Props = {
|
|||||||
|
|
||||||
export function ExpenseWorkspace({ categoryOptions }: Props) {
|
export function ExpenseWorkspace({ categoryOptions }: Props) {
|
||||||
const [expenses, setExpenses] = useState<ExpenseRecord[]>([]);
|
const [expenses, setExpenses] = useState<ExpenseRecord[]>([]);
|
||||||
const [formState, setFormState] = useState({
|
const [formState, setFormState] = useState<{
|
||||||
|
title: string;
|
||||||
|
amount: string;
|
||||||
|
date: string;
|
||||||
|
category: CategoryValue;
|
||||||
|
}>({
|
||||||
title: "",
|
title: "",
|
||||||
amount: "",
|
amount: "",
|
||||||
date: new Date().toISOString().slice(0, 10),
|
date: new Date().toISOString().slice(0, 10),
|
||||||
category: categoryOptions[0]?.value ?? "",
|
category: (categoryOptions[0]?.value as CategoryValue | undefined) ?? "MISC",
|
||||||
});
|
});
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [suggestionMessage, setSuggestionMessage] = useState<string | null>(null);
|
||||||
|
const [needsSuggestionConfirmation, setNeedsSuggestionConfirmation] = useState(false);
|
||||||
|
const [lastSuggestedMerchant, setLastSuggestedMerchant] = useState("");
|
||||||
|
const [suggestedCategory, setSuggestedCategory] = useState<CategoryValue | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function loadExpenses() {
|
async function loadExpenses() {
|
||||||
@@ -48,13 +66,96 @@ export function ExpenseWorkspace({ categoryOptions }: Props) {
|
|||||||
[expenses],
|
[expenses],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
async function handleMerchantSuggestion() {
|
||||||
|
const merchantName = formState.title.trim();
|
||||||
|
|
||||||
|
if (!merchantName || merchantName === lastSuggestedMerchant) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch("/categories/suggest", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ merchantName }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const payload = (await response.json().catch(() => null)) as SuggestionResponse | { error?: string } | null;
|
||||||
|
setLastSuggestedMerchant(merchantName);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
setSuggestionMessage(payload && "error" in payload ? payload.error ?? "Could not suggest a category." : "Could not suggest a category.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const suggestion = payload as SuggestionResponse;
|
||||||
|
setSuggestionMessage(suggestion.message);
|
||||||
|
|
||||||
|
if (suggestion.category) {
|
||||||
|
setFormState((current) => ({ ...current, category: suggestion.category! }));
|
||||||
|
setSuggestedCategory(suggestion.category);
|
||||||
|
}
|
||||||
|
|
||||||
|
setNeedsSuggestionConfirmation(suggestion.requiresConfirmation);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleEdit(expense: ExpenseRecord) {
|
||||||
|
setEditingId(expense.id);
|
||||||
|
setFormState({
|
||||||
|
title: expense.title,
|
||||||
|
amount: (expense.amountCents / 100).toFixed(2),
|
||||||
|
date: expense.date,
|
||||||
|
category: expense.category,
|
||||||
|
});
|
||||||
|
setSuggestionMessage(null);
|
||||||
|
setNeedsSuggestionConfirmation(false);
|
||||||
|
setLastSuggestedMerchant("");
|
||||||
|
setSuggestedCategory(null);
|
||||||
|
setError(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCancelEdit() {
|
||||||
|
setEditingId(null);
|
||||||
|
setFormState({
|
||||||
|
title: "",
|
||||||
|
amount: "",
|
||||||
|
date: new Date().toISOString().slice(0, 10),
|
||||||
|
category: (categoryOptions[0]?.value as CategoryValue | undefined) ?? "MISC",
|
||||||
|
});
|
||||||
|
setSuggestionMessage(null);
|
||||||
|
setNeedsSuggestionConfirmation(false);
|
||||||
|
setLastSuggestedMerchant("");
|
||||||
|
setSuggestedCategory(null);
|
||||||
|
setError(null);
|
||||||
|
}
|
||||||
|
|
||||||
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
||||||
|
if (needsSuggestionConfirmation) {
|
||||||
|
setError("Confirm or change the suggested category before saving.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the AI (model or learned) suggested a category and the user changed it,
|
||||||
|
// silently record the correction so future suggestions improve.
|
||||||
|
if (
|
||||||
|
lastSuggestedMerchant &&
|
||||||
|
suggestedCategory !== null &&
|
||||||
|
formState.category !== suggestedCategory
|
||||||
|
) {
|
||||||
|
void fetch("/categories/correct", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ merchantName: lastSuggestedMerchant, category: formState.category }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
const response = await fetch("/expenses", {
|
const isEditing = editingId !== null;
|
||||||
method: "POST",
|
const response = await fetch(isEditing ? `/expenses/${editingId}` : "/expenses", {
|
||||||
|
method: isEditing ? "PATCH" : "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify(formState),
|
body: JSON.stringify(formState),
|
||||||
});
|
});
|
||||||
@@ -63,14 +164,26 @@ export function ExpenseWorkspace({ categoryOptions }: Props) {
|
|||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const payload = (await response.json().catch(() => null)) as { error?: string } | null;
|
const payload = (await response.json().catch(() => null)) as { error?: string } | null;
|
||||||
setError(payload?.error ?? "Could not save the expense.");
|
setError(payload?.error ?? (isEditing ? "Could not update the expense." : "Could not save the expense."));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const payload = (await response.json()) as { expense: ExpenseRecord };
|
const payload = (await response.json()) as { expense: ExpenseRecord };
|
||||||
setExpenses((current) => [payload.expense, ...current]);
|
|
||||||
|
if (isEditing) {
|
||||||
|
setExpenses((current) =>
|
||||||
|
current.map((e) => (e.id === editingId ? payload.expense : e)),
|
||||||
|
);
|
||||||
|
setEditingId(null);
|
||||||
|
} else {
|
||||||
|
setExpenses((current) => [payload.expense, ...current]);
|
||||||
|
}
|
||||||
|
|
||||||
setFormState((current) => ({ ...current, title: "", amount: "" }));
|
setFormState((current) => ({ ...current, title: "", amount: "" }));
|
||||||
|
setSuggestionMessage(null);
|
||||||
|
setNeedsSuggestionConfirmation(false);
|
||||||
|
setLastSuggestedMerchant("");
|
||||||
|
setSuggestedCategory(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleDelete(id: string) {
|
async function handleDelete(id: string) {
|
||||||
@@ -94,8 +207,12 @@ export function ExpenseWorkspace({ categoryOptions }: Props) {
|
|||||||
<section className="rounded-[2rem] border border-stone-200 bg-white p-6 shadow-[0_24px_60px_rgba(120,90,50,0.08)]">
|
<section className="rounded-[2rem] border border-stone-200 bg-white p-6 shadow-[0_24px_60px_rgba(120,90,50,0.08)]">
|
||||||
<div className="mb-6 flex items-center justify-between gap-4">
|
<div className="mb-6 flex items-center justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-semibold uppercase tracking-[0.24em] text-amber-700">Daily entry</p>
|
<p className="text-sm font-semibold uppercase tracking-[0.24em] text-amber-700">
|
||||||
<h2 className="mt-2 text-3xl font-semibold text-stone-950">Log today's spend in seconds</h2>
|
{editingId ? "Edit expense" : "Daily entry"}
|
||||||
|
</p>
|
||||||
|
<h2 className="mt-2 text-3xl font-semibold text-stone-950">
|
||||||
|
{editingId ? "Update this entry" : "Log today\u2019s spend in seconds"}
|
||||||
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-2xl bg-amber-50 px-4 py-3 text-right">
|
<div className="rounded-2xl bg-amber-50 px-4 py-3 text-right">
|
||||||
<p className="text-xs uppercase tracking-[0.2em] text-amber-700">Current list total</p>
|
<p className="text-xs uppercase tracking-[0.2em] text-amber-700">Current list total</p>
|
||||||
@@ -109,7 +226,14 @@ export function ExpenseWorkspace({ categoryOptions }: Props) {
|
|||||||
<input
|
<input
|
||||||
required
|
required
|
||||||
value={formState.title}
|
value={formState.title}
|
||||||
onChange={(event) => setFormState((current) => ({ ...current, title: event.target.value }))}
|
onBlur={() => void handleMerchantSuggestion()}
|
||||||
|
onChange={(event) => {
|
||||||
|
const title = event.target.value;
|
||||||
|
setFormState((current) => ({ ...current, title }));
|
||||||
|
setLastSuggestedMerchant("");
|
||||||
|
setNeedsSuggestionConfirmation(false);
|
||||||
|
setSuggestionMessage(null);
|
||||||
|
}}
|
||||||
className="rounded-2xl border border-stone-300 bg-stone-50 px-4 py-3 outline-none transition focus:border-stone-900"
|
className="rounded-2xl border border-stone-300 bg-stone-50 px-4 py-3 outline-none transition focus:border-stone-900"
|
||||||
placeholder="Groceries, rent, train pass..."
|
placeholder="Groceries, rent, train pass..."
|
||||||
/>
|
/>
|
||||||
@@ -142,7 +266,10 @@ export function ExpenseWorkspace({ categoryOptions }: Props) {
|
|||||||
Category
|
Category
|
||||||
<select
|
<select
|
||||||
value={formState.category}
|
value={formState.category}
|
||||||
onChange={(event) => setFormState((current) => ({ ...current, category: event.target.value }))}
|
onChange={(event) => {
|
||||||
|
setFormState((current) => ({ ...current, category: event.target.value as CategoryValue }));
|
||||||
|
setNeedsSuggestionConfirmation(false);
|
||||||
|
}}
|
||||||
className="rounded-2xl border border-stone-300 bg-stone-50 px-4 py-3 outline-none transition focus:border-stone-900"
|
className="rounded-2xl border border-stone-300 bg-stone-50 px-4 py-3 outline-none transition focus:border-stone-900"
|
||||||
>
|
>
|
||||||
{categoryOptions.map((option) => (
|
{categoryOptions.map((option) => (
|
||||||
@@ -153,15 +280,39 @@ export function ExpenseWorkspace({ categoryOptions }: Props) {
|
|||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
<div className="md:col-span-2 flex items-center justify-between gap-3 rounded-2xl border border-stone-200 bg-stone-50 px-4 py-3 text-sm text-stone-600">
|
||||||
|
<p>{suggestionMessage ?? "Merchant rules auto-fill known shops. Unknown shops use local AI when available."}</p>
|
||||||
|
{needsSuggestionConfirmation ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setNeedsSuggestionConfirmation(false)}
|
||||||
|
className="rounded-full border border-stone-300 px-3 py-2 text-xs font-semibold uppercase tracking-[0.2em] text-stone-700 transition hover:border-stone-900"
|
||||||
|
>
|
||||||
|
Confirm suggestion
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="md:col-span-2 flex items-center justify-between gap-3">
|
<div className="md:col-span-2 flex items-center justify-between gap-3">
|
||||||
<p className="text-sm text-rose-700">{error}</p>
|
<p className="text-sm text-rose-700">{error}</p>
|
||||||
<button
|
<div className="flex gap-2">
|
||||||
type="submit"
|
{editingId ? (
|
||||||
disabled={busy}
|
<button
|
||||||
className="rounded-full bg-stone-950 px-5 py-3 text-sm font-semibold text-white transition hover:bg-stone-800 disabled:cursor-not-allowed disabled:bg-stone-400"
|
type="button"
|
||||||
>
|
onClick={handleCancelEdit}
|
||||||
{busy ? "Saving..." : "Save expense"}
|
className="rounded-full border border-stone-300 px-5 py-3 text-sm font-semibold text-stone-700 transition hover:border-stone-900"
|
||||||
</button>
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={busy}
|
||||||
|
className="rounded-full bg-stone-950 px-5 py-3 text-sm font-semibold text-white transition hover:bg-stone-800 disabled:cursor-not-allowed disabled:bg-stone-400"
|
||||||
|
>
|
||||||
|
{busy ? (editingId ? "Updating..." : "Saving...") : editingId ? "Update expense" : "Save expense"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
@@ -189,8 +340,15 @@ export function ExpenseWorkspace({ categoryOptions }: Props) {
|
|||||||
{expense.date} · {getCategoryLabel(expense.category)}
|
{expense.date} · {getCategoryLabel(expense.category)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-2">
|
||||||
<p className="font-semibold text-stone-950">{formatCurrencyFromCents(expense.amountCents)}</p>
|
<p className="mr-2 font-semibold text-stone-950">{formatCurrencyFromCents(expense.amountCents)}</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleEdit(expense)}
|
||||||
|
className="rounded-full border border-stone-300 px-3 py-2 text-xs font-semibold uppercase tracking-[0.2em] text-stone-600 transition hover:border-stone-900 hover:text-stone-900"
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleDelete(expense.id)}
|
onClick={() => handleDelete(expense.id)}
|
||||||
|
|||||||
@@ -9,6 +9,11 @@ import { formatCurrencyFromCents, formatPercent } from "@/lib/money";
|
|||||||
|
|
||||||
type DashboardSnapshot = {
|
type DashboardSnapshot = {
|
||||||
month: string;
|
month: string;
|
||||||
|
insight: {
|
||||||
|
summary: string;
|
||||||
|
recommendations: string;
|
||||||
|
generatedAt: string;
|
||||||
|
} | null;
|
||||||
totals: {
|
totals: {
|
||||||
expensesCents: number;
|
expensesCents: number;
|
||||||
paychecksCents: number;
|
paychecksCents: number;
|
||||||
@@ -23,30 +28,61 @@ 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 = {
|
||||||
|
available: boolean;
|
||||||
|
configuredModel: string;
|
||||||
|
configuredUrl: string;
|
||||||
|
installedModels: string[];
|
||||||
|
modelReady: boolean;
|
||||||
|
message: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function HomeDashboard() {
|
export function HomeDashboard() {
|
||||||
const [selectedMonth, setSelectedMonth] = useState(getCurrentMonthKey());
|
const [selectedMonth, setSelectedMonth] = useState(getCurrentMonthKey());
|
||||||
const [snapshot, setSnapshot] = useState<DashboardSnapshot | null>(null);
|
const [snapshot, setSnapshot] = useState<DashboardSnapshot | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [insightBusy, setInsightBusy] = useState(false);
|
||||||
|
const [ollamaBusy, setOllamaBusy] = useState(false);
|
||||||
|
const [ollamaStatus, setOllamaStatus] = useState<OllamaStatus | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
async function loadDashboard(month: string) {
|
||||||
async function loadDashboard() {
|
const response = await fetch(`/dashboard?month=${month}`, { cache: "no-store" });
|
||||||
const response = await fetch(`/dashboard?month=${selectedMonth}`, { cache: "no-store" });
|
const payload = (await response.json().catch(() => ({ error: "Could not load the dashboard." }))) as DashboardSnapshot & { error?: string };
|
||||||
const payload = (await response.json()) 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.");
|
||||||
return;
|
return;
|
||||||
}
|
|
||||||
|
|
||||||
setError(null);
|
|
||||||
setSnapshot(payload);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void loadDashboard();
|
setError(null);
|
||||||
|
setSnapshot(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadOllamaStatus() {
|
||||||
|
const response = await fetch("/ollama/status", { cache: "no-store" });
|
||||||
|
const payload = (await response.json()) as OllamaStatus;
|
||||||
|
setOllamaStatus(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timeoutId = window.setTimeout(() => {
|
||||||
|
void loadDashboard(selectedMonth);
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
return () => window.clearTimeout(timeoutId);
|
||||||
}, [selectedMonth]);
|
}, [selectedMonth]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timeoutId = window.setTimeout(async () => {
|
||||||
|
await loadOllamaStatus();
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
return () => window.clearTimeout(timeoutId);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const topCategoryLabel = useMemo(() => {
|
const topCategoryLabel = useMemo(() => {
|
||||||
if (!snapshot?.comparisons.highestCategory) {
|
if (!snapshot?.comparisons.highestCategory) {
|
||||||
return "No category leader yet";
|
return "No category leader yet";
|
||||||
@@ -60,6 +96,39 @@ export function HomeDashboard() {
|
|||||||
? "No spend yet"
|
? "No spend yet"
|
||||||
: formatPercent(snapshot.totals.paycheckCoverageRatio);
|
: 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handlePullModel() {
|
||||||
|
setOllamaBusy(true);
|
||||||
|
|
||||||
|
const response = await fetch("/ollama/pull", { method: "POST" });
|
||||||
|
const payload = (await response.json().catch(() => null)) as { error?: string; message?: string } | null;
|
||||||
|
|
||||||
|
setOllamaBusy(false);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
setError(payload?.error ?? "Could not pull the configured model.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setError(payload?.message ?? null);
|
||||||
|
await loadOllamaStatus();
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-10">
|
<div className="space-y-10">
|
||||||
<section className="grid gap-6 rounded-[2rem] border border-stone-200 bg-[radial-gradient(circle_at_top_left,_rgba(251,191,36,0.26),_transparent_32%),linear-gradient(135deg,#fffaf2,#f3efe7)] p-8 shadow-[0_28px_70px_rgba(120,90,50,0.10)] lg:grid-cols-[1.2fr_0.8fr]">
|
<section className="grid gap-6 rounded-[2rem] border border-stone-200 bg-[radial-gradient(circle_at_top_left,_rgba(251,191,36,0.26),_transparent_32%),linear-gradient(135deg,#fffaf2,#f3efe7)] p-8 shadow-[0_28px_70px_rgba(120,90,50,0.10)] lg:grid-cols-[1.2fr_0.8fr]">
|
||||||
@@ -119,6 +188,183 @@ export function HomeDashboard() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section className="rounded-[2rem] border border-stone-200 bg-white p-8 shadow-[0_24px_60px_rgba(120,90,50,0.08)]">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold uppercase tracking-[0.24em] text-stone-500">Private monthly insight</p>
|
||||||
|
<h2 className="mt-2 text-3xl font-semibold text-stone-950">Offline guidance for this month</h2>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void handleGenerateInsights()}
|
||||||
|
disabled={insightBusy}
|
||||||
|
className="rounded-full bg-stone-950 px-5 py-3 text-sm font-semibold text-white transition hover:bg-stone-800 disabled:cursor-not-allowed disabled:bg-stone-400"
|
||||||
|
>
|
||||||
|
{insightBusy ? "Generating..." : "Generate insights"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 rounded-3xl border border-stone-200 bg-stone-50 px-5 py-4">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs uppercase tracking-[0.2em] text-stone-500">Ollama runtime</p>
|
||||||
|
<p className="mt-2 text-sm font-medium text-stone-700">
|
||||||
|
{ollamaStatus?.message ?? "Checking local runtime status..."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-full px-3 py-2 text-xs font-semibold uppercase tracking-[0.2em] text-white "
|
||||||
|
data-ready={ollamaStatus?.available && ollamaStatus?.modelReady ? "true" : "false"}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
ollamaStatus?.available && ollamaStatus?.modelReady
|
||||||
|
? "rounded-full bg-emerald-600 px-3 py-2"
|
||||||
|
: "rounded-full bg-stone-500 px-3 py-2"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{ollamaStatus?.available && ollamaStatus?.modelReady ? "Ready" : "Needs attention"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 grid gap-3 text-sm text-stone-600 sm:grid-cols-2">
|
||||||
|
<p>
|
||||||
|
Model: <span className="font-semibold text-stone-900">{ollamaStatus?.configuredModel ?? "-"}</span>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
URL: <span className="font-semibold text-stone-900">{ollamaStatus?.configuredUrl ?? "-"}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 flex flex-wrap gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void loadOllamaStatus()}
|
||||||
|
className="rounded-full border border-stone-300 px-4 py-2 text-xs font-semibold uppercase tracking-[0.2em] text-stone-700 transition hover:border-stone-900"
|
||||||
|
>
|
||||||
|
Refresh status
|
||||||
|
</button>
|
||||||
|
{ollamaStatus?.available && !ollamaStatus.modelReady ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void handlePullModel()}
|
||||||
|
disabled={ollamaBusy}
|
||||||
|
className="rounded-full bg-stone-950 px-4 py-2 text-xs font-semibold uppercase tracking-[0.2em] text-white transition hover:bg-stone-800 disabled:cursor-not-allowed disabled:bg-stone-400"
|
||||||
|
>
|
||||||
|
{ollamaBusy ? "Pulling model..." : "Pull configured model"}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
<a
|
||||||
|
href="/backup/database"
|
||||||
|
className="rounded-full border border-stone-300 px-4 py-2 text-xs font-semibold uppercase tracking-[0.2em] text-stone-700 transition hover:border-stone-900"
|
||||||
|
>
|
||||||
|
Download backup
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{snapshot?.insight ? (
|
||||||
|
<div className="mt-6 space-y-4">
|
||||||
|
{/* AI summary */}
|
||||||
|
<div className="rounded-3xl border border-amber-100 bg-gradient-to-br from-[#fffdf8] to-[#fff8ec] px-6 py-6">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs font-semibold uppercase tracking-[0.2em] text-amber-700">AI Summary</span>
|
||||||
|
<span className="rounded-full bg-amber-100 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-amber-600">✦ Offline</span>
|
||||||
|
</div>
|
||||||
|
<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">
|
||||||
|
Generated {new Date(snapshot.insight.generatedAt).toLocaleString()}
|
||||||
|
</p>
|
||||||
|
</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 className="mt-6 rounded-3xl border border-dashed border-stone-300 px-5 py-8 text-stone-600">
|
||||||
|
No saved insight for this month yet. Generate one to get a private offline summary.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
<section className="grid gap-6 lg:grid-cols-[1.1fr_0.9fr]">
|
<section className="grid gap-6 lg:grid-cols-[1.1fr_0.9fr]">
|
||||||
<div className="rounded-[2rem] border border-stone-200 bg-white p-8 shadow-[0_24px_60px_rgba(120,90,50,0.08)]">
|
<div className="rounded-[2rem] border border-stone-200 bg-white p-8 shadow-[0_24px_60px_rgba(120,90,50,0.08)]">
|
||||||
<div className="flex items-center justify-between gap-4">
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useEffect, useMemo, useState, type FormEvent } from "react";
|
import { useEffect, useMemo, useState, type FormEvent } from "react";
|
||||||
|
|
||||||
|
import { getCurrentMonthKey } from "@/lib/date";
|
||||||
import { formatCurrencyFromCents } from "@/lib/money";
|
import { formatCurrencyFromCents } from "@/lib/money";
|
||||||
|
|
||||||
type PaycheckRecord = {
|
type PaycheckRecord = {
|
||||||
@@ -10,8 +11,18 @@ type PaycheckRecord = {
|
|||||||
payDate: string;
|
payDate: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type PaySchedule = {
|
||||||
|
id: string;
|
||||||
|
amountCents: number;
|
||||||
|
anchorDate: string;
|
||||||
|
};
|
||||||
|
|
||||||
export function PaycheckWorkspace() {
|
export function PaycheckWorkspace() {
|
||||||
const [paychecks, setPaychecks] = useState<PaycheckRecord[]>([]);
|
const [paychecks, setPaychecks] = useState<PaycheckRecord[]>([]);
|
||||||
|
const [schedule, setSchedule] = useState<PaySchedule | null>(null);
|
||||||
|
const [projectedDates, setProjectedDates] = useState<string[]>([]);
|
||||||
|
const [scheduleForm, setScheduleForm] = useState({ amount: "", anchorDate: new Date().toISOString().slice(0, 10) });
|
||||||
|
const [showScheduleForm, setShowScheduleForm] = useState(false);
|
||||||
const [formState, setFormState] = useState({
|
const [formState, setFormState] = useState({
|
||||||
amount: "",
|
amount: "",
|
||||||
payDate: new Date().toISOString().slice(0, 10),
|
payDate: new Date().toISOString().slice(0, 10),
|
||||||
@@ -20,13 +31,22 @@ export function PaycheckWorkspace() {
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function loadPaychecks() {
|
async function loadData() {
|
||||||
const response = await fetch("/paychecks", { cache: "no-store" });
|
const [paycheckRes, scheduleRes] = await Promise.all([
|
||||||
const payload = (await response.json()) as { paychecks?: PaycheckRecord[] };
|
fetch("/paychecks", { cache: "no-store" }),
|
||||||
setPaychecks(payload.paychecks ?? []);
|
fetch("/pay-schedule", { cache: "no-store" }),
|
||||||
|
]);
|
||||||
|
const paycheckPayload = (await paycheckRes.json()) as { paychecks?: PaycheckRecord[] };
|
||||||
|
setPaychecks(paycheckPayload.paychecks ?? []);
|
||||||
|
|
||||||
|
const schedulePayload = (await scheduleRes.json().catch(() => ({ schedule: null }))) as { schedule?: PaySchedule | null };
|
||||||
|
if (schedulePayload.schedule) {
|
||||||
|
setSchedule(schedulePayload.schedule);
|
||||||
|
setProjectedDates(computeProjectedDates(schedulePayload.schedule.anchorDate, getCurrentMonthKey()));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void loadPaychecks();
|
void loadData();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const totalIncome = useMemo(
|
const totalIncome = useMemo(
|
||||||
@@ -34,6 +54,71 @@ export function PaycheckWorkspace() {
|
|||||||
[paychecks],
|
[paychecks],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Projected dates that don't already have a manual paycheck
|
||||||
|
const manualPayDates = useMemo(() => new Set(paychecks.map((p) => p.payDate)), [paychecks]);
|
||||||
|
const pendingProjectedDates = useMemo(
|
||||||
|
() => projectedDates.filter((d) => !manualPayDates.has(d)),
|
||||||
|
[projectedDates, manualPayDates],
|
||||||
|
);
|
||||||
|
|
||||||
|
async function handleSaveSchedule(event: FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const response = await fetch("/pay-schedule", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(scheduleForm),
|
||||||
|
});
|
||||||
|
|
||||||
|
setBusy(false);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const payload = (await response.json().catch(() => null)) as { error?: string } | null;
|
||||||
|
setError(payload?.error ?? "Could not save the schedule.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = (await response.json()) as { schedule: PaySchedule };
|
||||||
|
setSchedule(payload.schedule);
|
||||||
|
setProjectedDates(computeProjectedDates(payload.schedule.anchorDate, getCurrentMonthKey()));
|
||||||
|
setShowScheduleForm(false);
|
||||||
|
setScheduleForm({ amount: "", anchorDate: new Date().toISOString().slice(0, 10) });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleClearSchedule() {
|
||||||
|
setBusy(true);
|
||||||
|
await fetch("/pay-schedule", { method: "DELETE" });
|
||||||
|
setBusy(false);
|
||||||
|
setSchedule(null);
|
||||||
|
setProjectedDates([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleConfirmProjected(date: string) {
|
||||||
|
if (!schedule) return;
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const amount = (schedule.amountCents / 100).toFixed(2);
|
||||||
|
const response = await fetch("/paychecks", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ amount, payDate: date }),
|
||||||
|
});
|
||||||
|
|
||||||
|
setBusy(false);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const payload = (await response.json().catch(() => null)) as { error?: string } | null;
|
||||||
|
setError(payload?.error ?? "Could not confirm paycheck.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = (await response.json()) as { paycheck: PaycheckRecord };
|
||||||
|
setPaychecks((current) => [payload.paycheck, ...current]);
|
||||||
|
}
|
||||||
|
|
||||||
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
@@ -75,92 +160,272 @@ export function PaycheckWorkspace() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid gap-6 lg:grid-cols-[1.1fr_0.9fr]">
|
<div className="space-y-6">
|
||||||
<section className="rounded-[2rem] border border-stone-200 bg-white p-6 shadow-[0_24px_60px_rgba(120,90,50,0.08)]">
|
{/* Biweekly schedule panel */}
|
||||||
<div className="mb-6 flex items-center justify-between gap-4">
|
<section className="rounded-[2rem] border border-emerald-100 bg-gradient-to-br from-[#f6fbf6] to-[#edf7ed] p-6 shadow-[0_24px_60px_rgba(60,120,60,0.06)]">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-semibold uppercase tracking-[0.24em] text-emerald-700">Income entry</p>
|
<p className="text-sm font-semibold uppercase tracking-[0.24em] text-emerald-700">Biweekly schedule</p>
|
||||||
<h2 className="mt-2 text-3xl font-semibold text-stone-950">Record each paycheck on the date it lands</h2>
|
<h2 className="mt-1 text-2xl font-semibold text-stone-950">
|
||||||
|
{schedule ? "Active pay schedule" : "Set up your pay schedule"}
|
||||||
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-2xl bg-emerald-50 px-4 py-3 text-right">
|
{schedule ? (
|
||||||
<p className="text-xs uppercase tracking-[0.2em] text-emerald-700">Tracked paychecks</p>
|
<div className="flex gap-2">
|
||||||
<p className="mt-1 text-2xl font-semibold text-stone-950">{formatCurrencyFromCents(totalIncome)}</p>
|
<button
|
||||||
</div>
|
type="button"
|
||||||
</div>
|
onClick={() => { setShowScheduleForm(true); setScheduleForm({ amount: (schedule.amountCents / 100).toFixed(2), anchorDate: schedule.anchorDate }); }}
|
||||||
|
className="rounded-full border border-stone-300 px-4 py-2 text-xs font-semibold uppercase tracking-[0.2em] text-stone-700 transition hover:border-stone-900"
|
||||||
<form className="grid gap-4 md:grid-cols-2" onSubmit={handleSubmit}>
|
>
|
||||||
<label className="grid gap-2 text-sm font-medium text-stone-700">
|
Edit
|
||||||
Amount
|
</button>
|
||||||
<input
|
<button
|
||||||
required
|
type="button"
|
||||||
inputMode="decimal"
|
onClick={() => void handleClearSchedule()}
|
||||||
value={formState.amount}
|
disabled={busy}
|
||||||
onChange={(event) => setFormState((current) => ({ ...current, amount: event.target.value }))}
|
className="rounded-full border border-stone-300 px-4 py-2 text-xs font-semibold uppercase tracking-[0.2em] text-stone-600 transition hover:border-rose-400 hover:text-rose-600 disabled:cursor-not-allowed"
|
||||||
className="rounded-2xl border border-stone-300 bg-stone-50 px-4 py-3 outline-none transition focus:border-stone-900"
|
>
|
||||||
placeholder="1800.00"
|
Clear
|
||||||
/>
|
</button>
|
||||||
</label>
|
|
||||||
|
|
||||||
<label className="grid gap-2 text-sm font-medium text-stone-700">
|
|
||||||
Pay date
|
|
||||||
<input
|
|
||||||
required
|
|
||||||
type="date"
|
|
||||||
value={formState.payDate}
|
|
||||||
onChange={(event) => setFormState((current) => ({ ...current, payDate: event.target.value }))}
|
|
||||||
className="rounded-2xl border border-stone-300 bg-stone-50 px-4 py-3 outline-none transition focus:border-stone-900"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<div className="md:col-span-2 flex items-center justify-between gap-3">
|
|
||||||
<p className="text-sm text-rose-700">{error}</p>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={busy}
|
|
||||||
className="rounded-full bg-stone-950 px-5 py-3 text-sm font-semibold text-white transition hover:bg-stone-800 disabled:cursor-not-allowed disabled:bg-stone-400"
|
|
||||||
>
|
|
||||||
{busy ? "Saving..." : "Save paycheck"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="rounded-[2rem] border border-stone-200 bg-[#f6fbf6] p-6 shadow-[0_24px_60px_rgba(120,90,50,0.08)]">
|
|
||||||
<div className="mb-5">
|
|
||||||
<p className="text-sm font-semibold uppercase tracking-[0.24em] text-stone-500">Income history</p>
|
|
||||||
<h2 className="mt-2 text-2xl font-semibold text-stone-950">Recent paychecks</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-3">
|
|
||||||
{paychecks.length === 0 ? (
|
|
||||||
<div className="rounded-3xl border border-dashed border-stone-300 px-4 py-6 text-sm text-stone-600">
|
|
||||||
No paychecks yet. Add the next deposit to start cash-flow tracking.
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
paychecks.map((paycheck) => (
|
!showScheduleForm && (
|
||||||
<article
|
<button
|
||||||
key={paycheck.id}
|
type="button"
|
||||||
className="flex items-center justify-between gap-4 rounded-3xl border border-stone-200 bg-white px-4 py-4"
|
onClick={() => setShowScheduleForm(true)}
|
||||||
|
className="rounded-full bg-emerald-700 px-5 py-2.5 text-sm font-semibold text-white transition hover:bg-emerald-800"
|
||||||
>
|
>
|
||||||
<div>
|
Set up schedule
|
||||||
<p className="font-semibold text-stone-950">Paycheck</p>
|
</button>
|
||||||
<p className="mt-1 text-sm text-stone-600">{paycheck.payDate}</p>
|
)
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<p className="font-semibold text-stone-950">{formatCurrencyFromCents(paycheck.amountCents)}</p>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => handleDelete(paycheck.id)}
|
|
||||||
className="rounded-full border border-stone-300 px-3 py-2 text-xs font-semibold uppercase tracking-[0.2em] text-stone-600 transition hover:border-rose-400 hover:text-rose-600"
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
))
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{showScheduleForm && (
|
||||||
|
<form className="mt-5 grid gap-4 sm:grid-cols-[1fr_1fr_auto]" onSubmit={handleSaveSchedule}>
|
||||||
|
<label className="grid gap-2 text-sm font-medium text-stone-700">
|
||||||
|
Amount per paycheck
|
||||||
|
<input
|
||||||
|
required
|
||||||
|
inputMode="decimal"
|
||||||
|
value={scheduleForm.amount}
|
||||||
|
onChange={(e) => setScheduleForm((s) => ({ ...s, amount: e.target.value }))}
|
||||||
|
className="rounded-2xl border border-stone-300 bg-white px-4 py-3 outline-none transition focus:border-emerald-600"
|
||||||
|
placeholder="2400.00"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="grid gap-2 text-sm font-medium text-stone-700">
|
||||||
|
A known pay date (anchor)
|
||||||
|
<input
|
||||||
|
required
|
||||||
|
type="date"
|
||||||
|
value={scheduleForm.anchorDate}
|
||||||
|
onChange={(e) => setScheduleForm((s) => ({ ...s, anchorDate: e.target.value }))}
|
||||||
|
className="rounded-2xl border border-stone-300 bg-white px-4 py-3 outline-none transition focus:border-emerald-600"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className="flex items-end gap-2">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={busy}
|
||||||
|
className="rounded-full bg-stone-950 px-5 py-3 text-sm font-semibold text-white transition hover:bg-stone-800 disabled:cursor-not-allowed disabled:bg-stone-400"
|
||||||
|
>
|
||||||
|
{busy ? "Saving..." : "Save"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowScheduleForm(false)}
|
||||||
|
className="rounded-full border border-stone-300 px-5 py-3 text-sm font-semibold text-stone-700 transition hover:border-stone-900"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{schedule && !showScheduleForm && (
|
||||||
|
<div className="mt-5">
|
||||||
|
<div className="flex flex-wrap gap-6 text-sm text-stone-600">
|
||||||
|
<span>
|
||||||
|
Amount: <span className="font-semibold text-stone-950">{formatCurrencyFromCents(schedule.amountCents)}</span>
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
Cadence: <span className="font-semibold text-stone-950">Every 2 weeks</span>
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
Anchor: <span className="font-semibold text-stone-950">{schedule.anchorDate}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{projectedDates.length > 0 && (
|
||||||
|
<div className="mt-4">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-stone-500">
|
||||||
|
Projected pay dates this month
|
||||||
|
</p>
|
||||||
|
<div className="mt-2 flex flex-wrap gap-3">
|
||||||
|
{projectedDates.map((date) => {
|
||||||
|
const confirmed = manualPayDates.has(date);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={date}
|
||||||
|
className={`flex items-center gap-2 rounded-2xl border px-4 py-2 text-sm ${
|
||||||
|
confirmed
|
||||||
|
? "border-emerald-200 bg-emerald-50 text-emerald-800"
|
||||||
|
: "border-stone-200 bg-white text-stone-700"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="font-medium">{date}</span>
|
||||||
|
{confirmed ? (
|
||||||
|
<span className="text-xs font-semibold text-emerald-600">✓ Confirmed</span>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void handleConfirmProjected(date)}
|
||||||
|
disabled={busy}
|
||||||
|
className="rounded-full bg-emerald-700 px-2.5 py-1 text-xs font-semibold text-white transition hover:bg-emerald-800 disabled:cursor-not-allowed disabled:bg-stone-400"
|
||||||
|
>
|
||||||
|
Mark received
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{pendingProjectedDates.length > 0 && (
|
||||||
|
<p className="mt-2 text-xs text-stone-500">
|
||||||
|
{pendingProjectedDates.length} pending — included in dashboard totals as projected income.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<div className="grid gap-6 lg:grid-cols-[1.1fr_0.9fr]">
|
||||||
|
{/* Manual paycheck entry */}
|
||||||
|
<section className="rounded-[2rem] border border-stone-200 bg-white p-6 shadow-[0_24px_60px_rgba(120,90,50,0.08)]">
|
||||||
|
<div className="mb-6 flex items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold uppercase tracking-[0.24em] text-emerald-700">One-off entry</p>
|
||||||
|
<h2 className="mt-2 text-3xl font-semibold text-stone-950">Log a bonus or extra deposit</h2>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-2xl bg-emerald-50 px-4 py-3 text-right">
|
||||||
|
<p className="text-xs uppercase tracking-[0.2em] text-emerald-700">Tracked paychecks</p>
|
||||||
|
<p className="mt-1 text-2xl font-semibold text-stone-950">{formatCurrencyFromCents(totalIncome)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form className="grid gap-4 md:grid-cols-2" onSubmit={handleSubmit}>
|
||||||
|
<label className="grid gap-2 text-sm font-medium text-stone-700">
|
||||||
|
Amount
|
||||||
|
<input
|
||||||
|
required
|
||||||
|
inputMode="decimal"
|
||||||
|
value={formState.amount}
|
||||||
|
onChange={(event) => setFormState((current) => ({ ...current, amount: event.target.value }))}
|
||||||
|
className="rounded-2xl border border-stone-300 bg-stone-50 px-4 py-3 outline-none transition focus:border-stone-900"
|
||||||
|
placeholder="1800.00"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="grid gap-2 text-sm font-medium text-stone-700">
|
||||||
|
Pay date
|
||||||
|
<input
|
||||||
|
required
|
||||||
|
type="date"
|
||||||
|
value={formState.payDate}
|
||||||
|
onChange={(event) => setFormState((current) => ({ ...current, payDate: event.target.value }))}
|
||||||
|
className="rounded-2xl border border-stone-300 bg-stone-50 px-4 py-3 outline-none transition focus:border-stone-900"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="md:col-span-2 flex items-center justify-between gap-3">
|
||||||
|
<p className="text-sm text-rose-700">{error}</p>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={busy}
|
||||||
|
className="rounded-full bg-stone-950 px-5 py-3 text-sm font-semibold text-white transition hover:bg-stone-800 disabled:cursor-not-allowed disabled:bg-stone-400"
|
||||||
|
>
|
||||||
|
{busy ? "Saving..." : "Save paycheck"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Paycheck history */}
|
||||||
|
<section className="rounded-[2rem] border border-stone-200 bg-[#f6fbf6] p-6 shadow-[0_24px_60px_rgba(120,90,50,0.08)]">
|
||||||
|
<div className="mb-5">
|
||||||
|
<p className="text-sm font-semibold uppercase tracking-[0.24em] text-stone-500">Income history</p>
|
||||||
|
<h2 className="mt-2 text-2xl font-semibold text-stone-950">Recent paychecks</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{paychecks.length === 0 ? (
|
||||||
|
<div className="rounded-3xl border border-dashed border-stone-300 px-4 py-6 text-sm text-stone-600">
|
||||||
|
No paychecks yet. Use "Mark received" on a projected date or add one manually.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
paychecks.map((paycheck) => (
|
||||||
|
<article
|
||||||
|
key={paycheck.id}
|
||||||
|
className="flex items-center justify-between gap-4 rounded-3xl border border-stone-200 bg-white px-4 py-4"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold text-stone-950">Paycheck</p>
|
||||||
|
<p className="mt-1 text-sm text-stone-600">{paycheck.payDate}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<p className="font-semibold text-stone-950">{formatCurrencyFromCents(paycheck.amountCents)}</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void handleDelete(paycheck.id)}
|
||||||
|
className="rounded-full border border-stone-300 px-3 py-2 text-xs font-semibold uppercase tracking-[0.2em] text-stone-600 transition hover:border-rose-400 hover:text-rose-600"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Client-side projection — mirrors the server-side logic in src/lib/pay-schedule.ts
|
||||||
|
* so the UI can show projected dates without a round-trip.
|
||||||
|
*/
|
||||||
|
function computeProjectedDates(anchorDate: string, month: string): string[] {
|
||||||
|
const [aY, aM, aD] = anchorDate.split("-").map(Number);
|
||||||
|
const [year, mon] = month.split("-").map(Number);
|
||||||
|
|
||||||
|
const anchor = new Date(aY, aM - 1, aD);
|
||||||
|
const monthStart = new Date(year, mon - 1, 1);
|
||||||
|
const monthEnd = new Date(year, mon, 0);
|
||||||
|
|
||||||
|
const MS_PER_DAY = 86_400_000;
|
||||||
|
const period = 14 * MS_PER_DAY;
|
||||||
|
const anchorMs = anchor.getTime();
|
||||||
|
const monthStartMs = monthStart.getTime();
|
||||||
|
const monthEndMs = monthEnd.getTime();
|
||||||
|
|
||||||
|
const diff = monthStartMs - anchorMs;
|
||||||
|
const periods = Math.floor(diff / period);
|
||||||
|
let current = anchorMs + periods * period;
|
||||||
|
while (current < monthStartMs) current += period;
|
||||||
|
|
||||||
|
const results: string[] = [];
|
||||||
|
while (current <= monthEndMs) {
|
||||||
|
const d = new Date(current);
|
||||||
|
const y = d.getFullYear();
|
||||||
|
const m = String(d.getMonth() + 1).padStart(2, "0");
|
||||||
|
const day = String(d.getDate()).padStart(2, "0");
|
||||||
|
results.push(`${y}-${m}-${day}`);
|
||||||
|
current += period;
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|||||||
60
src/lib/category-suggestion.test.ts
Normal file
60
src/lib/category-suggestion.test.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
vi.mock("@/lib/merchant-corrections", () => ({
|
||||||
|
getMerchantCorrection: vi.fn(),
|
||||||
|
getRecentCorrections: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { getMerchantCorrection, getRecentCorrections } from "@/lib/merchant-corrections";
|
||||||
|
import { getMerchantRuleCategory, suggestCategoryForMerchant } from "@/lib/category-suggestion";
|
||||||
|
|
||||||
|
describe("getMerchantRuleCategory", () => {
|
||||||
|
it("matches known merchants deterministically", () => {
|
||||||
|
expect(getMerchantRuleCategory("Amazon Marketplace")).toBe("SHOPPING");
|
||||||
|
expect(getMerchantRuleCategory("Uber Trip")).toBe("TRANSPORT");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("suggestCategoryForMerchant", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.mocked(getMerchantCorrection).mockResolvedValue(null);
|
||||||
|
vi.mocked(getRecentCorrections).mockResolvedValue([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns learned category without confirmation when a correction exists", async () => {
|
||||||
|
const { getMerchantCorrection } = await import("@/lib/merchant-corrections");
|
||||||
|
vi.mocked(getMerchantCorrection).mockResolvedValueOnce({ merchantName: "Blue Tokai", category: "FOOD" });
|
||||||
|
|
||||||
|
const suggestion = await suggestCategoryForMerchant("Blue Tokai");
|
||||||
|
|
||||||
|
expect(suggestion.category).toBe("FOOD");
|
||||||
|
expect(suggestion.source).toBe("learned");
|
||||||
|
expect(suggestion.requiresConfirmation).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the local model for unknown merchants", async () => {
|
||||||
|
vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ response: JSON.stringify({ category: "FOOD" }) }),
|
||||||
|
} as Response);
|
||||||
|
|
||||||
|
const suggestion = await suggestCategoryForMerchant("Blue Tokai");
|
||||||
|
|
||||||
|
expect(suggestion.category).toBe("FOOD");
|
||||||
|
expect(suggestion.source).toBe("model");
|
||||||
|
expect(suggestion.requiresConfirmation).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back cleanly when the local model is unavailable", async () => {
|
||||||
|
vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("offline"));
|
||||||
|
|
||||||
|
const suggestion = await suggestCategoryForMerchant("Unknown Merchant");
|
||||||
|
|
||||||
|
expect(suggestion.category).toBeNull();
|
||||||
|
expect(suggestion.source).toBe("unavailable");
|
||||||
|
});
|
||||||
|
});
|
||||||
138
src/lib/category-suggestion.ts
Normal file
138
src/lib/category-suggestion.ts
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
import { CATEGORY_VALUES, type CategoryValue } from "@/lib/categories";
|
||||||
|
import { getMerchantCorrection, getRecentCorrections } from "@/lib/merchant-corrections";
|
||||||
|
import { generateOllamaJson } from "@/lib/ollama";
|
||||||
|
|
||||||
|
type SuggestionSource = "rule" | "model" | "unavailable" | "learned";
|
||||||
|
|
||||||
|
export type CategorySuggestion = {
|
||||||
|
category: CategoryValue | null;
|
||||||
|
message: string;
|
||||||
|
merchantName: string;
|
||||||
|
requiresConfirmation: boolean;
|
||||||
|
source: SuggestionSource;
|
||||||
|
};
|
||||||
|
|
||||||
|
type MerchantRule = {
|
||||||
|
category: CategoryValue;
|
||||||
|
patterns: RegExp[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const MERCHANT_RULES: MerchantRule[] = [
|
||||||
|
{ category: "SHOPPING", patterns: [/amazon/i, /flipkart/i, /myntra/i] },
|
||||||
|
{ category: "FOOD", patterns: [/swiggy/i, /zomato/i, /starbucks/i, /instamart/i, /blinkit/i] },
|
||||||
|
{ category: "TRANSPORT", patterns: [/uber/i, /ola/i, /rapido/i, /metro/i] },
|
||||||
|
{ category: "BILLS", patterns: [/airtel/i, /jio/i, /electricity/i, /water bill/i] },
|
||||||
|
{ category: "HEALTH", patterns: [/apollo/i, /pharmacy/i, /clinic/i, /hospital/i] },
|
||||||
|
{ category: "ENTERTAINMENT", patterns: [/netflix/i, /spotify/i, /bookmyshow/i] },
|
||||||
|
{ category: "RENT", patterns: [/rent/i, /landlord/i, /lease/i] },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function normalizeMerchantName(value: string) {
|
||||||
|
return value.trim().replace(/\s+/g, " ");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMerchantRuleCategory(merchantName: string): CategoryValue | null {
|
||||||
|
const normalized = normalizeMerchantName(merchantName);
|
||||||
|
|
||||||
|
for (const rule of MERCHANT_RULES) {
|
||||||
|
if (rule.patterns.some((pattern) => pattern.test(normalized))) {
|
||||||
|
return rule.category;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSuggestedCategory(raw: unknown): CategoryValue | null {
|
||||||
|
if (typeof raw !== "string") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = raw.trim().toUpperCase();
|
||||||
|
return CATEGORY_VALUES.includes(normalized as CategoryValue) ? (normalized as CategoryValue) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildOllamaPrompt(merchantName: string, examples: Array<{ merchantName: string; category: string }>) {
|
||||||
|
const lines = [
|
||||||
|
"You categorize personal expense merchants.",
|
||||||
|
"Return JSON with one key named category.",
|
||||||
|
"Allowed values only: RENT, FOOD, TRANSPORT, BILLS, SHOPPING, HEALTH, ENTERTAINMENT, MISC.",
|
||||||
|
];
|
||||||
|
|
||||||
|
if (examples.length > 0) {
|
||||||
|
lines.push("Use these corrections the user has made previously as guidance:");
|
||||||
|
for (const ex of examples) {
|
||||||
|
lines.push(` "${ex.merchantName}" → ${ex.category}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push(`Merchant: ${merchantName}`);
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function suggestCategoryForMerchant(merchantName: string): Promise<CategorySuggestion> {
|
||||||
|
const normalized = normalizeMerchantName(merchantName);
|
||||||
|
|
||||||
|
if (!normalized) {
|
||||||
|
return {
|
||||||
|
category: null,
|
||||||
|
message: "Enter a merchant name to get a suggestion.",
|
||||||
|
merchantName: normalized,
|
||||||
|
requiresConfirmation: false,
|
||||||
|
source: "unavailable",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Check stored user corrections first — highest priority, no confirmation needed.
|
||||||
|
const learned = await getMerchantCorrection(normalized);
|
||||||
|
if (learned) {
|
||||||
|
return {
|
||||||
|
category: learned.category as CategoryValue,
|
||||||
|
message: "Category auto-filled from your previous correction.",
|
||||||
|
merchantName: normalized,
|
||||||
|
requiresConfirmation: false,
|
||||||
|
source: "learned",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Hardcoded rules for well-known merchants.
|
||||||
|
const matchedCategory = getMerchantRuleCategory(normalized);
|
||||||
|
if (matchedCategory) {
|
||||||
|
return {
|
||||||
|
category: matchedCategory,
|
||||||
|
message: "Known merchant matched locally. Category auto-filled.",
|
||||||
|
merchantName: normalized,
|
||||||
|
requiresConfirmation: false,
|
||||||
|
source: "rule",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Ask Ollama, providing recent user corrections as few-shot examples.
|
||||||
|
try {
|
||||||
|
const recentCorrections = await getRecentCorrections(20);
|
||||||
|
const parsed = await generateOllamaJson<{ category?: string }>({
|
||||||
|
prompt: buildOllamaPrompt(normalized, recentCorrections),
|
||||||
|
});
|
||||||
|
const category = parseSuggestedCategory(parsed?.category);
|
||||||
|
|
||||||
|
if (!category) {
|
||||||
|
throw new Error("Model did not return a valid category.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
category,
|
||||||
|
message: "Local AI suggested a category. Review before saving.",
|
||||||
|
merchantName: normalized,
|
||||||
|
requiresConfirmation: true,
|
||||||
|
source: "model",
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
category: null,
|
||||||
|
message: "Local AI suggestion is unavailable right now. Choose a category manually.",
|
||||||
|
merchantName: normalized,
|
||||||
|
requiresConfirmation: false,
|
||||||
|
source: "unavailable",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -32,6 +32,7 @@ describe("buildDashboardSnapshot", () => {
|
|||||||
createdAt: new Date("2026-03-01T10:00:00Z"),
|
createdAt: new Date("2026-03-01T10:00:00Z"),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
insight: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(snapshot.totals.expensesCents).toBe(123200);
|
expect(snapshot.totals.expensesCents).toBe(123200);
|
||||||
@@ -48,6 +49,7 @@ describe("buildDashboardSnapshot", () => {
|
|||||||
month: "2026-03",
|
month: "2026-03",
|
||||||
expenses: [],
|
expenses: [],
|
||||||
paychecks: [],
|
paychecks: [],
|
||||||
|
insight: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(snapshot.totals.expensesCents).toBe(0);
|
expect(snapshot.totals.expensesCents).toBe(0);
|
||||||
@@ -55,5 +57,6 @@ describe("buildDashboardSnapshot", () => {
|
|||||||
expect(snapshot.totals.paycheckCoverageRatio).toBeNull();
|
expect(snapshot.totals.paycheckCoverageRatio).toBeNull();
|
||||||
expect(snapshot.comparisons.highestCategory).toBeNull();
|
expect(snapshot.comparisons.highestCategory).toBeNull();
|
||||||
expect(snapshot.comparisons.largestExpense).toBeNull();
|
expect(snapshot.comparisons.largestExpense).toBeNull();
|
||||||
|
expect(snapshot.insight).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 { db } from "@/lib/db";
|
||||||
import {
|
import {
|
||||||
@@ -9,9 +9,15 @@ import {
|
|||||||
isCurrentMonthKey,
|
isCurrentMonthKey,
|
||||||
isDateInMonth,
|
isDateInMonth,
|
||||||
} from "@/lib/date";
|
} from "@/lib/date";
|
||||||
|
import { getActiveSchedule, getProjectedPayDates, type PaySchedule } from "@/lib/pay-schedule";
|
||||||
|
|
||||||
export type DashboardSnapshot = {
|
export type DashboardSnapshot = {
|
||||||
month: string;
|
month: string;
|
||||||
|
insight: {
|
||||||
|
summary: string;
|
||||||
|
recommendations: string;
|
||||||
|
generatedAt: string;
|
||||||
|
} | null;
|
||||||
totals: {
|
totals: {
|
||||||
expensesCents: number;
|
expensesCents: number;
|
||||||
paychecksCents: number;
|
paychecksCents: number;
|
||||||
@@ -26,18 +32,39 @@ export 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;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function buildDashboardSnapshot(input: {
|
export function buildDashboardSnapshot(input: {
|
||||||
month: string;
|
month: string;
|
||||||
expenses: Expense[];
|
expenses: Expense[];
|
||||||
paychecks: Paycheck[];
|
paychecks: Paycheck[];
|
||||||
|
paySchedule?: PaySchedule | null;
|
||||||
|
insight?: MonthlyInsight | null;
|
||||||
}): DashboardSnapshot {
|
}): DashboardSnapshot {
|
||||||
const monthExpenses = input.expenses.filter((expense) => isDateInMonth(expense.date, input.month));
|
const monthExpenses = input.expenses.filter((expense) => isDateInMonth(expense.date, input.month));
|
||||||
const monthPaychecks = input.paychecks.filter((paycheck) => isDateInMonth(paycheck.payDate, input.month));
|
const monthPaychecks = input.paychecks.filter((paycheck) => isDateInMonth(paycheck.payDate, input.month));
|
||||||
|
|
||||||
|
// Project biweekly pay dates; suppress any that already have a manual paycheck on the same date
|
||||||
|
const manualPayDates = new Set(monthPaychecks.map((p) => p.payDate));
|
||||||
|
const projectedDates = input.paySchedule
|
||||||
|
? getProjectedPayDates(input.paySchedule.anchorDate, input.month)
|
||||||
|
: [];
|
||||||
|
const projectedPaychecks = projectedDates
|
||||||
|
.filter((date) => !manualPayDates.has(date))
|
||||||
|
.map((date) => ({ payDate: date, amountCents: input.paySchedule!.amountCents }));
|
||||||
|
|
||||||
|
const allPaychecks = [
|
||||||
|
...monthPaychecks.map((p) => ({ payDate: p.payDate, amountCents: p.amountCents })),
|
||||||
|
...projectedPaychecks,
|
||||||
|
];
|
||||||
|
|
||||||
const expensesCents = monthExpenses.reduce((sum, expense) => sum + expense.amountCents, 0);
|
const expensesCents = monthExpenses.reduce((sum, expense) => sum + expense.amountCents, 0);
|
||||||
const paychecksCents = monthPaychecks.reduce((sum, paycheck) => sum + paycheck.amountCents, 0);
|
const paychecksCents = allPaychecks.reduce((sum, p) => sum + p.amountCents, 0);
|
||||||
const netCashFlowCents = paychecksCents - expensesCents;
|
const netCashFlowCents = paychecksCents - expensesCents;
|
||||||
|
|
||||||
const daysConsidered = isCurrentMonthKey(input.month)
|
const daysConsidered = isCurrentMonthKey(input.month)
|
||||||
@@ -68,7 +95,7 @@ export function buildDashboardSnapshot(input: {
|
|||||||
dailyMap.set(expense.date, current);
|
dailyMap.set(expense.date, current);
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const paycheck of monthPaychecks) {
|
for (const paycheck of allPaychecks) {
|
||||||
const current = dailyMap.get(paycheck.payDate) ?? { expensesCents: 0, paychecksCents: 0 };
|
const current = dailyMap.get(paycheck.payDate) ?? { expensesCents: 0, paychecksCents: 0 };
|
||||||
current.paychecksCents += paycheck.amountCents;
|
current.paychecksCents += paycheck.amountCents;
|
||||||
dailyMap.set(paycheck.payDate, current);
|
dailyMap.set(paycheck.payDate, current);
|
||||||
@@ -80,6 +107,13 @@ export function buildDashboardSnapshot(input: {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
month: input.month,
|
month: input.month,
|
||||||
|
insight: input.insight
|
||||||
|
? {
|
||||||
|
summary: input.insight.summary,
|
||||||
|
recommendations: input.insight.recommendations,
|
||||||
|
generatedAt: input.insight.generatedAt.toISOString(),
|
||||||
|
}
|
||||||
|
: null,
|
||||||
totals: {
|
totals: {
|
||||||
expensesCents,
|
expensesCents,
|
||||||
paychecksCents,
|
paychecksCents,
|
||||||
@@ -98,6 +132,9 @@ export function buildDashboardSnapshot(input: {
|
|||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
},
|
},
|
||||||
|
paySchedule: input.paySchedule
|
||||||
|
? { amountCents: input.paySchedule.amountCents, anchorDate: input.paySchedule.anchorDate, projectedDates }
|
||||||
|
: null,
|
||||||
categoryBreakdown,
|
categoryBreakdown,
|
||||||
recentExpenses: monthExpenses
|
recentExpenses: monthExpenses
|
||||||
.slice()
|
.slice()
|
||||||
@@ -115,10 +152,12 @@ export function buildDashboardSnapshot(input: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getDashboardSnapshot(month = getCurrentMonthKey()) {
|
export async function getDashboardSnapshot(month = getCurrentMonthKey()) {
|
||||||
const [expenses, paychecks] = await Promise.all([
|
const [expenses, paychecks, insight, paySchedule] = await Promise.all([
|
||||||
db.expense.findMany({ orderBy: [{ date: "desc" }, { createdAt: "desc" }] }),
|
db.expense.findMany({ orderBy: [{ date: "desc" }, { createdAt: "desc" }] }),
|
||||||
db.paycheck.findMany({ orderBy: [{ payDate: "desc" }, { createdAt: "desc" }] }),
|
db.paycheck.findMany({ orderBy: [{ payDate: "desc" }, { createdAt: "desc" }] }),
|
||||||
|
db.monthlyInsight.findUnique({ where: { month } }),
|
||||||
|
getActiveSchedule(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return buildDashboardSnapshot({ month, expenses, paychecks });
|
return buildDashboardSnapshot({ month, expenses, paychecks, paySchedule, insight });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,21 @@ export async function removeExpense(id: string) {
|
|||||||
return db.expense.delete({ where: { id } });
|
return db.expense.delete({ where: { id } });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function updateExpense(
|
||||||
|
id: string,
|
||||||
|
input: { title: string; amountCents: number; date: string; category: Category },
|
||||||
|
) {
|
||||||
|
return db.expense.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
title: input.title.trim(),
|
||||||
|
amountCents: input.amountCents,
|
||||||
|
date: input.date,
|
||||||
|
category: input.category,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function getExpenseDashboardPreview(month = getCurrentMonthKey()) {
|
export async function getExpenseDashboardPreview(month = getCurrentMonthKey()) {
|
||||||
const expenses = await listExpenses();
|
const expenses = await listExpenses();
|
||||||
const monthExpenses = expenses.filter((expense) => isDateInMonth(expense.date, month));
|
const monthExpenses = expenses.filter((expense) => isDateInMonth(expense.date, month));
|
||||||
|
|||||||
139
src/lib/insights.test.ts
Normal file
139
src/lib/insights.test.ts
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
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<string, unknown>; create: Record<string, unknown> }) => ({
|
||||||
|
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() },
|
||||||
|
paySchedule: { findFirst: vi.fn().mockResolvedValue(null) },
|
||||||
|
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."]');
|
||||||
|
});
|
||||||
|
|
||||||
|
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.");
|
||||||
|
});
|
||||||
|
});
|
||||||
195
src/lib/insights.ts
Normal file
195
src/lib/insights.ts
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
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";
|
||||||
|
import { getCategoryLabel, type CategoryValue } from "@/lib/categories";
|
||||||
|
|
||||||
|
export type InsightResult = {
|
||||||
|
insight: MonthlyInsight;
|
||||||
|
source: "model" | "fallback";
|
||||||
|
};
|
||||||
|
|
||||||
|
type GeneratedInsightPayload = {
|
||||||
|
summary?: string;
|
||||||
|
recommendations?: string | 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 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 {
|
||||||
|
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 centsToDisplay(cents: number) {
|
||||||
|
return `$${(cents / 100).toFixed(2)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 [
|
||||||
|
"You are a private offline financial summarizer for a single-user expense tracker.",
|
||||||
|
"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 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.",
|
||||||
|
`Month: ${snapshot.month}`,
|
||||||
|
`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.",
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.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<InsightResult> {
|
||||||
|
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<GeneratedInsightPayload>({
|
||||||
|
prompt: buildInsightPrompt(snapshot),
|
||||||
|
});
|
||||||
|
|
||||||
|
const summary = coerceInsightText(generated.summary);
|
||||||
|
const recommendationsList = coerceRecommendationsList(generated.recommendations);
|
||||||
|
|
||||||
|
if (!summary || recommendationsList.length === 0) {
|
||||||
|
throw new OllamaUnavailableError("The local model returned an incomplete insight response.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const insight = await upsertMonthlyInsight(month, {
|
||||||
|
summary,
|
||||||
|
recommendations: JSON.stringify(recommendationsList),
|
||||||
|
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" };
|
||||||
|
}
|
||||||
|
}
|
||||||
28
src/lib/merchant-corrections.ts
Normal file
28
src/lib/merchant-corrections.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import type { Category } from "@prisma/client";
|
||||||
|
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
|
||||||
|
export type MerchantCorrection = {
|
||||||
|
merchantName: string;
|
||||||
|
category: Category;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function getMerchantCorrection(merchantName: string): Promise<MerchantCorrection | null> {
|
||||||
|
return db.merchantCorrection.findUnique({ where: { merchantName } });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getRecentCorrections(limit = 30): Promise<MerchantCorrection[]> {
|
||||||
|
return db.merchantCorrection.findMany({
|
||||||
|
orderBy: { updatedAt: "desc" },
|
||||||
|
take: limit,
|
||||||
|
select: { merchantName: true, category: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveMerchantCorrection(merchantName: string, category: Category): Promise<void> {
|
||||||
|
await db.merchantCorrection.upsert({
|
||||||
|
where: { merchantName },
|
||||||
|
update: { category },
|
||||||
|
create: { merchantName, category },
|
||||||
|
});
|
||||||
|
}
|
||||||
55
src/lib/ollama.test.ts
Normal file
55
src/lib/ollama.test.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { generateOllamaJson, getOllamaStatus, pullConfiguredOllamaModel } from "@/lib/ollama";
|
||||||
|
|
||||||
|
describe("getOllamaStatus", () => {
|
||||||
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("pullConfiguredOllamaModel", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requests a pull for the configured model", async () => {
|
||||||
|
vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ status: "success" }),
|
||||||
|
} as Response);
|
||||||
|
|
||||||
|
const result = await pullConfiguredOllamaModel();
|
||||||
|
|
||||||
|
expect(result.model).toBe("qwen3.5:9b");
|
||||||
|
});
|
||||||
|
});
|
||||||
132
src/lib/ollama.ts
Normal file
132
src/lib/ollama.ts
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
export class OllamaUnavailableError extends Error {
|
||||||
|
constructor(message = "Local AI runtime is unavailable.") {
|
||||||
|
super(message);
|
||||||
|
this.name = "OllamaUnavailableError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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<OllamaStatus> {
|
||||||
|
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 pullConfiguredOllamaModel() {
|
||||||
|
const { baseUrl, model } = getOllamaConfig();
|
||||||
|
|
||||||
|
let response: Response;
|
||||||
|
|
||||||
|
try {
|
||||||
|
response = await fetch(`${baseUrl}/api/pull`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ model, stream: false }),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
throw new OllamaUnavailableError("Ollama is not reachable at the configured URL.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new OllamaUnavailableError(`Ollama pull failed with status ${response.status}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
model,
|
||||||
|
message: `${model} is available for offline use.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateOllamaJson<T>({ prompt, model }: GenerateJsonInput): Promise<T> {
|
||||||
|
const { baseUrl, model: configuredModel } = getOllamaConfig();
|
||||||
|
const selectedModel = model ?? configuredModel;
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
58
src/lib/pay-schedule.ts
Normal file
58
src/lib/pay-schedule.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import { db } from "@/lib/db";
|
||||||
|
|
||||||
|
export type PaySchedule = {
|
||||||
|
id: string;
|
||||||
|
amountCents: number;
|
||||||
|
anchorDate: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function getActiveSchedule(): Promise<PaySchedule | null> {
|
||||||
|
const schedule = await db.paySchedule.findFirst({ where: { active: true }, orderBy: { createdAt: "desc" } });
|
||||||
|
return schedule ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveSchedule(amountCents: number, anchorDate: string): Promise<PaySchedule> {
|
||||||
|
await db.paySchedule.updateMany({ where: { active: true }, data: { active: false } });
|
||||||
|
return db.paySchedule.create({ data: { amountCents, anchorDate, active: true } });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearSchedule(): Promise<void> {
|
||||||
|
await db.paySchedule.updateMany({ where: { active: true }, data: { active: false } });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns all biweekly pay dates falling within the given month (YYYY-MM),
|
||||||
|
* anchored to a known pay date.
|
||||||
|
*/
|
||||||
|
export function getProjectedPayDates(anchorDate: string, month: string): string[] {
|
||||||
|
const [anchorYear, anchorMonth, anchorDay] = anchorDate.split("-").map(Number);
|
||||||
|
const [year, mon] = month.split("-").map(Number);
|
||||||
|
|
||||||
|
// Use UTC-based dates throughout to avoid DST day-shift bugs.
|
||||||
|
const anchorMs = Date.UTC(anchorYear, anchorMonth - 1, anchorDay);
|
||||||
|
const monthStartMs = Date.UTC(year, mon - 1, 1);
|
||||||
|
const monthEndMs = Date.UTC(year, mon, 0); // last day of month (day 0 of next month)
|
||||||
|
|
||||||
|
const MS_PER_DAY = 86_400_000;
|
||||||
|
const period = 14 * MS_PER_DAY;
|
||||||
|
|
||||||
|
// Find the first pay date on or after monthStart that is on the biweekly cycle.
|
||||||
|
const diff = monthStartMs - anchorMs;
|
||||||
|
const periods = Math.floor(diff / period);
|
||||||
|
let current = anchorMs + periods * period;
|
||||||
|
while (current < monthStartMs) {
|
||||||
|
current += period;
|
||||||
|
}
|
||||||
|
|
||||||
|
const results: string[] = [];
|
||||||
|
while (current <= monthEndMs) {
|
||||||
|
const d = new Date(current);
|
||||||
|
const y = d.getUTCFullYear();
|
||||||
|
const m = String(d.getUTCMonth() + 1).padStart(2, "0");
|
||||||
|
const day = String(d.getUTCDate()).padStart(2, "0");
|
||||||
|
results.push(`${y}-${m}-${day}`);
|
||||||
|
current += period;
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
13
src/lib/storage.test.ts
Normal file
13
src/lib/storage.test.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { resolveSqliteDatabasePath } from "@/lib/storage";
|
||||||
|
|
||||||
|
describe("resolveSqliteDatabasePath", () => {
|
||||||
|
it("resolves relative sqlite paths from the prisma directory", () => {
|
||||||
|
expect(resolveSqliteDatabasePath("file:./dev.db")).toContain("/prisma/dev.db");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves absolute sqlite paths", () => {
|
||||||
|
expect(resolveSqliteDatabasePath("file:/data/dev.db")).toBe("/data/dev.db");
|
||||||
|
});
|
||||||
|
});
|
||||||
20
src/lib/storage.ts
Normal file
20
src/lib/storage.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
export function resolveSqliteDatabasePath(databaseUrl = process.env.DATABASE_URL ?? "file:./dev.db") {
|
||||||
|
if (!databaseUrl.startsWith("file:")) {
|
||||||
|
throw new Error("Only SQLite file DATABASE_URL values are supported.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawPath = databaseUrl.slice("file:".length);
|
||||||
|
|
||||||
|
if (path.isAbsolute(rawPath)) {
|
||||||
|
return rawPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
return path.resolve(process.cwd(), "prisma", rawPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDatabaseBackupFileName() {
|
||||||
|
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||||
|
return `monthytracker-backup-${timestamp}.db`;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user