Compare commits
19 Commits
12c72ddcad
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| feb2f2c37e | |||
| c852ad0d80 | |||
| d2c230b4f9 | |||
| 8dcddbf278 | |||
| e3ac732b1b | |||
| 2dae000342 | |||
| 48b481999d | |||
| 012385e9e1 | |||
| 5f2111ea66 | |||
| 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
|
||||||
80
AGENT_HANDOFF.md
Normal file
80
AGENT_HANDOFF.md
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
# Monthy Tracker Agent Handoff
|
||||||
|
|
||||||
|
Use this file as the starting point for any new agent. It summarizes the repo state, the important files, and the non-obvious behavior so you can continue without re-discovering basics.
|
||||||
|
|
||||||
|
## What this app is
|
||||||
|
|
||||||
|
- Single-user, local-first monthly expense tracker.
|
||||||
|
- Stack: Next.js App Router, Prisma, SQLite, Ollama for offline AI.
|
||||||
|
- Main views: dashboard, add expense, income/paychecks.
|
||||||
|
- Goal: track expenses and paychecks, then generate private monthly summaries and recommendations.
|
||||||
|
|
||||||
|
## Current state
|
||||||
|
|
||||||
|
- Dashboard data is loaded from `/dashboard?month=YYYY-MM`.
|
||||||
|
- Monthly insight generation lives in `src/lib/insights.ts`.
|
||||||
|
- The app is currently working with the local dev DB at `file:./prisma/dev.db`.
|
||||||
|
- Seed data exists for February 2026 and March 2026.
|
||||||
|
- The frontend shows the current month by default, so if data looks missing, check the active month selector first.
|
||||||
|
|
||||||
|
## Important files
|
||||||
|
|
||||||
|
- `src/components/home-dashboard.tsx` - dashboard UI, fetches dashboard and Ollama status.
|
||||||
|
- `src/lib/dashboard.ts` - aggregates totals, breakdowns, recent expenses, and comparisons.
|
||||||
|
- `src/lib/insights.ts` - builds the model prompt, calls Ollama, stores fallback/model insights.
|
||||||
|
- `src/app/dashboard/route.ts` - dashboard API route.
|
||||||
|
- `src/app/backup/database/route.ts` - SQLite backup endpoint.
|
||||||
|
- `src/lib/date.ts` - month/date helpers; month filtering uses local date strings.
|
||||||
|
- `prisma/schema.prisma` - data model source of truth.
|
||||||
|
- `prisma/seed.ts` - local seed script.
|
||||||
|
- `README.md` - setup and runtime notes.
|
||||||
|
- `openspec/changes/monthly-expense-tracker-v1/` - design/spec/task source for the product.
|
||||||
|
|
||||||
|
## Key behavior to know
|
||||||
|
|
||||||
|
- Dashboard aggregates all records, then filters them to the selected month in memory.
|
||||||
|
- `getCurrentMonthKey()` uses local machine time.
|
||||||
|
- `generateMonthlyInsight()` in `src/lib/insights.ts`:
|
||||||
|
- builds a structured prompt from dashboard totals, category breakdown, recent expenses, and chart points
|
||||||
|
- expects strict JSON with `summary` and `recommendations`
|
||||||
|
- falls back when activity is sparse or Ollama is unavailable
|
||||||
|
- stores the final result in `MonthlyInsight`
|
||||||
|
- The fallback message is intentionally safe and short when data is too sparse.
|
||||||
|
|
||||||
|
## Known gotchas
|
||||||
|
|
||||||
|
- If dashboard values appear missing, confirm the selected month matches the seeded data.
|
||||||
|
- If Docker backup fails, check `DATABASE_URL`; the backup route reads the SQLite path from the env var.
|
||||||
|
- Prisma migration ordering matters because migration folders are applied lexicographically.
|
||||||
|
- In Docker Compose on Linux, `extra_hosts: host-gateway` is needed for host Ollama access.
|
||||||
|
|
||||||
|
## Current enhancement target
|
||||||
|
|
||||||
|
- The next useful improvement is making the generated monthly summary more helpful and more structured.
|
||||||
|
- Best place to change it: `src/lib/insights.ts`.
|
||||||
|
- Likely changes:
|
||||||
|
- strengthen the prompt with clearer sections or required observations
|
||||||
|
- improve the fallback logic for sparse months
|
||||||
|
- optionally store more structured fields in `MonthlyInsight`
|
||||||
|
- update the dashboard card rendering in `src/components/home-dashboard.tsx` if the output shape changes
|
||||||
|
|
||||||
|
## Useful commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
npx prisma migrate deploy
|
||||||
|
npx prisma db seed
|
||||||
|
npm test
|
||||||
|
```
|
||||||
|
|
||||||
|
If you need to verify the dashboard API directly:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl "http://localhost:3000/dashboard?month=2026-03"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Recommended next step for another agent
|
||||||
|
|
||||||
|
1. Read `src/lib/insights.ts`, `src/lib/dashboard.ts`, and `prisma/schema.prisma`.
|
||||||
|
2. Decide whether to improve the insight prompt, the stored data shape, or the dashboard rendering.
|
||||||
|
3. Make the change, then verify with the dashboard API and a generated insight.
|
||||||
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 && rm -rf .next/node_modules && 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:
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { NextConfig } from "next";
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
/* config options here */
|
serverExternalPackages: ["@prisma/client"],
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -18,6 +18,13 @@ The system SHALL allow the user to list recorded expenses and delete a specific
|
|||||||
- **WHEN** the user requests expenses for the app
|
- **WHEN** the user requests expenses for the app
|
||||||
- **THEN** the system returns stored expenses in a stable order with their recorded fields
|
- **THEN** the system returns stored expenses in a stable order with their recorded fields
|
||||||
|
|
||||||
|
### Requirement: User can browse expense history by month
|
||||||
|
The system SHALL allow the user to select a `YYYY-MM` month when reviewing expense history and SHALL return the expenses recorded for that month.
|
||||||
|
|
||||||
|
#### Scenario: Prior month entries are visible
|
||||||
|
- **WHEN** the user selects February 2026 in the add-expense history view
|
||||||
|
- **THEN** the system shows the expenses recorded in February 2026 and exposes delete actions for deletable entries in that month
|
||||||
|
|
||||||
#### Scenario: Expense is deleted
|
#### Scenario: Expense is deleted
|
||||||
- **WHEN** the user deletes an existing expense
|
- **WHEN** the user deletes an existing expense
|
||||||
- **THEN** the system removes that expense and it no longer appears in future listings or aggregates
|
- **THEN** the system removes that expense and it no longer appears in future listings or aggregates
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-03-23
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
## Context
|
||||||
|
|
||||||
|
The project uses Next.js with Tailwind CSS v4 and a warm stone/amber palette. All styling is done via Tailwind utility classes directly in JSX. There are no separate CSS modules for components. The root layout owns the `<html>` and `<body>` tags, making it the natural place to manage the theme class and prevent flash-of-unstyled-content.
|
||||||
|
|
||||||
|
## Goals / Non-Goals
|
||||||
|
|
||||||
|
**Goals:**
|
||||||
|
- Implement class-based dark mode that works with Tailwind v4's `@custom-variant` API.
|
||||||
|
- Prevent theme flash on page load with an inline blocking script.
|
||||||
|
- Persist user preference in `localStorage` under the key `theme`.
|
||||||
|
- Fall back to system preference (`prefers-color-scheme`) when no preference is saved.
|
||||||
|
- Keep the dark palette warm and consistent with the existing amber/stone design language.
|
||||||
|
|
||||||
|
**Non-Goals:**
|
||||||
|
- Server-side theme rendering or cookies (local-only app, no SSR theme concerns beyond flash prevention).
|
||||||
|
- Per-page or per-component theme overrides.
|
||||||
|
- Animated theme transitions beyond the existing `transition-duration: 180ms` on interactive elements.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
### Use Tailwind v4 `@custom-variant` for class-based dark mode
|
||||||
|
- Rationale: Tailwind v4's default dark mode is media-query-based. Adding `@custom-variant dark (&:where(.dark, .dark *))` in `globals.css` enables the `dark:` prefix to respond to a class on any ancestor, which is the standard pattern for toggle-able dark mode.
|
||||||
|
- Alternative considered: Continue using media-query dark mode with no toggle. Rejected because users cannot override system preference.
|
||||||
|
|
||||||
|
### Store theme preference in `localStorage`
|
||||||
|
- Rationale: Simple, synchronous, requires no server or cookies. Reads correctly in the blocking inline script.
|
||||||
|
- Alternative considered: Cookies for SSR. Rejected because this app is local-first and has no meaningful SSR theme benefit.
|
||||||
|
|
||||||
|
### Inject an inline blocking script in `<head>` to set the `dark` class before first paint
|
||||||
|
- Rationale: Prevents the flash where the page briefly renders in light mode before React hydrates and reads `localStorage`. The script is small and runs synchronously.
|
||||||
|
- Alternative considered: Set the class in a React `useEffect`. Rejected because `useEffect` runs after paint, causing a visible flash.
|
||||||
|
|
||||||
|
### Warm deep-stone dark palette
|
||||||
|
- Rationale: The light theme is built on warm stone and amber tones. The dark theme mirrors this with deep stone backgrounds (`stone-950`, `stone-900`, `stone-800`) and dimmed amber/emerald accents, keeping the visual identity coherent.
|
||||||
|
- Alternative considered: Neutral dark greys. Rejected because they clash with the warm amber accents and feel disconnected from the brand.
|
||||||
|
|
||||||
|
## Risks / Trade-offs
|
||||||
|
|
||||||
|
- [Inline script adds a small amount of render-blocking HTML] → Acceptable; the script is under 200 bytes and only runs once per page load.
|
||||||
|
- [LocalStorage is unavailable in some privacy modes] → The script wraps the read in a try/catch and falls back to system preference.
|
||||||
|
- [Many components have hardcoded warm background strings like `bg-[#fffaf2]`] → These are replaced with equivalent Tailwind tokens plus `dark:` overrides so the mapping is explicit and maintainable.
|
||||||
|
|
||||||
|
## Migration Plan
|
||||||
|
|
||||||
|
1. Update `globals.css` with `@custom-variant dark` and dark-mode CSS variable overrides.
|
||||||
|
2. Create the `ThemeToggle` client component.
|
||||||
|
3. Update `layout.tsx` to add the blocking script and render `ThemeToggle` in the header.
|
||||||
|
4. Update `site-nav.tsx` with dark-mode nav styles.
|
||||||
|
5. Update `home-dashboard.tsx`, `expense-workspace.tsx`, `paycheck-workspace.tsx`, and `recurring-expense-manager.tsx` with `dark:` variants.
|
||||||
|
6. Update page-level header text that uses hardcoded colour classes.
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
- Should the toggle also expose a "System" option (three-way: Light / Dark / System) rather than a binary flip? Deferred to a follow-up; the initialisation script already handles system fallback on first visit.
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
## Why
|
||||||
|
|
||||||
|
The app currently ships with a warm light theme only. Adding a system-aware dark mode with a manual toggle removes eye strain in low-light conditions and meets baseline accessibility expectations for a personal finance tool used at any hour.
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
- Add a class-based dark mode variant using Tailwind v4 `@custom-variant` so all `dark:` utilities are controlled by a `dark` class on `<html>`.
|
||||||
|
- Add a `ThemeToggle` component that persists the user's preference in `localStorage` and initialises the correct class before first paint to prevent flash.
|
||||||
|
- Update the root layout to inject the inline initialisation script and render the toggle inside the existing header.
|
||||||
|
- Apply `dark:` variants to all UI components to produce a warm, deep-stone dark palette consistent with the existing amber/stone design language.
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
### New Capabilities
|
||||||
|
- `dark-mode`: Users can switch between light and dark themes with a toggle that persists across sessions. System preference is respected on first visit.
|
||||||
|
|
||||||
|
### Modified Capabilities
|
||||||
|
- `expense-tracking`: Expense workspace and recurring expense manager now render correctly in both themes.
|
||||||
|
- `paycheck-tracking`: Paycheck workspace now renders correctly in both themes.
|
||||||
|
- `monthly-dashboard`: Dashboard sections, insight cards, category bars, and stat cards now render correctly in both themes.
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
- Affected code: `globals.css`, `layout.tsx`, all components under `src/components/`, and page header text in `src/app/`.
|
||||||
|
- APIs: None.
|
||||||
|
- Dependencies: None — uses Tailwind v4 built-ins and the Web Storage API.
|
||||||
|
- Systems: No server-side changes required; theme state is fully client-side.
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: User can switch between light and dark themes
|
||||||
|
The system SHALL allow the user to toggle between light and dark themes with a persistent preference.
|
||||||
|
|
||||||
|
#### Scenario: User toggles dark mode on
|
||||||
|
- **WHEN** the user activates the theme toggle while the app is in light mode
|
||||||
|
- **THEN** the system applies the dark theme and saves the preference for future visits
|
||||||
|
|
||||||
|
#### Scenario: User toggles dark mode off
|
||||||
|
- **WHEN** the user activates the theme toggle while the app is in dark mode
|
||||||
|
- **THEN** the system applies the light theme and saves the preference for future visits
|
||||||
|
|
||||||
|
### Requirement: Theme preference respects system defaults on first visit
|
||||||
|
The system SHALL use the user's system color scheme preference when no saved preference exists.
|
||||||
|
|
||||||
|
#### Scenario: No stored preference uses system theme
|
||||||
|
- **WHEN** the user opens the app for the first time without a saved theme preference
|
||||||
|
- **THEN** the system applies dark mode when the operating system prefers dark color schemes and light mode otherwise
|
||||||
|
|
||||||
|
### Requirement: Theme selection loads without a flash of the wrong theme
|
||||||
|
The system SHALL initialize the theme before the first visible paint so the page does not briefly render in the wrong theme.
|
||||||
|
|
||||||
|
#### Scenario: Initial paint matches saved theme
|
||||||
|
- **WHEN** the app loads and a saved theme preference exists
|
||||||
|
- **THEN** the document theme is applied before the page content is visibly rendered
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Expense tracking UI renders correctly in both themes
|
||||||
|
The system SHALL render the expense tracking workspace, history list, forms, and item states with readable contrast in both light and dark themes.
|
||||||
|
|
||||||
|
#### Scenario: Expense workspace renders in dark mode
|
||||||
|
- **WHEN** the user opens the expense tracking view while dark mode is active
|
||||||
|
- **THEN** the form card, history card, inputs, actions, and expense rows use dark-compatible colors and remain readable
|
||||||
|
|
||||||
|
#### Scenario: Expense workspace renders in light mode
|
||||||
|
- **WHEN** the user opens the expense tracking view while light mode is active
|
||||||
|
- **THEN** the form card, history card, inputs, actions, and expense rows use light-compatible colors and remain readable
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Monthly dashboard UI renders correctly in both themes
|
||||||
|
The system SHALL render dashboard sections, insight cards, category bars, stat tiles, and empty states with readable contrast in both light and dark themes.
|
||||||
|
|
||||||
|
#### Scenario: Dashboard renders in dark mode
|
||||||
|
- **WHEN** the user opens the dashboard while dark mode is active
|
||||||
|
- **THEN** the summary cards, comparison cards, progress bars, and empty states use dark-compatible colors and remain readable
|
||||||
|
|
||||||
|
#### Scenario: Dashboard renders in light mode
|
||||||
|
- **WHEN** the user opens the dashboard while light mode is active
|
||||||
|
- **THEN** the summary cards, comparison cards, progress bars, and empty states use light-compatible colors and remain readable
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Paycheck tracking UI renders correctly in both themes
|
||||||
|
The system SHALL render the paycheck tracking workspace, schedule panel, form, and list items with readable contrast in both light and dark themes.
|
||||||
|
|
||||||
|
#### Scenario: Paycheck workspace renders in dark mode
|
||||||
|
- **WHEN** the user opens the paycheck tracking view while dark mode is active
|
||||||
|
- **THEN** the schedule panel, form card, inputs, and paycheck rows use dark-compatible colors and remain readable
|
||||||
|
|
||||||
|
#### Scenario: Paycheck workspace renders in light mode
|
||||||
|
- **WHEN** the user opens the paycheck tracking view while light mode is active
|
||||||
|
- **THEN** the schedule panel, form card, inputs, and paycheck rows use light-compatible colors and remain readable
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
## 1. CSS and variant setup
|
||||||
|
|
||||||
|
- [x] 1.1 Add `@custom-variant dark (&:where(.dark, .dark *))` to `globals.css` and update `:root` / `@theme` blocks with dark-mode CSS variable overrides.
|
||||||
|
|
||||||
|
## 2. Theme toggle infrastructure
|
||||||
|
|
||||||
|
- [x] 2.1 Create `src/components/theme-toggle.tsx` — a client component that reads and writes `localStorage` theme preference, toggles the `dark` class on `<html>`, and renders a sun/moon button.
|
||||||
|
- [x] 2.2 Update `src/app/layout.tsx` to inject the inline blocking script in `<head>` and render `ThemeToggle` in the header alongside `SiteNav`.
|
||||||
|
|
||||||
|
## 3. Component dark-mode styles
|
||||||
|
|
||||||
|
- [x] 3.1 Update `src/components/site-nav.tsx` with `dark:` variants for link backgrounds, borders, and text.
|
||||||
|
- [x] 3.2 Update `src/components/home-dashboard.tsx` with `dark:` variants for all section cards, stat tiles, insight blocks, progress bars, and empty states.
|
||||||
|
- [x] 3.3 Update `src/components/expense-workspace.tsx` with `dark:` variants for the form card, list card, inputs, and expense articles.
|
||||||
|
- [x] 3.4 Update `src/components/paycheck-workspace.tsx` with `dark:` variants for the schedule panel, form, and paycheck list.
|
||||||
|
- [x] 3.5 Update `src/components/recurring-expense-manager.tsx` with `dark:` variants for the panel, inline form, and definition articles.
|
||||||
|
|
||||||
|
## 4. Page header text
|
||||||
|
|
||||||
|
- [x] 4.1 Update hardcoded text colours in `src/app/add-expense/page.tsx` and `src/app/income/page.tsx` with `dark:` overrides.
|
||||||
|
|
||||||
|
## 5. Verification
|
||||||
|
|
||||||
|
- [x] 5.1 Visually verify light and dark modes across the dashboard, add-expense, and income pages in the browser.
|
||||||
|
- [x] 5.2 Verify that theme preference persists across page refreshes and that there is no flash of the wrong theme on load.
|
||||||
27
openspec/specs/category-suggestion/spec.md
Normal file
27
openspec/specs/category-suggestion/spec.md
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
## Purpose
|
||||||
|
|
||||||
|
TBD
|
||||||
|
|
||||||
|
## 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
|
||||||
30
openspec/specs/dark-mode/spec.md
Normal file
30
openspec/specs/dark-mode/spec.md
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
## Purpose
|
||||||
|
|
||||||
|
TBD
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
### Requirement: User can switch between light and dark themes
|
||||||
|
The system SHALL allow the user to toggle between light and dark themes with a persistent preference.
|
||||||
|
|
||||||
|
#### Scenario: User toggles dark mode on
|
||||||
|
- **WHEN** the user activates the theme toggle while the app is in light mode
|
||||||
|
- **THEN** the system applies the dark theme and saves the preference for future visits
|
||||||
|
|
||||||
|
#### Scenario: User toggles dark mode off
|
||||||
|
- **WHEN** the user activates the theme toggle while the app is in dark mode
|
||||||
|
- **THEN** the system applies the light theme and saves the preference for future visits
|
||||||
|
|
||||||
|
### Requirement: Theme preference respects system defaults on first visit
|
||||||
|
The system SHALL use the user's system color scheme preference when no saved preference exists.
|
||||||
|
|
||||||
|
#### Scenario: No stored preference uses system theme
|
||||||
|
- **WHEN** the user opens the app for the first time without a saved theme preference
|
||||||
|
- **THEN** the system applies dark mode when the operating system prefers dark color schemes and light mode otherwise
|
||||||
|
|
||||||
|
### Requirement: Theme selection loads without a flash of the wrong theme
|
||||||
|
The system SHALL initialize the theme before the first visible paint so the page does not briefly render in the wrong theme.
|
||||||
|
|
||||||
|
#### Scenario: Initial paint matches saved theme
|
||||||
|
- **WHEN** the app loads and a saved theme preference exists
|
||||||
|
- **THEN** the document theme is applied before the page content is visibly rendered
|
||||||
47
openspec/specs/expense-tracking/spec.md
Normal file
47
openspec/specs/expense-tracking/spec.md
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
## Purpose
|
||||||
|
|
||||||
|
TBD
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
### Requirement: User can record categorized expenses
|
||||||
|
The system SHALL allow the user to create an expense with a title, amount, category, and local calendar date using fixed starter categories.
|
||||||
|
|
||||||
|
#### Scenario: Valid expense is created
|
||||||
|
- **WHEN** the user submits a title, positive amount, valid category, and valid local date
|
||||||
|
- **THEN** the system stores the expense and returns the created record
|
||||||
|
|
||||||
|
#### Scenario: Invalid expense is rejected
|
||||||
|
- **WHEN** the user submits a missing title, invalid amount, invalid category, or invalid date
|
||||||
|
- **THEN** the system rejects the request with a validation error and does not store the expense
|
||||||
|
|
||||||
|
### Requirement: User can review and delete expenses
|
||||||
|
The system SHALL allow the user to list recorded expenses and delete a specific expense by identifier.
|
||||||
|
|
||||||
|
#### Scenario: Expenses are listed
|
||||||
|
- **WHEN** the user requests expenses for the app
|
||||||
|
- **THEN** the system returns stored expenses in a stable order with their recorded fields
|
||||||
|
|
||||||
|
### Requirement: User can browse expense history by month
|
||||||
|
The system SHALL allow the user to select a `YYYY-MM` month when reviewing expense history and SHALL return the expenses recorded for that month.
|
||||||
|
|
||||||
|
#### Scenario: Prior month entries are visible
|
||||||
|
- **WHEN** the user selects February 2026 in the add-expense history view
|
||||||
|
- **THEN** the system shows the expenses recorded in February 2026 and exposes delete actions for deletable entries in that month
|
||||||
|
|
||||||
|
#### Scenario: Expense is deleted
|
||||||
|
- **WHEN** the user deletes an existing expense
|
||||||
|
- **THEN** the system removes that expense and it no longer appears in future listings or aggregates
|
||||||
|
|
||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Expense tracking UI renders correctly in both themes
|
||||||
|
The system SHALL render the expense tracking workspace, history list, forms, and item states with readable contrast in both light and dark themes.
|
||||||
|
|
||||||
|
#### Scenario: Expense workspace renders in dark mode
|
||||||
|
- **WHEN** the user opens the expense tracking view while dark mode is active
|
||||||
|
- **THEN** the form card, history card, inputs, actions, and expense rows use dark-compatible colors and remain readable
|
||||||
|
|
||||||
|
#### Scenario: Expense workspace renders in light mode
|
||||||
|
- **WHEN** the user opens the expense tracking view while light mode is active
|
||||||
|
- **THEN** the form card, history card, inputs, actions, and expense rows use light-compatible colors and remain readable
|
||||||
40
openspec/specs/monthly-dashboard/spec.md
Normal file
40
openspec/specs/monthly-dashboard/spec.md
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
## Purpose
|
||||||
|
|
||||||
|
TBD
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
### Requirement: Dashboard shows month-specific financial totals
|
||||||
|
The system SHALL return month-specific dashboard data for a requested `YYYY-MM` month using the local machine timezone for month boundaries.
|
||||||
|
|
||||||
|
#### Scenario: Dashboard totals are calculated for a populated month
|
||||||
|
- **WHEN** the user requests the dashboard for a month with expenses and paychecks
|
||||||
|
- **THEN** the system returns total expenses, total paychecks, net cash flow, and a category breakdown for that month
|
||||||
|
|
||||||
|
#### Scenario: Dashboard supports partial current-month data
|
||||||
|
- **WHEN** the user requests the dashboard for the current month before the month is complete
|
||||||
|
- **THEN** the system returns meaningful month-to-date totals and comparisons using the transactions recorded so far
|
||||||
|
|
||||||
|
### Requirement: Dashboard includes derived spending comparisons
|
||||||
|
The system SHALL provide derived comparisons for the selected month, including highest category, largest expense, average daily spend, and paycheck coverage information.
|
||||||
|
|
||||||
|
#### Scenario: Derived comparisons are available
|
||||||
|
- **WHEN** the selected month contains enough data for comparisons
|
||||||
|
- **THEN** the system returns the highest category, largest single expense, average daily spend, and spend-versus-paycheck coverage values
|
||||||
|
|
||||||
|
#### Scenario: Derived comparisons degrade safely for sparse data
|
||||||
|
- **WHEN** the selected month has no expenses or otherwise insufficient data for a comparison
|
||||||
|
- **THEN** the system returns null or empty-safe comparison fields instead of failing
|
||||||
|
|
||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Monthly dashboard UI renders correctly in both themes
|
||||||
|
The system SHALL render dashboard sections, insight cards, category bars, stat tiles, and empty states with readable contrast in both light and dark themes.
|
||||||
|
|
||||||
|
#### Scenario: Dashboard renders in dark mode
|
||||||
|
- **WHEN** the user opens the dashboard while dark mode is active
|
||||||
|
- **THEN** the summary cards, comparison cards, progress bars, and empty states use dark-compatible colors and remain readable
|
||||||
|
|
||||||
|
#### Scenario: Dashboard renders in light mode
|
||||||
|
- **WHEN** the user opens the dashboard while light mode is active
|
||||||
|
- **THEN** the summary cards, comparison cards, progress bars, and empty states use light-compatible colors and remain readable
|
||||||
34
openspec/specs/monthly-insights/spec.md
Normal file
34
openspec/specs/monthly-insights/spec.md
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
## Purpose
|
||||||
|
|
||||||
|
TBD
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
### Requirement: User can generate monthly AI insights on demand
|
||||||
|
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
|
||||||
|
- **WHEN** the user requests insight generation for a month with recorded activity
|
||||||
|
- **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
|
||||||
|
- **WHEN** the user requests insight generation for a previous month that has recorded data
|
||||||
|
- **THEN** the system generates and stores insight output for that requested month
|
||||||
|
|
||||||
|
### Requirement: Insight generation is read-only and safe for sparse months
|
||||||
|
The system SHALL keep AI insight generation read-only and return a safe fallback summary when a month does not have enough data for meaningful guidance.
|
||||||
|
|
||||||
|
#### Scenario: Sparse month returns fallback insight
|
||||||
|
- **WHEN** the user requests insight generation for a month with empty or near-empty data
|
||||||
|
- **THEN** the system returns a fallback message instead of low-confidence advice
|
||||||
|
|
||||||
|
#### Scenario: AI does not mutate financial records
|
||||||
|
- **WHEN** the system generates or stores monthly insights
|
||||||
|
- **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
|
||||||
40
openspec/specs/paycheck-tracking/spec.md
Normal file
40
openspec/specs/paycheck-tracking/spec.md
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
## Purpose
|
||||||
|
|
||||||
|
TBD
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
### Requirement: User can record paychecks by pay date
|
||||||
|
The system SHALL allow the user to create a paycheck with a positive amount and a local pay date.
|
||||||
|
|
||||||
|
#### Scenario: Valid paycheck is created
|
||||||
|
- **WHEN** the user submits a positive amount and valid local pay date
|
||||||
|
- **THEN** the system stores the paycheck and returns the created record
|
||||||
|
|
||||||
|
#### Scenario: Invalid paycheck is rejected
|
||||||
|
- **WHEN** the user submits a missing or invalid amount or date
|
||||||
|
- **THEN** the system rejects the request with a validation error and does not store the paycheck
|
||||||
|
|
||||||
|
### Requirement: User can review and delete paychecks
|
||||||
|
The system SHALL allow the user to list recorded paychecks and delete a specific paycheck by identifier.
|
||||||
|
|
||||||
|
#### Scenario: Paychecks are listed
|
||||||
|
- **WHEN** the user requests paychecks for the app
|
||||||
|
- **THEN** the system returns stored paychecks in a stable order with their recorded fields
|
||||||
|
|
||||||
|
#### Scenario: Paycheck is deleted
|
||||||
|
- **WHEN** the user deletes an existing paycheck
|
||||||
|
- **THEN** the system removes that paycheck and it no longer appears in future dashboard totals or insight inputs
|
||||||
|
|
||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Paycheck tracking UI renders correctly in both themes
|
||||||
|
The system SHALL render the paycheck tracking workspace, schedule panel, form, and list items with readable contrast in both light and dark themes.
|
||||||
|
|
||||||
|
#### Scenario: Paycheck workspace renders in dark mode
|
||||||
|
- **WHEN** the user opens the paycheck tracking view while dark mode is active
|
||||||
|
- **THEN** the schedule panel, form card, inputs, and paycheck rows use dark-compatible colors and remain readable
|
||||||
|
|
||||||
|
#### Scenario: Paycheck workspace renders in light mode
|
||||||
|
- **WHEN** the user opens the paycheck tracking view while light mode is active
|
||||||
|
- **THEN** the schedule panel, form card, inputs, and paycheck rows use light-compatible colors and remain readable
|
||||||
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");
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "RecurringExpense" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"title" TEXT NOT NULL,
|
||||||
|
"amountCents" INTEGER NOT NULL,
|
||||||
|
"category" TEXT NOT NULL,
|
||||||
|
"dayOfMonth" INTEGER NOT NULL,
|
||||||
|
"active" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" DATETIME NOT NULL
|
||||||
|
);
|
||||||
@@ -34,9 +34,36 @@ 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 RecurringExpense {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
title String
|
||||||
|
amountCents Int
|
||||||
|
category Category
|
||||||
|
dayOfMonth Int // 1–28, capped so it is valid in every month including February
|
||||||
|
active Boolean @default(true)
|
||||||
|
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())
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { ExpenseWorkspace } from "@/components/expense-workspace";
|
import { ExpenseWorkspace } from "@/components/expense-workspace";
|
||||||
|
import { RecurringExpenseManager } from "@/components/recurring-expense-manager";
|
||||||
import { CATEGORY_OPTIONS } from "@/lib/categories";
|
import { CATEGORY_OPTIONS } from "@/lib/categories";
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
@@ -9,13 +10,15 @@ export default function AddExpensePage() {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
<header className="max-w-2xl space-y-3">
|
<header className="max-w-2xl space-y-3">
|
||||||
<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 dark:text-amber-500">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 dark:text-white">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 dark:text-stone-400">
|
||||||
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>
|
||||||
|
|
||||||
|
<RecurringExpenseManager categoryOptions={CATEGORY_OPTIONS.map((option) => ({ ...option }))} />
|
||||||
|
|
||||||
<ExpenseWorkspace categoryOptions={CATEGORY_OPTIONS.map((option) => ({ ...option }))} />
|
<ExpenseWorkspace categoryOptions={CATEGORY_OPTIONS.map((option) => ({ ...option }))} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
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,12 @@ 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);
|
||||||
|
const msg = error instanceof Error ? error.message : String(error);
|
||||||
|
return NextResponse.json({ error: "Could not load the dashboard.", detail: msg }, { 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;
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,32 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
import { isDateInMonth, isValidMonthKey } from "@/lib/date";
|
||||||
import { createExpense, listExpenses } from "@/lib/expenses";
|
import { createExpense, listExpenses } from "@/lib/expenses";
|
||||||
|
import { getProjectedRecurringExpenses, listActiveRecurringExpenses } from "@/lib/recurring-expenses";
|
||||||
import { expenseInputSchema } from "@/lib/validation";
|
import { expenseInputSchema } from "@/lib/validation";
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET(request: Request) {
|
||||||
const expenses = await listExpenses();
|
const url = new URL(request.url);
|
||||||
return NextResponse.json({ expenses });
|
const rawMonth = url.searchParams.get("month");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const expenses = await listExpenses();
|
||||||
|
|
||||||
|
if (rawMonth && isValidMonthKey(rawMonth)) {
|
||||||
|
const definitions = await listActiveRecurringExpenses();
|
||||||
|
const projected = getProjectedRecurringExpenses(definitions, rawMonth);
|
||||||
|
const monthReal = expenses
|
||||||
|
.filter((e) => isDateInMonth(e.date, rawMonth))
|
||||||
|
.map((e) => ({ ...e, isRecurring: false as const }));
|
||||||
|
const all = [...monthReal, ...projected].sort((a, b) => b.date.localeCompare(a.date));
|
||||||
|
return NextResponse.json({ expenses: all });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ expenses });
|
||||||
|
} catch (error) {
|
||||||
|
const msg = error instanceof Error ? error.message : String(error);
|
||||||
|
return NextResponse.json({ error: "Could not load expenses.", detail: msg }, { status: 500 });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
|
|||||||
@@ -1,10 +1,17 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
@custom-variant dark (&:where(.dark, .dark *));
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--background: #ffffff;
|
--background: #ffffff;
|
||||||
--foreground: #171717;
|
--foreground: #171717;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
--background: #1a1714;
|
||||||
|
--foreground: #f5f0eb;
|
||||||
|
}
|
||||||
|
|
||||||
@theme inline {
|
@theme inline {
|
||||||
--color-background: #fbfaf7;
|
--color-background: #fbfaf7;
|
||||||
--color-foreground: #1c1917;
|
--color-foreground: #1c1917;
|
||||||
|
|||||||
@@ -8,9 +8,9 @@ export default function IncomePage() {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
<header className="max-w-2xl space-y-3">
|
<header className="max-w-2xl space-y-3">
|
||||||
<p className="text-sm font-semibold uppercase tracking-[0.28em] text-emerald-700">Income & Paychecks</p>
|
<p className="text-sm font-semibold uppercase tracking-[0.28em] text-emerald-700 dark:text-emerald-400">Income & Paychecks</p>
|
||||||
<h1 className="text-4xl font-semibold text-stone-950">Capture income on real pay dates, not rough monthly averages.</h1>
|
<h1 className="text-4xl font-semibold text-stone-950 dark:text-white">Capture income on real pay dates, not rough monthly averages.</h1>
|
||||||
<p className="text-lg leading-8 text-stone-600">
|
<p className="text-lg leading-8 text-stone-600 dark:text-stone-400">
|
||||||
This slice tracks each paycheck as a distinct event so later dashboard and AI guidance can reason about cash timing accurately.
|
This slice tracks each paycheck as a distinct event so later dashboard and AI guidance can reason about cash timing accurately.
|
||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
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);
|
||||||
|
}
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { Fraunces, Manrope } from "next/font/google";
|
import { Fraunces, Manrope } from "next/font/google";
|
||||||
|
import Script from "next/script";
|
||||||
|
|
||||||
import { SiteNav } from "@/components/site-nav";
|
import { SiteNav } from "@/components/site-nav";
|
||||||
|
import { ThemeToggle } from "@/components/theme-toggle";
|
||||||
|
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
|
||||||
@@ -20,6 +22,18 @@ export const metadata: Metadata = {
|
|||||||
description: "Local-first monthly expense tracking with AI insights.",
|
description: "Local-first monthly expense tracking with AI insights.",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const themeScript = `
|
||||||
|
(function() {
|
||||||
|
try {
|
||||||
|
var saved = localStorage.getItem('theme');
|
||||||
|
var prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||||
|
if (saved === 'dark' || (!saved && prefersDark)) {
|
||||||
|
document.documentElement.classList.add('dark');
|
||||||
|
}
|
||||||
|
} catch(e) {}
|
||||||
|
})();
|
||||||
|
`;
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
children,
|
children,
|
||||||
}: Readonly<{
|
}: Readonly<{
|
||||||
@@ -29,15 +43,22 @@ export default function RootLayout({
|
|||||||
<html
|
<html
|
||||||
lang="en"
|
lang="en"
|
||||||
className={`${headingFont.variable} ${bodyFont.variable} h-full antialiased`}
|
className={`${headingFont.variable} ${bodyFont.variable} h-full antialiased`}
|
||||||
|
suppressHydrationWarning
|
||||||
>
|
>
|
||||||
<body className="min-h-full bg-[linear-gradient(180deg,#f8f3ea_0%,#f5efe4_28%,#fbfaf7_100%)] text-stone-950">
|
<body className="min-h-full bg-[linear-gradient(180deg,#f8f3ea_0%,#f5efe4_28%,#fbfaf7_100%)] text-stone-950 dark:bg-[linear-gradient(180deg,#1a1714_0%,#1c1a17_28%,#1e1c19_100%)] dark:text-stone-100">
|
||||||
|
<Script id="theme-script" strategy="beforeInteractive">
|
||||||
|
{themeScript}
|
||||||
|
</Script>
|
||||||
<div className="mx-auto flex min-h-full w-full max-w-7xl flex-col px-4 py-6 sm:px-6 lg:px-8">
|
<div className="mx-auto flex min-h-full w-full max-w-7xl flex-col px-4 py-6 sm:px-6 lg:px-8">
|
||||||
<header className="mb-10 flex flex-col gap-4 rounded-[2rem] border border-white/70 bg-white/80 px-6 py-5 shadow-[0_20px_50px_rgba(120,90,50,0.08)] backdrop-blur sm:flex-row sm:items-center sm:justify-between">
|
<header className="mb-10 flex flex-col gap-4 rounded-[2rem] border border-white/70 bg-white/80 px-6 py-5 shadow-[0_20px_50px_rgba(120,90,50,0.08)] backdrop-blur sm:flex-row sm:items-center sm:justify-between dark:border-stone-700/60 dark:bg-stone-900/80">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.28em] text-amber-700">Monthy Tracker</p>
|
<p className="text-xs font-semibold uppercase tracking-[0.28em] text-amber-700 dark:text-amber-500">Monthy Tracker</p>
|
||||||
<p className="mt-2 text-lg text-stone-600">Track the month as it unfolds, not after it slips away.</p>
|
<p className="mt-2 text-lg text-stone-600 dark:text-stone-400">Track the month as it unfolds, not after it slips away.</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<SiteNav />
|
||||||
|
<ThemeToggle />
|
||||||
</div>
|
</div>
|
||||||
<SiteNav />
|
|
||||||
</header>
|
</header>
|
||||||
<main className="flex-1 pb-10">{children}</main>
|
<main className="flex-1 pb-10">{children}</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
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);
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { HomeDashboard } from "@/components/home-dashboard";
|
import { HomeDashboard } from "@/components/home-dashboard";
|
||||||
|
import { getCurrentMonthKey } from "@/lib/date";
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
return <HomeDashboard />;
|
return <HomeDashboard initialMonth={getCurrentMonthKey()} />;
|
||||||
}
|
}
|
||||||
|
|||||||
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 });
|
||||||
|
}
|
||||||
51
src/app/recurring-expenses/[id]/route.ts
Normal file
51
src/app/recurring-expenses/[id]/route.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { Prisma } from "@prisma/client";
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
import { deactivateRecurringExpense, updateRecurringExpense } from "@/lib/recurring-expenses";
|
||||||
|
import { recurringExpenseInputSchema } from "@/lib/validation";
|
||||||
|
|
||||||
|
type RouteContext = {
|
||||||
|
params: Promise<{ id: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function PATCH(request: Request, context: RouteContext) {
|
||||||
|
const { id } = await context.params;
|
||||||
|
const payload = await request.json();
|
||||||
|
const parsed = recurringExpenseInputSchema.safeParse(payload);
|
||||||
|
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: parsed.error.issues[0]?.message ?? "Invalid recurring expense payload." },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const recurringExpense = await updateRecurringExpense(id, {
|
||||||
|
title: parsed.data.title,
|
||||||
|
amountCents: parsed.data.amount,
|
||||||
|
dayOfMonth: parsed.data.dayOfMonth,
|
||||||
|
category: parsed.data.category,
|
||||||
|
});
|
||||||
|
return NextResponse.json({ recurringExpense });
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2025") {
|
||||||
|
return NextResponse.json({ error: "Recurring expense not found." }, { status: 404 });
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(_: Request, context: RouteContext) {
|
||||||
|
const { id } = await context.params;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await deactivateRecurringExpense(id);
|
||||||
|
return new NextResponse(null, { status: 204 });
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2025") {
|
||||||
|
return NextResponse.json({ error: "Recurring expense not found." }, { status: 404 });
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
30
src/app/recurring-expenses/route.ts
Normal file
30
src/app/recurring-expenses/route.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
import { createRecurringExpense, listActiveRecurringExpenses } from "@/lib/recurring-expenses";
|
||||||
|
import { recurringExpenseInputSchema } from "@/lib/validation";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const recurringExpenses = await listActiveRecurringExpenses();
|
||||||
|
return NextResponse.json({ recurringExpenses });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const payload = await request.json();
|
||||||
|
const parsed = recurringExpenseInputSchema.safeParse(payload);
|
||||||
|
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: parsed.error.issues[0]?.message ?? "Invalid recurring expense payload." },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const recurringExpense = await createRecurringExpense({
|
||||||
|
title: parsed.data.title,
|
||||||
|
amountCents: parsed.data.amount,
|
||||||
|
dayOfMonth: parsed.data.dayOfMonth,
|
||||||
|
category: parsed.data.category,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ recurringExpense }, { status: 201 });
|
||||||
|
}
|
||||||
@@ -1,16 +1,26 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useMemo, useState, type FormEvent } from "react";
|
import { useCallback, useEffect, useMemo, useState, type FormEvent } from "react";
|
||||||
|
|
||||||
import { getCategoryLabel, type CategoryValue } from "@/lib/categories";
|
import { getCategoryLabel, type CategoryValue } from "@/lib/categories";
|
||||||
|
import { getCurrentMonthKey, getLocalToday, getMonthLabel } from "@/lib/date";
|
||||||
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;
|
||||||
amountCents: number;
|
amountCents: number;
|
||||||
date: string;
|
date: string;
|
||||||
category: CategoryValue;
|
category: CategoryValue;
|
||||||
|
isRecurring?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type CategoryOption = {
|
type CategoryOption = {
|
||||||
@@ -24,37 +34,148 @@ 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 [selectedMonth, setSelectedMonth] = 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: "",
|
||||||
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);
|
||||||
|
|
||||||
|
const loadExpenses = useCallback(async (month: string) => {
|
||||||
|
const response = await fetch(`/expenses?month=${month}`, { cache: "no-store" });
|
||||||
|
const payload = (await response.json().catch(() => null)) as { expenses?: ExpenseRecord[] } | null;
|
||||||
|
setExpenses(payload?.expenses ?? []);
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function loadExpenses() {
|
const timeoutId = window.setTimeout(() => {
|
||||||
const response = await fetch("/expenses", { cache: "no-store" });
|
setSelectedMonth(getCurrentMonthKey());
|
||||||
const payload = (await response.json()) as { expenses?: ExpenseRecord[] };
|
setFormState((current) => (current.date ? current : { ...current, date: getLocalToday() }));
|
||||||
setExpenses(payload.expenses ?? []);
|
}, 0);
|
||||||
|
|
||||||
|
return () => window.clearTimeout(timeoutId);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedMonth) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
void loadExpenses();
|
const timeoutId = window.setTimeout(() => {
|
||||||
}, []);
|
void loadExpenses(selectedMonth);
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
return () => window.clearTimeout(timeoutId);
|
||||||
|
}, [loadExpenses, selectedMonth]);
|
||||||
|
|
||||||
const totalSpent = useMemo(
|
const totalSpent = useMemo(
|
||||||
() => expenses.reduce((sum, expense) => sum + expense.amountCents, 0),
|
() => expenses.reduce((sum, expense) => sum + expense.amountCents, 0),
|
||||||
[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: getLocalToday(),
|
||||||
|
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 +184,30 @@ 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]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedMonth) {
|
||||||
|
await loadExpenses(selectedMonth);
|
||||||
|
}
|
||||||
|
|
||||||
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) {
|
||||||
@@ -86,64 +223,83 @@ export function ExpenseWorkspace({ categoryOptions }: Props) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (selectedMonth) {
|
||||||
|
await loadExpenses(selectedMonth);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setExpenses((current) => current.filter((expense) => expense.id !== id));
|
setExpenses((current) => current.filter((expense) => expense.id !== id));
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid gap-6 lg:grid-cols-[1.1fr_0.9fr]">
|
<div className="grid gap-6 lg:grid-cols-[1.1fr_0.9fr]">
|
||||||
<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)] dark:border-stone-700 dark:bg-stone-900">
|
||||||
<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 dark:text-amber-500">
|
||||||
<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 dark:text-white">
|
||||||
|
{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 dark:bg-amber-900/20">
|
||||||
<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 dark:text-amber-400">Current list total</p>
|
||||||
<p className="mt-1 text-2xl font-semibold text-stone-950">{formatCurrencyFromCents(totalSpent)}</p>
|
<p className="mt-1 text-2xl font-semibold text-stone-950 dark:text-white">{formatCurrencyFromCents(totalSpent)}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form className="grid gap-4 md:grid-cols-2" onSubmit={handleSubmit}>
|
<form className="grid gap-4 md:grid-cols-2" onSubmit={handleSubmit}>
|
||||||
<label className="grid gap-2 text-sm font-medium text-stone-700 md:col-span-2">
|
<label className="grid gap-2 text-sm font-medium text-stone-700 dark:text-stone-300 md:col-span-2">
|
||||||
Title
|
Title
|
||||||
<input
|
<input
|
||||||
required
|
required
|
||||||
value={formState.title}
|
value={formState.title}
|
||||||
onChange={(event) => setFormState((current) => ({ ...current, title: event.target.value }))}
|
onBlur={() => void handleMerchantSuggestion()}
|
||||||
className="rounded-2xl border border-stone-300 bg-stone-50 px-4 py-3 outline-none transition focus:border-stone-900"
|
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 dark:border-stone-600 dark:bg-stone-800 dark:text-stone-200 dark:focus:border-stone-400"
|
||||||
placeholder="Groceries, rent, train pass..."
|
placeholder="Groceries, rent, train pass..."
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label className="grid gap-2 text-sm font-medium text-stone-700">
|
<label className="grid gap-2 text-sm font-medium text-stone-700 dark:text-stone-300">
|
||||||
Amount
|
Amount
|
||||||
<input
|
<input
|
||||||
required
|
required
|
||||||
inputMode="decimal"
|
inputMode="decimal"
|
||||||
value={formState.amount}
|
value={formState.amount}
|
||||||
onChange={(event) => setFormState((current) => ({ ...current, amount: event.target.value }))}
|
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"
|
className="rounded-2xl border border-stone-300 bg-stone-50 px-4 py-3 outline-none transition focus:border-stone-900 dark:border-stone-600 dark:bg-stone-800 dark:text-stone-200 dark:focus:border-stone-400"
|
||||||
placeholder="42.50"
|
placeholder="42.50"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label className="grid gap-2 text-sm font-medium text-stone-700">
|
<label className="grid gap-2 text-sm font-medium text-stone-700 dark:text-stone-300">
|
||||||
Date
|
Date
|
||||||
<input
|
<input
|
||||||
required
|
required
|
||||||
type="date"
|
type="date"
|
||||||
value={formState.date}
|
value={formState.date}
|
||||||
onChange={(event) => setFormState((current) => ({ ...current, date: event.target.value }))}
|
onChange={(event) => setFormState((current) => ({ ...current, date: event.target.value }))}
|
||||||
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 dark:border-stone-600 dark:bg-stone-800 dark:text-stone-200 dark:focus:border-stone-400"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label className="grid gap-2 text-sm font-medium text-stone-700 md:col-span-2">
|
<label className="grid gap-2 text-sm font-medium text-stone-700 dark:text-stone-300 md:col-span-2">
|
||||||
Category
|
Category
|
||||||
<select
|
<select
|
||||||
value={formState.category}
|
value={formState.category}
|
||||||
onChange={(event) => setFormState((current) => ({ ...current, category: event.target.value }))}
|
onChange={(event) => {
|
||||||
className="rounded-2xl border border-stone-300 bg-stone-50 px-4 py-3 outline-none transition focus:border-stone-900"
|
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 dark:border-stone-600 dark:bg-stone-800 dark:text-stone-200 dark:focus:border-stone-400"
|
||||||
>
|
>
|
||||||
{categoryOptions.map((option) => (
|
{categoryOptions.map((option) => (
|
||||||
<option key={option.value} value={option.value}>
|
<option key={option.value} value={option.value}>
|
||||||
@@ -153,51 +309,109 @@ 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 dark:border-stone-700 dark:bg-stone-800 dark:text-stone-400">
|
||||||
|
<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 dark:border-stone-600 dark:text-stone-300 dark:hover:border-stone-300"
|
||||||
|
>
|
||||||
|
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 dark:border-stone-600 dark:text-stone-300 dark:hover:border-stone-300"
|
||||||
</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 dark:bg-stone-100 dark:text-stone-900 dark:hover:bg-white dark:disabled:bg-stone-700 dark:disabled:text-stone-500"
|
||||||
|
>
|
||||||
|
{busy ? (editingId ? "Updating..." : "Saving...") : editingId ? "Update expense" : "Save expense"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="rounded-[2rem] border border-stone-200 bg-[#fffaf2] p-6 shadow-[0_24px_60px_rgba(120,90,50,0.08)]">
|
<section className="rounded-[2rem] border border-stone-200 bg-[#fffaf2] p-6 shadow-[0_24px_60px_rgba(120,90,50,0.08)] dark:border-stone-700 dark:bg-stone-900/60">
|
||||||
<div className="mb-5">
|
<div className="mb-5 flex flex-wrap items-end justify-between gap-4">
|
||||||
<p className="text-sm font-semibold uppercase tracking-[0.24em] text-stone-500">Recent entries</p>
|
<div>
|
||||||
<h2 className="mt-2 text-2xl font-semibold text-stone-950">Expense history</h2>
|
<p className="text-sm font-semibold uppercase tracking-[0.24em] text-stone-500 dark:text-stone-400">Recent entries</p>
|
||||||
|
<h2 className="mt-2 text-2xl font-semibold text-stone-950 dark:text-white">Expense history</h2>
|
||||||
|
{selectedMonth ? (
|
||||||
|
<p className="mt-2 text-sm text-stone-600 dark:text-stone-400">
|
||||||
|
Showing {getMonthLabel(selectedMonth)} entries.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<label className="grid gap-2 text-sm font-medium text-stone-700 dark:text-stone-300">
|
||||||
|
Month
|
||||||
|
<input
|
||||||
|
type="month"
|
||||||
|
value={selectedMonth}
|
||||||
|
onChange={(event) => setSelectedMonth(event.target.value)}
|
||||||
|
className="rounded-2xl border border-stone-300 bg-stone-50 px-3 py-2 text-sm font-medium text-stone-700 outline-none transition focus:border-stone-900 dark:border-stone-600 dark:bg-stone-800 dark:text-stone-300 dark:focus:border-stone-400"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{expenses.length === 0 ? (
|
{expenses.length === 0 ? (
|
||||||
<div className="rounded-3xl border border-dashed border-stone-300 px-4 py-6 text-sm text-stone-600">
|
<div className="rounded-3xl border border-dashed border-stone-300 px-4 py-6 text-sm text-stone-600 dark:border-stone-600 dark:text-stone-400">
|
||||||
No expenses yet. Add your first entry to start the month.
|
{selectedMonth ? `No expenses recorded for ${getMonthLabel(selectedMonth)} yet.` : "No expenses yet. Add your first entry to start the month."}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
expenses.map((expense) => (
|
expenses.map((expense) => (
|
||||||
<article
|
<article
|
||||||
key={expense.id}
|
key={expense.id}
|
||||||
className="flex items-center justify-between gap-4 rounded-3xl border border-stone-200 bg-white px-4 py-4"
|
className="flex items-center justify-between gap-4 rounded-3xl border border-stone-200 bg-white px-4 py-4 dark:border-stone-700 dark:bg-stone-800"
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<p className="font-semibold text-stone-950">{expense.title}</p>
|
<div className="flex items-center gap-2">
|
||||||
<p className="mt-1 text-sm text-stone-600">
|
<p className="font-semibold text-stone-950 dark:text-white">{expense.title}</p>
|
||||||
|
{expense.isRecurring && (
|
||||||
|
<span className="rounded-full bg-amber-100 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-amber-700 dark:bg-amber-900/40 dark:text-amber-400">
|
||||||
|
Recurring
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-sm text-stone-600 dark:text-stone-400">
|
||||||
{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 dark:text-white">{formatCurrencyFromCents(expense.amountCents)}</p>
|
||||||
<button
|
{!expense.isRecurring && (
|
||||||
type="button"
|
<>
|
||||||
onClick={() => handleDelete(expense.id)}
|
<button
|
||||||
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"
|
type="button"
|
||||||
>
|
onClick={() => handleEdit(expense)}
|
||||||
Delete
|
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 dark:border-stone-600 dark:text-stone-400 dark:hover:border-stone-300 dark:hover:text-stone-200"
|
||||||
</button>
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleDelete(expense.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 dark:border-stone-600 dark:text-stone-400 dark:hover:border-rose-500 dark:hover:text-rose-400"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
))
|
))
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -21,32 +26,71 @@ type DashboardSnapshot = {
|
|||||||
largestExpense: { title: string; amountCents: number; date: string; category: string } | null;
|
largestExpense: { title: string; amountCents: number; date: string; category: string } | null;
|
||||||
};
|
};
|
||||||
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; isRecurring?: true }>;
|
||||||
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 HomeDashboard() {
|
type OllamaStatus = {
|
||||||
const [selectedMonth, setSelectedMonth] = useState(getCurrentMonthKey());
|
available: boolean;
|
||||||
|
configuredModel: string;
|
||||||
|
configuredUrl: string;
|
||||||
|
installedModels: string[];
|
||||||
|
modelReady: boolean;
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type HomeDashboardProps = {
|
||||||
|
initialMonth: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function HomeDashboard({ initialMonth }: HomeDashboardProps) {
|
||||||
|
const [selectedMonth, setSelectedMonth] = useState(initialMonth);
|
||||||
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(() => {
|
||||||
|
if (!selectedMonth) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
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,58 +104,91 @@ 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] dark:border-stone-700 dark:bg-[radial-gradient(circle_at_top_left,_rgba(251,191,36,0.08),_transparent_32%),linear-gradient(135deg,#242019,#1e1c18)]">
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
<p className="text-sm font-semibold uppercase tracking-[0.28em] text-amber-700">Monthly Expense Tracker</p>
|
<p className="text-sm font-semibold uppercase tracking-[0.28em] text-amber-700 dark:text-amber-500">Monthly Expense Tracker</p>
|
||||||
<h1 className="max-w-3xl text-5xl font-semibold leading-tight text-stone-950">
|
<h1 className="max-w-3xl text-5xl font-semibold leading-tight text-stone-950 dark:text-white">
|
||||||
A calm local-first home for everyday spending.
|
A calm local-first home for everyday spending.
|
||||||
</h1>
|
</h1>
|
||||||
<p className="max-w-2xl text-lg leading-8 text-stone-600">
|
<p className="max-w-2xl text-lg leading-8 text-stone-600 dark:text-stone-400">
|
||||||
Track expenses and paycheck timing together so the month-to-date picture stays honest.
|
Track expenses and paycheck timing together so the month-to-date picture stays honest.
|
||||||
</p>
|
</p>
|
||||||
<div className="flex flex-wrap gap-3">
|
<div className="flex flex-wrap gap-3">
|
||||||
<Link href="/add-expense" className="rounded-full bg-stone-950 px-5 py-3 text-sm font-semibold text-white transition hover:bg-stone-800">
|
<Link href="/add-expense" className="rounded-full bg-stone-950 px-5 py-3 text-sm font-semibold text-white transition hover:bg-stone-800 dark:bg-stone-100 dark:text-stone-900 dark:hover:bg-white">
|
||||||
Add an expense
|
Add an expense
|
||||||
</Link>
|
</Link>
|
||||||
<Link href="/income" className="rounded-full border border-stone-300 bg-white px-5 py-3 text-sm font-semibold text-stone-800 transition hover:border-stone-900">
|
<Link href="/income" className="rounded-full border border-stone-300 bg-white px-5 py-3 text-sm font-semibold text-stone-800 transition hover:border-stone-900 dark:border-stone-600 dark:bg-stone-800 dark:text-stone-200 dark:hover:border-stone-300">
|
||||||
Track paychecks
|
Track paychecks
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-[1.75rem] border border-white/80 bg-white/90 p-6">
|
<div className="rounded-[1.75rem] border border-white/80 bg-white/90 p-6 dark:border-stone-700/60 dark:bg-stone-900/80">
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-semibold uppercase tracking-[0.24em] text-stone-500">Month to date</p>
|
<p className="text-sm font-semibold uppercase tracking-[0.24em] text-stone-500 dark:text-stone-400">Month to date</p>
|
||||||
<h2 className="mt-2 text-3xl font-semibold text-stone-950">
|
<h2 className="mt-2 text-3xl font-semibold text-stone-950 dark:text-white">
|
||||||
{snapshot ? getMonthLabel(snapshot.month) : getMonthLabel(selectedMonth)}
|
{snapshot ? getMonthLabel(snapshot.month) : selectedMonth ? getMonthLabel(selectedMonth) : "Loading current month..."}
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
type="month"
|
type="month"
|
||||||
value={selectedMonth}
|
value={selectedMonth}
|
||||||
onChange={(event) => setSelectedMonth(event.target.value)}
|
onChange={(event) => setSelectedMonth(event.target.value)}
|
||||||
className="rounded-2xl border border-stone-300 bg-stone-50 px-3 py-2 text-sm font-medium text-stone-700 outline-none transition focus:border-stone-900"
|
className="rounded-2xl border border-stone-300 bg-stone-50 px-3 py-2 text-sm font-medium text-stone-700 outline-none transition focus:border-stone-900 dark:border-stone-600 dark:bg-stone-800 dark:text-stone-300 dark:focus:border-stone-400"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-6 grid gap-4 sm:grid-cols-2">
|
<div className="mt-6 grid gap-4 sm:grid-cols-2">
|
||||||
<article className="rounded-3xl bg-stone-950 px-4 py-5 text-white">
|
<article className="rounded-3xl bg-stone-950 px-4 py-5 text-white dark:bg-stone-800">
|
||||||
<p className="text-xs uppercase tracking-[0.2em] text-stone-300">Total spent</p>
|
<p className="text-xs uppercase tracking-[0.2em] text-stone-300">Total spent</p>
|
||||||
<p className="mt-3 text-3xl font-semibold">{formatCurrencyFromCents(snapshot?.totals.expensesCents ?? 0)}</p>
|
<p className="mt-3 text-3xl font-semibold">{formatCurrencyFromCents(snapshot?.totals.expensesCents ?? 0)}</p>
|
||||||
</article>
|
</article>
|
||||||
<article className="rounded-3xl bg-emerald-50 px-4 py-5 text-stone-950">
|
<article className="rounded-3xl bg-emerald-50 px-4 py-5 text-stone-950 dark:bg-emerald-900/20 dark:text-emerald-100">
|
||||||
<p className="text-xs uppercase tracking-[0.2em] text-emerald-700">Paychecks tracked</p>
|
<p className="text-xs uppercase tracking-[0.2em] text-emerald-700 dark:text-emerald-400">Paychecks tracked</p>
|
||||||
<p className="mt-3 text-3xl font-semibold">{formatCurrencyFromCents(snapshot?.totals.paychecksCents ?? 0)}</p>
|
<p className="mt-3 text-3xl font-semibold">{formatCurrencyFromCents(snapshot?.totals.paychecksCents ?? 0)}</p>
|
||||||
</article>
|
</article>
|
||||||
<article className="rounded-3xl bg-amber-50 px-4 py-5 text-stone-950">
|
<article className="rounded-3xl bg-amber-50 px-4 py-5 text-stone-950 dark:bg-amber-900/20 dark:text-amber-100">
|
||||||
<p className="text-xs uppercase tracking-[0.2em] text-amber-700">Net cash flow</p>
|
<p className="text-xs uppercase tracking-[0.2em] text-amber-700 dark:text-amber-400">Net cash flow</p>
|
||||||
<p className="mt-3 text-3xl font-semibold">{formatCurrencyFromCents(snapshot?.totals.netCashFlowCents ?? 0)}</p>
|
<p className="mt-3 text-3xl font-semibold">{formatCurrencyFromCents(snapshot?.totals.netCashFlowCents ?? 0)}</p>
|
||||||
</article>
|
</article>
|
||||||
<article className="rounded-3xl bg-stone-100 px-4 py-5 text-stone-950">
|
<article className="rounded-3xl bg-stone-100 px-4 py-5 text-stone-950 dark:bg-stone-700 dark:text-stone-100">
|
||||||
<p className="text-xs uppercase tracking-[0.2em] text-stone-600">Average daily spend</p>
|
<p className="text-xs uppercase tracking-[0.2em] text-stone-600 dark:text-stone-400">Average daily spend</p>
|
||||||
<p className="mt-3 text-3xl font-semibold">{formatCurrencyFromCents(snapshot?.totals.averageDailySpendCents ?? 0)}</p>
|
<p className="mt-3 text-3xl font-semibold">{formatCurrencyFromCents(snapshot?.totals.averageDailySpendCents ?? 0)}</p>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
@@ -119,63 +196,240 @@ 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)] dark:border-stone-700 dark:bg-stone-900">
|
||||||
|
<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 dark:text-stone-400">Private monthly insight</p>
|
||||||
|
<h2 className="mt-2 text-3xl font-semibold text-stone-950 dark:text-white">Offline guidance for this month</h2>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void handleGenerateInsights()}
|
||||||
|
disabled={insightBusy}
|
||||||
|
className="cursor-pointer 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 dark:bg-stone-100 dark:text-stone-900 dark:hover:bg-white dark:disabled:bg-stone-600 dark:disabled:text-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 dark:border-stone-700 dark:bg-stone-800">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs uppercase tracking-[0.2em] text-stone-500 dark:text-stone-400">Ollama runtime</p>
|
||||||
|
<p className="mt-2 text-sm font-medium text-stone-700 dark:text-stone-300">
|
||||||
|
{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 dark:text-stone-400 sm:grid-cols-2">
|
||||||
|
<p>
|
||||||
|
Model: <span className="font-semibold text-stone-900 dark:text-stone-200">{ollamaStatus?.configuredModel ?? "-"}</span>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
URL: <span className="font-semibold text-stone-900 dark:text-stone-200">{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 dark:border-stone-600 dark:text-stone-300 dark:hover:border-stone-300"
|
||||||
|
>
|
||||||
|
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 dark:bg-stone-100 dark:text-stone-900 dark:hover:bg-white"
|
||||||
|
>
|
||||||
|
{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 dark:border-stone-600 dark:text-stone-300 dark:hover:border-stone-300"
|
||||||
|
>
|
||||||
|
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 dark:border-amber-900/40 dark:from-[#2a2418] dark:to-[#251f13]">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs font-semibold uppercase tracking-[0.2em] text-amber-700 dark:text-amber-400">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 dark:bg-amber-900/40 dark:text-amber-400">✦ Offline</span>
|
||||||
|
</div>
|
||||||
|
<p className="mt-3 text-lg leading-8 text-stone-700 dark:text-stone-300">{snapshot.insight.summary}</p>
|
||||||
|
<p className="mt-4 text-xs uppercase tracking-[0.2em] text-stone-400 dark:text-stone-500">
|
||||||
|
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 dark:border-stone-700 dark:bg-stone-800">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-stone-500 dark:text-stone-400">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 dark:text-white">
|
||||||
|
{formatCurrencyFromCents(snapshot.totals.expensesCents)}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm text-stone-500 dark:text-stone-400">
|
||||||
|
of {formatCurrencyFromCents(snapshot.totals.paychecksCents)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 h-2.5 overflow-hidden rounded-full bg-stone-200 dark:bg-stone-700">
|
||||||
|
<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 dark:text-stone-400">
|
||||||
|
{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 dark:border-stone-700 dark:bg-stone-800">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-stone-500 dark:text-stone-400">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 dark:text-stone-400">{getCategoryLabel(item.category as never)}</span>
|
||||||
|
<span className="font-semibold text-stone-900 dark:text-stone-200">{formatCurrencyFromCents(item.amountCents)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 h-1.5 overflow-hidden rounded-full bg-stone-200 dark:bg-stone-700">
|
||||||
|
<div
|
||||||
|
className="h-1.5 rounded-full bg-stone-700 dark:bg-stone-400 transition-all"
|
||||||
|
style={{ width: `${pct.toFixed(1)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{snapshot.categoryBreakdown.length === 0 && (
|
||||||
|
<p className="text-xs text-stone-400 dark:text-stone-500">No categories recorded yet.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Recommendations */}
|
||||||
|
<div className="rounded-3xl border border-stone-200 bg-[#f8faf7] px-5 py-5 dark:border-stone-700 dark:bg-stone-800/60">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-stone-500 dark:text-stone-400">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 dark:border-stone-700 dark:bg-stone-800">
|
||||||
|
<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 dark:bg-stone-300 dark:text-stone-900">
|
||||||
|
{i + 1}
|
||||||
|
</span>
|
||||||
|
<p className="text-sm leading-6 text-stone-700 dark:text-stone-300">{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 dark:border-stone-600 dark:text-stone-400">
|
||||||
|
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)] dark:border-stone-700 dark:bg-stone-900">
|
||||||
<div className="flex items-center justify-between gap-4">
|
<div className="flex items-center justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-semibold uppercase tracking-[0.24em] text-stone-500">Comparisons</p>
|
<p className="text-sm font-semibold uppercase tracking-[0.24em] text-stone-500 dark:text-stone-400">Comparisons</p>
|
||||||
<h2 className="mt-2 text-3xl font-semibold text-stone-950">What stands out this month</h2>
|
<h2 className="mt-2 text-3xl font-semibold text-stone-950 dark:text-white">What stands out this month</h2>
|
||||||
</div>
|
</div>
|
||||||
<Link href="/add-expense" className="text-sm font-semibold text-amber-800 transition hover:text-stone-950">
|
<Link href="/add-expense" className="text-sm font-semibold text-amber-800 transition hover:text-stone-950 dark:text-amber-400 dark:hover:text-white">
|
||||||
Manage expenses
|
Manage expenses
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-6 grid gap-4 sm:grid-cols-2">
|
<div className="mt-6 grid gap-4 sm:grid-cols-2">
|
||||||
<article className="rounded-3xl border border-stone-200 bg-[#fffcf7] px-4 py-5">
|
<article className="rounded-3xl border border-stone-200 bg-[#fffcf7] px-4 py-5 dark:border-stone-700 dark:bg-stone-800">
|
||||||
<p className="text-xs uppercase tracking-[0.2em] text-stone-500">Highest category</p>
|
<p className="text-xs uppercase tracking-[0.2em] text-stone-500 dark:text-stone-400">Highest category</p>
|
||||||
<p className="mt-3 text-lg font-semibold text-stone-950">{topCategoryLabel}</p>
|
<p className="mt-3 text-lg font-semibold text-stone-950 dark:text-white">{topCategoryLabel}</p>
|
||||||
</article>
|
</article>
|
||||||
<article className="rounded-3xl border border-stone-200 bg-[#f8faf7] px-4 py-5">
|
<article className="rounded-3xl border border-stone-200 bg-[#f8faf7] px-4 py-5 dark:border-stone-700 dark:bg-stone-800">
|
||||||
<p className="text-xs uppercase tracking-[0.2em] text-stone-500">Paycheck coverage</p>
|
<p className="text-xs uppercase tracking-[0.2em] text-stone-500 dark:text-stone-400">Paycheck coverage</p>
|
||||||
<p className="mt-3 text-lg font-semibold text-stone-950">{coverageLabel}</p>
|
<p className="mt-3 text-lg font-semibold text-stone-950 dark:text-white">{coverageLabel}</p>
|
||||||
</article>
|
</article>
|
||||||
<article className="rounded-3xl border border-stone-200 bg-white px-4 py-5 sm:col-span-2">
|
<article className="rounded-3xl border border-stone-200 bg-white px-4 py-5 sm:col-span-2 dark:border-stone-700 dark:bg-stone-800/50">
|
||||||
<p className="text-xs uppercase tracking-[0.2em] text-stone-500">Largest expense</p>
|
<p className="text-xs uppercase tracking-[0.2em] text-stone-500 dark:text-stone-400">Largest expense</p>
|
||||||
{snapshot?.comparisons.largestExpense ? (
|
{snapshot?.comparisons.largestExpense ? (
|
||||||
<div className="mt-3 flex flex-wrap items-center justify-between gap-3">
|
<div className="mt-3 flex flex-wrap items-center justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-lg font-semibold text-stone-950">{snapshot.comparisons.largestExpense.title}</p>
|
<p className="text-lg font-semibold text-stone-950 dark:text-white">{snapshot.comparisons.largestExpense.title}</p>
|
||||||
<p className="text-sm text-stone-600">
|
<p className="text-sm text-stone-600 dark:text-stone-400">
|
||||||
{snapshot.comparisons.largestExpense.date} · {getCategoryLabel(snapshot.comparisons.largestExpense.category as never)}
|
{snapshot.comparisons.largestExpense.date} · {getCategoryLabel(snapshot.comparisons.largestExpense.category as never)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xl font-semibold text-stone-950">
|
<p className="text-xl font-semibold text-stone-950 dark:text-white">
|
||||||
{formatCurrencyFromCents(snapshot.comparisons.largestExpense.amountCents)}
|
{formatCurrencyFromCents(snapshot.comparisons.largestExpense.amountCents)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className="mt-3 text-sm text-stone-600">No expense data for this month yet.</p>
|
<p className="mt-3 text-sm text-stone-600 dark:text-stone-400">No expense data for this month yet.</p>
|
||||||
)}
|
)}
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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)] dark:border-stone-700 dark:bg-stone-900">
|
||||||
<p className="text-sm font-semibold uppercase tracking-[0.24em] text-stone-500">Category breakdown</p>
|
<p className="text-sm font-semibold uppercase tracking-[0.24em] text-stone-500 dark:text-stone-400">Category breakdown</p>
|
||||||
<h2 className="mt-2 text-3xl font-semibold text-stone-950">Where the month is going</h2>
|
<h2 className="mt-2 text-3xl font-semibold text-stone-950 dark:text-white">Where the month is going</h2>
|
||||||
<div className="mt-6 space-y-3">
|
<div className="mt-6 space-y-3">
|
||||||
{snapshot?.categoryBreakdown.length ? (
|
{snapshot?.categoryBreakdown.length ? (
|
||||||
snapshot.categoryBreakdown.map((item) => (
|
snapshot.categoryBreakdown.map((item) => (
|
||||||
<article key={item.category} className="rounded-3xl border border-stone-200 bg-stone-50 px-4 py-4">
|
<article key={item.category} className="rounded-3xl border border-stone-200 bg-stone-50 px-4 py-4 dark:border-stone-700 dark:bg-stone-800">
|
||||||
<div className="flex items-center justify-between gap-4">
|
<div className="flex items-center justify-between gap-4">
|
||||||
<p className="font-semibold text-stone-950">{getCategoryLabel(item.category as never)}</p>
|
<p className="font-semibold text-stone-950 dark:text-white">{getCategoryLabel(item.category as never)}</p>
|
||||||
<p className="font-semibold text-stone-950">{formatCurrencyFromCents(item.amountCents)}</p>
|
<p className="font-semibold text-stone-950 dark:text-white">{formatCurrencyFromCents(item.amountCents)}</p>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<div className="rounded-3xl border border-dashed border-stone-300 px-4 py-8 text-sm text-stone-600">
|
<div className="rounded-3xl border border-dashed border-stone-300 px-4 py-8 text-sm text-stone-600 dark:border-stone-600 dark:text-stone-400">
|
||||||
No category totals yet for this month.
|
No category totals yet for this month.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -183,13 +437,13 @@ 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)]">
|
<section className="rounded-[2rem] border border-stone-200 bg-white p-8 shadow-[0_24px_60px_rgba(120,90,50,0.08)] dark:border-stone-700 dark:bg-stone-900">
|
||||||
<div className="flex items-center justify-between gap-4">
|
<div className="flex items-center justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-semibold uppercase tracking-[0.24em] text-stone-500">Recent expense pulse</p>
|
<p className="text-sm font-semibold uppercase tracking-[0.24em] text-stone-500 dark:text-stone-400">Recent expense pulse</p>
|
||||||
<h2 className="mt-2 text-3xl font-semibold text-stone-950">Latest entries</h2>
|
<h2 className="mt-2 text-3xl font-semibold text-stone-950 dark:text-white">Latest entries</h2>
|
||||||
</div>
|
</div>
|
||||||
<Link href="/income" className="text-sm font-semibold text-emerald-800 transition hover:text-stone-950">
|
<Link href="/income" className="text-sm font-semibold text-emerald-800 transition hover:text-stone-950 dark:text-emerald-400 dark:hover:text-white">
|
||||||
Manage paychecks
|
Manage paychecks
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
@@ -197,18 +451,25 @@ export function HomeDashboard() {
|
|||||||
<div className="mt-6 grid gap-3 md:grid-cols-2">
|
<div className="mt-6 grid gap-3 md:grid-cols-2">
|
||||||
{snapshot?.recentExpenses.length ? (
|
{snapshot?.recentExpenses.length ? (
|
||||||
snapshot.recentExpenses.map((expense) => (
|
snapshot.recentExpenses.map((expense) => (
|
||||||
<article key={expense.id} className="flex flex-wrap items-center justify-between gap-3 rounded-3xl border border-stone-200 bg-[#fffcf7] px-4 py-4">
|
<article key={expense.id} className="flex flex-wrap items-center justify-between gap-3 rounded-3xl border border-stone-200 bg-[#fffcf7] px-4 py-4 dark:border-stone-700 dark:bg-stone-800">
|
||||||
<div>
|
<div>
|
||||||
<p className="font-semibold text-stone-950">{expense.title}</p>
|
<div className="flex items-center gap-2">
|
||||||
<p className="mt-1 text-sm text-stone-600">
|
<p className="font-semibold text-stone-950 dark:text-white">{expense.title}</p>
|
||||||
|
{expense.isRecurring && (
|
||||||
|
<span className="rounded-full bg-amber-100 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-amber-700 dark:bg-amber-900/40 dark:text-amber-400">
|
||||||
|
Recurring
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-sm text-stone-600 dark:text-stone-400">
|
||||||
{expense.date} · {getCategoryLabel(expense.category as never)}
|
{expense.date} · {getCategoryLabel(expense.category as never)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<p className="font-semibold text-stone-950">{formatCurrencyFromCents(expense.amountCents)}</p>
|
<p className="font-semibold text-stone-950 dark:text-white">{formatCurrencyFromCents(expense.amountCents)}</p>
|
||||||
</article>
|
</article>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<div className="rounded-3xl border border-dashed border-stone-300 px-4 py-10 text-center text-stone-600 md:col-span-2">
|
<div className="rounded-3xl border border-dashed border-stone-300 px-4 py-10 text-center text-stone-600 md:col-span-2 dark:border-stone-600 dark:text-stone-400">
|
||||||
No expenses recorded yet. Start with one quick entry.
|
No expenses recorded yet. Start with one quick entry.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useEffect, useMemo, useState, type FormEvent } from "react";
|
import { useEffect, useMemo, useState, type FormEvent } from "react";
|
||||||
|
|
||||||
|
import { getCurrentMonthKey, getLocalToday } from "@/lib/date";
|
||||||
import { formatCurrencyFromCents } from "@/lib/money";
|
import { formatCurrencyFromCents } from "@/lib/money";
|
||||||
|
|
||||||
type PaycheckRecord = {
|
type PaycheckRecord = {
|
||||||
@@ -10,23 +11,50 @@ 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: "" });
|
||||||
|
const [showScheduleForm, setShowScheduleForm] = useState(false);
|
||||||
const [formState, setFormState] = useState({
|
const [formState, setFormState] = useState({
|
||||||
amount: "",
|
amount: "",
|
||||||
payDate: new Date().toISOString().slice(0, 10),
|
payDate: "",
|
||||||
});
|
});
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
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();
|
const timeoutId = window.setTimeout(() => {
|
||||||
|
const today = getLocalToday();
|
||||||
|
setScheduleForm((current) => (current.anchorDate ? current : { ...current, anchorDate: today }));
|
||||||
|
setFormState((current) => (current.payDate ? current : { ...current, payDate: today }));
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
void loadData();
|
||||||
|
|
||||||
|
return () => window.clearTimeout(timeoutId);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const totalIncome = useMemo(
|
const totalIncome = useMemo(
|
||||||
@@ -34,6 +62,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: getLocalToday() });
|
||||||
|
}
|
||||||
|
|
||||||
|
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 +168,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)] dark:border-emerald-900/30 dark:from-[#1a2420] dark:to-[#182219]">
|
||||||
|
<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 dark:text-emerald-400">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 dark:text-white">
|
||||||
|
{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 dark:border-stone-600 dark:text-stone-300 dark:hover:border-stone-300"
|
||||||
<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 dark:border-stone-600 dark:text-stone-400 dark:hover:border-rose-500 dark:hover:text-rose-400"
|
||||||
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 dark:text-stone-300">
|
||||||
|
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 dark:border-stone-600 dark:bg-stone-800 dark:text-stone-200 dark:focus:border-emerald-500"
|
||||||
|
placeholder="2400.00"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="grid gap-2 text-sm font-medium text-stone-700 dark:text-stone-300">
|
||||||
|
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 dark:border-stone-600 dark:bg-stone-800 dark:text-stone-200 dark:focus:border-emerald-500"
|
||||||
|
/>
|
||||||
|
</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 dark:bg-stone-100 dark:text-stone-900 dark:hover:bg-white dark:disabled:bg-stone-700 dark:disabled:text-stone-500"
|
||||||
|
>
|
||||||
|
{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 dark:border-stone-600 dark:text-stone-300 dark:hover:border-stone-300"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{schedule && !showScheduleForm && (
|
||||||
|
<div className="mt-5">
|
||||||
|
<div className="flex flex-wrap gap-6 text-sm text-stone-600 dark:text-stone-400">
|
||||||
|
<span>
|
||||||
|
Amount: <span className="font-semibold text-stone-950 dark:text-white">{formatCurrencyFromCents(schedule.amountCents)}</span>
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
Cadence: <span className="font-semibold text-stone-950 dark:text-white">Every 2 weeks</span>
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
Anchor: <span className="font-semibold text-stone-950 dark:text-white">{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 dark:text-stone-400">
|
||||||
|
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 dark:border-emerald-800/50 dark:bg-emerald-900/20 dark:text-emerald-300"
|
||||||
|
: "border-stone-200 bg-white text-stone-700 dark:border-stone-700 dark:bg-stone-800 dark:text-stone-300"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="font-medium">{date}</span>
|
||||||
|
{confirmed ? (
|
||||||
|
<span className="text-xs font-semibold text-emerald-600 dark:text-emerald-400">✓ 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 dark:bg-emerald-600 dark:hover:bg-emerald-500"
|
||||||
|
>
|
||||||
|
Mark received
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{pendingProjectedDates.length > 0 && (
|
||||||
|
<p className="mt-2 text-xs text-stone-500 dark:text-stone-400">
|
||||||
|
{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)] dark:border-stone-700 dark:bg-stone-900">
|
||||||
|
<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 dark:text-emerald-400">One-off entry</p>
|
||||||
|
<h2 className="mt-2 text-3xl font-semibold text-stone-950 dark:text-white">Log a bonus or extra deposit</h2>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-2xl bg-emerald-50 px-4 py-3 text-right dark:bg-emerald-900/20">
|
||||||
|
<p className="text-xs uppercase tracking-[0.2em] text-emerald-700 dark:text-emerald-400">Tracked paychecks</p>
|
||||||
|
<p className="mt-1 text-2xl font-semibold text-stone-950 dark:text-white">{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 dark:text-stone-300">
|
||||||
|
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 dark:border-stone-600 dark:bg-stone-800 dark:text-stone-200 dark:focus:border-stone-400"
|
||||||
|
placeholder="1800.00"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="grid gap-2 text-sm font-medium text-stone-700 dark:text-stone-300">
|
||||||
|
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 dark:border-stone-600 dark:bg-stone-800 dark:text-stone-200 dark:focus:border-stone-400"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="md:col-span-2 flex items-center justify-between gap-3">
|
||||||
|
<p className="text-sm text-rose-700 dark:text-rose-400">{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 dark:bg-stone-100 dark:text-stone-900 dark:hover:bg-white dark:disabled:bg-stone-700 dark:disabled:text-stone-500"
|
||||||
|
>
|
||||||
|
{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)] dark:border-stone-700 dark:bg-stone-900/60">
|
||||||
|
<div className="mb-5">
|
||||||
|
<p className="text-sm font-semibold uppercase tracking-[0.24em] text-stone-500 dark:text-stone-400">Income history</p>
|
||||||
|
<h2 className="mt-2 text-2xl font-semibold text-stone-950 dark:text-white">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 dark:border-stone-600 dark:text-stone-400">
|
||||||
|
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 dark:border-stone-700 dark:bg-stone-800"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold text-stone-950 dark:text-white">Paycheck</p>
|
||||||
|
<p className="mt-1 text-sm text-stone-600 dark:text-stone-400">{paycheck.payDate}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<p className="font-semibold text-stone-950 dark:text-white">{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 dark:border-stone-600 dark:text-stone-400 dark:hover:border-rose-500 dark:hover:text-rose-400"
|
||||||
|
>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|||||||
277
src/components/recurring-expense-manager.tsx
Normal file
277
src/components/recurring-expense-manager.tsx
Normal file
@@ -0,0 +1,277 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState, type FormEvent } from "react";
|
||||||
|
|
||||||
|
import { getCategoryLabel, type CategoryValue } from "@/lib/categories";
|
||||||
|
import { formatCurrencyFromCents } from "@/lib/money";
|
||||||
|
|
||||||
|
type RecurringDefinition = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
amountCents: number;
|
||||||
|
category: string;
|
||||||
|
dayOfMonth: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CategoryOption = {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
categoryOptions: CategoryOption[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const emptyForm = { title: "", amount: "", dayOfMonth: "1", category: "MISC" as CategoryValue };
|
||||||
|
|
||||||
|
export function RecurringExpenseManager({ categoryOptions }: Props) {
|
||||||
|
const [definitions, setDefinitions] = useState<RecurringDefinition[]>([]);
|
||||||
|
const [showAddForm, setShowAddForm] = useState(false);
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
|
const [formState, setFormState] = useState(emptyForm);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function load() {
|
||||||
|
const res = await fetch("/recurring-expenses", { cache: "no-store" });
|
||||||
|
const payload = (await res.json()) as { recurringExpenses?: RecurringDefinition[] };
|
||||||
|
setDefinitions(payload.recurringExpenses ?? []);
|
||||||
|
}
|
||||||
|
void load();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
function openAdd() {
|
||||||
|
setEditingId(null);
|
||||||
|
setFormState(emptyForm);
|
||||||
|
setError(null);
|
||||||
|
setShowAddForm(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(def: RecurringDefinition) {
|
||||||
|
setShowAddForm(false);
|
||||||
|
setEditingId(def.id);
|
||||||
|
setFormState({
|
||||||
|
title: def.title,
|
||||||
|
amount: (def.amountCents / 100).toFixed(2),
|
||||||
|
dayOfMonth: String(def.dayOfMonth),
|
||||||
|
category: def.category as CategoryValue,
|
||||||
|
});
|
||||||
|
setError(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelForm() {
|
||||||
|
setShowAddForm(false);
|
||||||
|
setEditingId(null);
|
||||||
|
setFormState(emptyForm);
|
||||||
|
setError(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const isEditing = editingId !== null;
|
||||||
|
const url = isEditing ? `/recurring-expenses/${editingId}` : "/recurring-expenses";
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: isEditing ? "PATCH" : "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
title: formState.title,
|
||||||
|
amount: formState.amount,
|
||||||
|
dayOfMonth: parseInt(formState.dayOfMonth, 10),
|
||||||
|
category: formState.category,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
setBusy(false);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const payload = (await response.json().catch(() => null)) as { error?: string } | null;
|
||||||
|
setError(payload?.error ?? "Could not save.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = (await response.json()) as { recurringExpense: RecurringDefinition };
|
||||||
|
|
||||||
|
if (isEditing) {
|
||||||
|
setDefinitions((current) =>
|
||||||
|
current.map((d) => (d.id === editingId ? payload.recurringExpense : d)),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
setDefinitions((current) => [...current, payload.recurringExpense]);
|
||||||
|
}
|
||||||
|
|
||||||
|
cancelForm();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(id: string) {
|
||||||
|
setBusy(true);
|
||||||
|
const response = await fetch(`/recurring-expenses/${id}`, { method: "DELETE" });
|
||||||
|
setBusy(false);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
setError("Could not delete.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setDefinitions((current) => current.filter((d) => d.id !== id));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="rounded-[2rem] border border-amber-200 bg-amber-50 p-6 shadow-[0_24px_60px_rgba(120,90,50,0.08)] dark:border-amber-900/40 dark:bg-amber-900/10">
|
||||||
|
<div className="mb-5 flex items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold uppercase tracking-[0.24em] text-amber-700 dark:text-amber-400">Fixed monthly costs</p>
|
||||||
|
<h2 className="mt-2 text-2xl font-semibold text-stone-950 dark:text-white">Recurring expenses</h2>
|
||||||
|
<p className="mt-1 text-sm text-stone-600 dark:text-stone-400">These appear automatically in every month without manual entry.</p>
|
||||||
|
</div>
|
||||||
|
{!showAddForm && editingId === null && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={openAdd}
|
||||||
|
className="shrink-0 rounded-full bg-stone-950 px-4 py-2.5 text-sm font-semibold text-white transition hover:bg-stone-700 dark:bg-stone-100 dark:text-stone-900 dark:hover:bg-white"
|
||||||
|
>
|
||||||
|
+ Add recurring
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(showAddForm || editingId !== null) && (
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
className="mb-5 grid gap-3 rounded-2xl border border-amber-200 bg-white p-5 md:grid-cols-2 dark:border-amber-900/40 dark:bg-stone-900"
|
||||||
|
>
|
||||||
|
<p className="text-sm font-semibold text-stone-700 dark:text-stone-300 md:col-span-2">
|
||||||
|
{editingId ? "Edit recurring expense" : "New recurring expense"}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<label className="grid gap-1.5 text-sm font-medium text-stone-700 dark:text-stone-300 md:col-span-2">
|
||||||
|
Title
|
||||||
|
<input
|
||||||
|
required
|
||||||
|
value={formState.title}
|
||||||
|
onChange={(e) => setFormState((s) => ({ ...s, title: e.target.value }))}
|
||||||
|
className="rounded-2xl border border-stone-300 bg-stone-50 px-4 py-2.5 outline-none transition focus:border-stone-900 dark:border-stone-600 dark:bg-stone-800 dark:text-stone-200 dark:focus:border-stone-400"
|
||||||
|
placeholder="Rent, car insurance, EMI..."
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="grid gap-1.5 text-sm font-medium text-stone-700 dark:text-stone-300">
|
||||||
|
Amount
|
||||||
|
<input
|
||||||
|
required
|
||||||
|
inputMode="decimal"
|
||||||
|
value={formState.amount}
|
||||||
|
onChange={(e) => setFormState((s) => ({ ...s, amount: e.target.value }))}
|
||||||
|
className="rounded-2xl border border-stone-300 bg-stone-50 px-4 py-2.5 outline-none transition focus:border-stone-900 dark:border-stone-600 dark:bg-stone-800 dark:text-stone-200 dark:focus:border-stone-400"
|
||||||
|
placeholder="1200.00"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="grid gap-1.5 text-sm font-medium text-stone-700 dark:text-stone-300">
|
||||||
|
Day of month
|
||||||
|
<input
|
||||||
|
required
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={28}
|
||||||
|
value={formState.dayOfMonth}
|
||||||
|
onChange={(e) => setFormState((s) => ({ ...s, dayOfMonth: e.target.value }))}
|
||||||
|
className="rounded-2xl border border-stone-300 bg-stone-50 px-4 py-2.5 outline-none transition focus:border-stone-900 dark:border-stone-600 dark:bg-stone-800 dark:text-stone-200 dark:focus:border-stone-400"
|
||||||
|
placeholder="1"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="grid gap-1.5 text-sm font-medium text-stone-700 dark:text-stone-300 md:col-span-2">
|
||||||
|
Category
|
||||||
|
<select
|
||||||
|
value={formState.category}
|
||||||
|
onChange={(e) => setFormState((s) => ({ ...s, category: e.target.value as CategoryValue }))}
|
||||||
|
className="rounded-2xl border border-stone-300 bg-stone-50 px-4 py-2.5 outline-none transition focus:border-stone-900 dark:border-stone-600 dark:bg-stone-800 dark:text-stone-200 dark:focus:border-stone-400"
|
||||||
|
>
|
||||||
|
{categoryOptions.map((option) => (
|
||||||
|
<option key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="md:col-span-2 flex items-center justify-between gap-3">
|
||||||
|
<p className="text-sm text-rose-700">{error}</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={cancelForm}
|
||||||
|
className="rounded-full border border-stone-300 px-4 py-2.5 text-sm font-semibold text-stone-700 transition hover:border-stone-900 dark:border-stone-600 dark:text-stone-300 dark:hover:border-stone-300"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={busy}
|
||||||
|
className="rounded-full bg-stone-950 px-4 py-2.5 text-sm font-semibold text-white transition hover:bg-stone-800 disabled:cursor-not-allowed disabled:bg-stone-400 dark:bg-stone-100 dark:text-stone-900 dark:hover:bg-white dark:disabled:bg-stone-700 dark:disabled:text-stone-500"
|
||||||
|
>
|
||||||
|
{busy ? "Saving..." : editingId ? "Update" : "Save"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!showAddForm && editingId === null && error && (
|
||||||
|
<p className="mb-3 text-sm text-rose-700">{error}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{definitions.length === 0 && !showAddForm ? (
|
||||||
|
<div className="rounded-3xl border border-dashed border-amber-300 px-4 py-6 text-sm text-stone-600 dark:border-amber-800/50 dark:text-stone-400">
|
||||||
|
No recurring expenses yet. Add fixed monthly costs like rent or EMI.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{definitions.map((def) => (
|
||||||
|
<article
|
||||||
|
key={def.id}
|
||||||
|
className={`flex items-center justify-between gap-4 rounded-3xl border px-4 py-4 ${
|
||||||
|
editingId === def.id
|
||||||
|
? "border-amber-300 bg-amber-100/50 dark:border-amber-700/50 dark:bg-amber-900/20"
|
||||||
|
: "border-amber-200 bg-white dark:border-amber-900/30 dark:bg-stone-900"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold text-stone-950 dark:text-white">{def.title}</p>
|
||||||
|
<p className="mt-1 text-sm text-stone-600 dark:text-stone-400">
|
||||||
|
Day {def.dayOfMonth} each month · {getCategoryLabel(def.category as CategoryValue)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<p className="mr-2 font-semibold text-stone-950 dark:text-white">{formatCurrencyFromCents(def.amountCents)}</p>
|
||||||
|
{editingId !== def.id && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => openEdit(def)}
|
||||||
|
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 dark:border-stone-600 dark:text-stone-400 dark:hover:border-stone-300 dark:hover:text-stone-200"
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => void handleDelete(def.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 disabled:cursor-not-allowed dark:border-stone-600 dark:text-stone-400 dark:hover:border-rose-500 dark:hover:text-rose-400"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,7 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
|
||||||
const links = [
|
const links = [
|
||||||
{ href: "/", label: "Dashboard" },
|
{ href: "/", label: "Dashboard" },
|
||||||
@@ -7,17 +10,26 @@ const links = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export function SiteNav() {
|
export function SiteNav() {
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav className="flex flex-wrap gap-3 text-sm font-semibold text-stone-700">
|
<nav className="flex flex-wrap gap-3 text-sm font-semibold text-stone-700 dark:text-stone-300">
|
||||||
{links.map((link) => (
|
{links.map((link) => {
|
||||||
<Link
|
const isActive = link.href === "/" ? pathname === "/" : pathname.startsWith(link.href);
|
||||||
key={link.href}
|
return (
|
||||||
href={link.href}
|
<Link
|
||||||
className="rounded-full border border-stone-300/80 bg-white/80 px-4 py-2 transition hover:border-stone-900 hover:text-stone-950"
|
key={link.href}
|
||||||
>
|
href={link.href}
|
||||||
{link.label}
|
className={
|
||||||
</Link>
|
isActive
|
||||||
))}
|
? "rounded-full border border-stone-900 bg-stone-900 px-4 py-2 text-white transition hover:bg-stone-700 hover:border-stone-700 dark:border-stone-100 dark:bg-stone-100 dark:text-stone-900 dark:hover:bg-white dark:hover:border-white"
|
||||||
|
: "rounded-full border border-stone-300/80 bg-white/80 px-4 py-2 transition hover:border-stone-900 hover:text-stone-950 dark:border-stone-600 dark:bg-stone-800/60 dark:hover:border-stone-300 dark:hover:text-white"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{link.label}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</nav>
|
</nav>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
48
src/components/theme-toggle.tsx
Normal file
48
src/components/theme-toggle.tsx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
export function ThemeToggle() {
|
||||||
|
const [isDark, setIsDark] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timeoutId = window.setTimeout(() => {
|
||||||
|
setIsDark(document.documentElement.classList.contains("dark"));
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
return () => window.clearTimeout(timeoutId);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
function toggle() {
|
||||||
|
const root = document.documentElement;
|
||||||
|
if (root.classList.contains("dark")) {
|
||||||
|
root.classList.remove("dark");
|
||||||
|
localStorage.setItem("theme", "light");
|
||||||
|
setIsDark(false);
|
||||||
|
} else {
|
||||||
|
root.classList.add("dark");
|
||||||
|
localStorage.setItem("theme", "dark");
|
||||||
|
setIsDark(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={toggle}
|
||||||
|
aria-label={isDark ? "Switch to light mode" : "Switch to dark mode"}
|
||||||
|
className="rounded-full border border-stone-300/80 bg-white/80 px-3 py-2 text-stone-700 transition hover:border-stone-900 hover:text-stone-950 dark:border-stone-600 dark:bg-stone-800 dark:text-stone-300 dark:hover:border-stone-400 dark:hover:text-white"
|
||||||
|
>
|
||||||
|
{isDark ? (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<circle cx="12" cy="12" r="4" />
|
||||||
|
<path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
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,20 @@ import {
|
|||||||
isCurrentMonthKey,
|
isCurrentMonthKey,
|
||||||
isDateInMonth,
|
isDateInMonth,
|
||||||
} from "@/lib/date";
|
} from "@/lib/date";
|
||||||
|
import { getActiveSchedule, getProjectedPayDates, type PaySchedule } from "@/lib/pay-schedule";
|
||||||
|
import {
|
||||||
|
getProjectedRecurringExpenses,
|
||||||
|
listActiveRecurringExpenses,
|
||||||
|
type ProjectedRecurringExpense,
|
||||||
|
} from "@/lib/recurring-expenses";
|
||||||
|
|
||||||
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;
|
||||||
@@ -24,20 +35,48 @@ export type DashboardSnapshot = {
|
|||||||
largestExpense: { title: string; amountCents: number; date: string; category: string } | null;
|
largestExpense: { title: string; amountCents: number; date: string; category: string } | null;
|
||||||
};
|
};
|
||||||
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; isRecurring?: true }>;
|
||||||
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;
|
||||||
|
projectedRecurring?: ProjectedRecurringExpense[];
|
||||||
}): 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 recurringExpenses = input.projectedRecurring ?? [];
|
||||||
const monthPaychecks = input.paychecks.filter((paycheck) => isDateInMonth(paycheck.payDate, input.month));
|
const monthPaychecks = input.paychecks.filter((paycheck) => isDateInMonth(paycheck.payDate, input.month));
|
||||||
|
|
||||||
const expensesCents = monthExpenses.reduce((sum, expense) => sum + expense.amountCents, 0);
|
// Project biweekly pay dates; suppress any that already have a manual paycheck on the same date
|
||||||
const paychecksCents = monthPaychecks.reduce((sum, paycheck) => sum + paycheck.amountCents, 0);
|
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 allMonthExpenses = [
|
||||||
|
...monthExpenses.map((e) => ({ ...e, isRecurring: undefined as true | undefined })),
|
||||||
|
...recurringExpenses,
|
||||||
|
];
|
||||||
|
|
||||||
|
const expensesCents = allMonthExpenses.reduce((sum, expense) => sum + expense.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)
|
||||||
@@ -45,7 +84,7 @@ export function buildDashboardSnapshot(input: {
|
|||||||
: getDaysInMonth(input.month);
|
: getDaysInMonth(input.month);
|
||||||
|
|
||||||
const categoryTotals = new Map<string, number>();
|
const categoryTotals = new Map<string, number>();
|
||||||
for (const expense of monthExpenses) {
|
for (const expense of allMonthExpenses) {
|
||||||
categoryTotals.set(expense.category, (categoryTotals.get(expense.category) ?? 0) + expense.amountCents);
|
categoryTotals.set(expense.category, (categoryTotals.get(expense.category) ?? 0) + expense.amountCents);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,19 +95,19 @@ export function buildDashboardSnapshot(input: {
|
|||||||
const highestCategory = categoryBreakdown[0] ?? null;
|
const highestCategory = categoryBreakdown[0] ?? null;
|
||||||
|
|
||||||
const largestExpense =
|
const largestExpense =
|
||||||
monthExpenses
|
allMonthExpenses
|
||||||
.slice()
|
.slice()
|
||||||
.sort((left, right) => right.amountCents - left.amountCents || right.date.localeCompare(left.date))[0] ?? null;
|
.sort((left, right) => right.amountCents - left.amountCents || right.date.localeCompare(left.date))[0] ?? null;
|
||||||
|
|
||||||
const dailyMap = new Map<string, { expensesCents: number; paychecksCents: number }>();
|
const dailyMap = new Map<string, { expensesCents: number; paychecksCents: number }>();
|
||||||
|
|
||||||
for (const expense of monthExpenses) {
|
for (const expense of allMonthExpenses) {
|
||||||
const current = dailyMap.get(expense.date) ?? { expensesCents: 0, paychecksCents: 0 };
|
const current = dailyMap.get(expense.date) ?? { expensesCents: 0, paychecksCents: 0 };
|
||||||
current.expensesCents += expense.amountCents;
|
current.expensesCents += expense.amountCents;
|
||||||
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 +119,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,10 +144,20 @@ export function buildDashboardSnapshot(input: {
|
|||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
},
|
},
|
||||||
|
paySchedule: input.paySchedule
|
||||||
|
? { amountCents: input.paySchedule.amountCents, anchorDate: input.paySchedule.anchorDate, projectedDates }
|
||||||
|
: null,
|
||||||
categoryBreakdown,
|
categoryBreakdown,
|
||||||
recentExpenses: monthExpenses
|
recentExpenses: allMonthExpenses
|
||||||
.slice()
|
.slice()
|
||||||
.sort((left, right) => right.date.localeCompare(left.date) || right.createdAt.getTime() - left.createdAt.getTime())
|
.sort((left, right) => {
|
||||||
|
const dateDiff = right.date.localeCompare(left.date);
|
||||||
|
if (dateDiff !== 0) return dateDiff;
|
||||||
|
// Real expenses have createdAt; projected recurring don't — sort them after real ones
|
||||||
|
const leftTime = "createdAt" in left ? (left.createdAt as Date).getTime() : 0;
|
||||||
|
const rightTime = "createdAt" in right ? (right.createdAt as Date).getTime() : 0;
|
||||||
|
return rightTime - leftTime;
|
||||||
|
})
|
||||||
.slice(0, 6)
|
.slice(0, 6)
|
||||||
.map((expense) => ({
|
.map((expense) => ({
|
||||||
id: expense.id,
|
id: expense.id,
|
||||||
@@ -109,16 +165,22 @@ export function buildDashboardSnapshot(input: {
|
|||||||
amountCents: expense.amountCents,
|
amountCents: expense.amountCents,
|
||||||
date: expense.date,
|
date: expense.date,
|
||||||
category: expense.category,
|
category: expense.category,
|
||||||
|
...(expense.isRecurring ? { isRecurring: true as const } : {}),
|
||||||
})),
|
})),
|
||||||
chart,
|
chart,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getDashboardSnapshot(month = getCurrentMonthKey()) {
|
export async function getDashboardSnapshot(month = getCurrentMonthKey()) {
|
||||||
const [expenses, paychecks] = await Promise.all([
|
const [expenses, paychecks, insight, paySchedule, recurringDefinitions] = 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(),
|
||||||
|
listActiveRecurringExpenses(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return buildDashboardSnapshot({ month, expenses, paychecks });
|
const projectedRecurring = getProjectedRecurringExpenses(recurringDefinitions, month);
|
||||||
|
|
||||||
|
return buildDashboardSnapshot({ month, expenses, paychecks, paySchedule, insight, projectedRecurring });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
143
src/lib/insights.test.ts
Normal file
143
src/lib/insights.test.ts
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
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() },
|
||||||
|
recurringExpense: { findMany: vi.fn().mockResolvedValue([]) },
|
||||||
|
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([]);
|
||||||
|
vi.mocked(db.recurringExpense.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.mocked(db.recurringExpense.findMany).mockResolvedValue([]);
|
||||||
|
|
||||||
|
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.mocked(db.recurringExpense.findMany).mockResolvedValue([]);
|
||||||
|
|
||||||
|
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.map((e) => ({ title: e.title, amount: centsToDisplay(e.amountCents), date: e.date, category: getCategoryLabel(e.category as CategoryValue) })))}`,
|
||||||
|
`Daily chart points: ${JSON.stringify(snapshot.chart.map((p) => ({ date: p.date, expenses: centsToDisplay(p.expensesCents), paychecks: centsToDisplay(p.paychecksCents) })))}`,
|
||||||
|
"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;
|
||||||
|
}
|
||||||
87
src/lib/recurring-expenses.ts
Normal file
87
src/lib/recurring-expenses.ts
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import type { Category } from "@prisma/client";
|
||||||
|
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
|
||||||
|
export type RecurringExpense = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
amountCents: number;
|
||||||
|
category: string;
|
||||||
|
dayOfMonth: number;
|
||||||
|
active: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ProjectedRecurringExpense = {
|
||||||
|
id: string;
|
||||||
|
recurringId: string;
|
||||||
|
title: string;
|
||||||
|
amountCents: number;
|
||||||
|
category: string;
|
||||||
|
date: string;
|
||||||
|
isRecurring: true;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function listActiveRecurringExpenses(): Promise<RecurringExpense[]> {
|
||||||
|
return db.recurringExpense.findMany({
|
||||||
|
where: { active: true },
|
||||||
|
orderBy: { createdAt: "asc" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createRecurringExpense(input: {
|
||||||
|
title: string;
|
||||||
|
amountCents: number;
|
||||||
|
category: Category;
|
||||||
|
dayOfMonth: number;
|
||||||
|
}): Promise<RecurringExpense> {
|
||||||
|
return db.recurringExpense.create({
|
||||||
|
data: {
|
||||||
|
title: input.title.trim(),
|
||||||
|
amountCents: input.amountCents,
|
||||||
|
category: input.category,
|
||||||
|
dayOfMonth: input.dayOfMonth,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateRecurringExpense(
|
||||||
|
id: string,
|
||||||
|
input: { title: string; amountCents: number; category: Category; dayOfMonth: number },
|
||||||
|
): Promise<RecurringExpense> {
|
||||||
|
return db.recurringExpense.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
title: input.title.trim(),
|
||||||
|
amountCents: input.amountCents,
|
||||||
|
category: input.category,
|
||||||
|
dayOfMonth: input.dayOfMonth,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deactivateRecurringExpense(id: string): Promise<void> {
|
||||||
|
await db.recurringExpense.update({ where: { id }, data: { active: false } });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure function — no DB access. Projects recurring expense definitions into
|
||||||
|
* virtual expense objects for the given month (YYYY-MM).
|
||||||
|
*/
|
||||||
|
export function getProjectedRecurringExpenses(
|
||||||
|
definitions: RecurringExpense[],
|
||||||
|
month: string,
|
||||||
|
): ProjectedRecurringExpense[] {
|
||||||
|
return definitions.map((def) => {
|
||||||
|
const day = String(def.dayOfMonth).padStart(2, "0");
|
||||||
|
const date = `${month}-${day}`;
|
||||||
|
return {
|
||||||
|
id: `recurring-${def.id}-${month}`,
|
||||||
|
recurringId: def.id,
|
||||||
|
title: def.title,
|
||||||
|
amountCents: def.amountCents,
|
||||||
|
category: def.category,
|
||||||
|
date,
|
||||||
|
isRecurring: true as const,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
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`;
|
||||||
|
}
|
||||||
@@ -35,5 +35,17 @@ export const monthQuerySchema = z.object({
|
|||||||
month: z.string().refine(isValidMonthKey, "Use a YYYY-MM month."),
|
month: z.string().refine(isValidMonthKey, "Use a YYYY-MM month."),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const recurringExpenseInputSchema = z.object({
|
||||||
|
title: z.string().trim().min(1, "Title is required.").max(80, "Keep titles under 80 characters."),
|
||||||
|
amount: amountSchema,
|
||||||
|
dayOfMonth: z
|
||||||
|
.number({ invalid_type_error: "Day must be a number." })
|
||||||
|
.int()
|
||||||
|
.min(1, "Day must be at least 1.")
|
||||||
|
.max(28, "Day must be 28 or less (works across all months)."),
|
||||||
|
category: z.nativeEnum(Category, { message: "Choose a valid category." }),
|
||||||
|
});
|
||||||
|
|
||||||
export type ExpenseInput = z.infer<typeof expenseInputSchema>;
|
export type ExpenseInput = z.infer<typeof expenseInputSchema>;
|
||||||
export type PaycheckInput = z.infer<typeof paycheckInputSchema>;
|
export type PaycheckInput = z.infer<typeof paycheckInputSchema>;
|
||||||
|
export type RecurringExpenseInput = z.infer<typeof recurringExpenseInputSchema>;
|
||||||
|
|||||||
1
tsconfig.tsbuildinfo
Normal file
1
tsconfig.tsbuildinfo
Normal file
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user