Compare commits

..

8 Commits

Author SHA1 Message Date
feb2f2c37e Fix dashboard hydration and archive theming 2026-03-23 23:08:18 -04:00
c852ad0d80 Fix month-browsable expense history 2026-03-23 22:53:14 -04:00
d2c230b4f9 Update TypeScript build info 2026-03-23 22:42:24 -04:00
8dcddbf278 Fix hydration mismatches and theme bootstrap 2026-03-23 22:40:57 -04:00
e3ac732b1b Add pointer cursor to Generate insights button
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 22:17:35 -04:00
2dae000342 Fix hydration mismatch from theme blocking script
The inline script adds the 'dark' class to <html> before React hydrates,
causing a server/client className mismatch. suppressHydrationWarning on
<html> is the standard Next.js fix for this pattern.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 22:15:34 -04:00
48b481999d Fix AI insight prompt passing raw cents instead of dollar amounts
recentExpenses and chart were JSON-stringified with raw amountCents/
expensesCents/paychecksCents values, causing the model to read e.g.
10000 as $10,000 instead of $100. Both arrays are now mapped through
centsToDisplay() before inclusion in the prompt, matching the pattern
already used for totals and category breakdown.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 22:11:45 -04:00
012385e9e1 Add dark mode with theme toggle and OpenSpec change
- Add @custom-variant dark in globals.css for class-based dark mode
- Add ThemeToggle component with localStorage persistence and system preference fallback
- Inject blocking inline script in layout to prevent flash on load
- Apply dark: variants across all components (layout, site-nav, home-dashboard, expense-workspace, paycheck-workspace, recurring-expense-manager) and page headers
- Create openspec/changes/theming-dark-mode with proposal, design, and tasks artifacts

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 22:04:20 -04:00
39 changed files with 836 additions and 221 deletions

80
AGENT_HANDOFF.md Normal file
View 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.

2
next-env.d.ts vendored
View File

@@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts";
import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

View File

@@ -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
- **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

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-03-23

View File

@@ -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.

View File

@@ -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.

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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.

View 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

View 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

View 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

View 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

View 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

View 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

View File

@@ -10,9 +10,9 @@ export default function AddExpensePage() {
return (
<div className="space-y-8">
<header className="max-w-2xl space-y-3">
<p className="text-sm font-semibold uppercase tracking-[0.28em] text-amber-700">Add Expense</p>
<h1 className="text-4xl font-semibold text-stone-950">Capture spending while it still feels fresh.</h1>
<p className="text-lg leading-8 text-stone-600">
<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 dark:text-white">Capture spending while it still feels fresh.</h1>
<p className="text-lg leading-8 text-stone-600 dark:text-stone-400">
Enter the shop name and the app can auto-fill a category locally for known merchants, with offline AI help for unfamiliar ones.
</p>
</header>

View File

@@ -1,10 +1,17 @@
@import "tailwindcss";
@custom-variant dark (&:where(.dark, .dark *));
:root {
--background: #ffffff;
--foreground: #171717;
}
.dark {
--background: #1a1714;
--foreground: #f5f0eb;
}
@theme inline {
--color-background: #fbfaf7;
--color-foreground: #1c1917;

View File

@@ -8,9 +8,9 @@ export default function IncomePage() {
return (
<div className="space-y-8">
<header className="max-w-2xl space-y-3">
<p className="text-sm font-semibold uppercase tracking-[0.28em] text-emerald-700">Income & Paychecks</p>
<h1 className="text-4xl font-semibold text-stone-950">Capture income on real pay dates, not rough monthly averages.</h1>
<p className="text-lg leading-8 text-stone-600">
<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 dark:text-white">Capture income on real pay dates, not rough monthly averages.</h1>
<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.
</p>
</header>

View File

@@ -1,7 +1,9 @@
import type { Metadata } from "next";
import { Fraunces, Manrope } from "next/font/google";
import Script from "next/script";
import { SiteNav } from "@/components/site-nav";
import { ThemeToggle } from "@/components/theme-toggle";
import "./globals.css";
@@ -20,6 +22,18 @@ export const metadata: Metadata = {
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({
children,
}: Readonly<{
@@ -29,15 +43,22 @@ export default function RootLayout({
<html
lang="en"
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">
<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>
<p className="text-xs font-semibold uppercase tracking-[0.28em] text-amber-700">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="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 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>
<SiteNav />
</header>
<main className="flex-1 pb-10">{children}</main>
</div>

View File

@@ -1,5 +1,6 @@
import { HomeDashboard } from "@/components/home-dashboard";
import { getCurrentMonthKey } from "@/lib/date";
export default function Home() {
return <HomeDashboard />;
return <HomeDashboard initialMonth={getCurrentMonthKey()} />;
}

View File

@@ -1,9 +1,9 @@
"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 { getCurrentMonthKey } from "@/lib/date";
import { getCurrentMonthKey, getLocalToday, getMonthLabel } from "@/lib/date";
import { formatCurrencyFromCents } from "@/lib/money";
type SuggestionResponse = {
@@ -34,6 +34,7 @@ type Props = {
export function ExpenseWorkspace({ categoryOptions }: Props) {
const [expenses, setExpenses] = useState<ExpenseRecord[]>([]);
const [selectedMonth, setSelectedMonth] = useState("");
const [formState, setFormState] = useState<{
title: string;
amount: string;
@@ -42,7 +43,7 @@ export function ExpenseWorkspace({ categoryOptions }: Props) {
}>({
title: "",
amount: "",
date: new Date().toISOString().slice(0, 10),
date: "",
category: (categoryOptions[0]?.value as CategoryValue | undefined) ?? "MISC",
});
const [editingId, setEditingId] = useState<string | null>(null);
@@ -53,16 +54,32 @@ export function ExpenseWorkspace({ categoryOptions }: Props) {
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(() => {
async function loadExpenses() {
const month = getCurrentMonthKey();
const response = await fetch(`/expenses?month=${month}`, { cache: "no-store" });
const payload = (await response.json().catch(() => null)) as { expenses?: ExpenseRecord[] } | null;
setExpenses(payload?.expenses ?? []);
const timeoutId = window.setTimeout(() => {
setSelectedMonth(getCurrentMonthKey());
setFormState((current) => (current.date ? current : { ...current, date: getLocalToday() }));
}, 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(
() => expenses.reduce((sum, expense) => sum + expense.amountCents, 0),
@@ -121,7 +138,7 @@ export function ExpenseWorkspace({ categoryOptions }: Props) {
setFormState({
title: "",
amount: "",
date: new Date().toISOString().slice(0, 10),
date: getLocalToday(),
category: (categoryOptions[0]?.value as CategoryValue | undefined) ?? "MISC",
});
setSuggestionMessage(null);
@@ -182,6 +199,10 @@ export function ExpenseWorkspace({ categoryOptions }: Props) {
setExpenses((current) => [payload.expense, ...current]);
}
if (selectedMonth) {
await loadExpenses(selectedMonth);
}
setFormState((current) => ({ ...current, title: "", amount: "" }));
setSuggestionMessage(null);
setNeedsSuggestionConfirmation(false);
@@ -202,29 +223,34 @@ export function ExpenseWorkspace({ categoryOptions }: Props) {
return;
}
if (selectedMonth) {
await loadExpenses(selectedMonth);
return;
}
setExpenses((current) => current.filter((expense) => expense.id !== id));
}
return (
<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>
<p className="text-sm font-semibold uppercase tracking-[0.24em] text-amber-700">
<p className="text-sm font-semibold uppercase tracking-[0.24em] text-amber-700 dark:text-amber-500">
{editingId ? "Edit expense" : "Daily entry"}
</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">
{editingId ? "Update this entry" : "Log today\u2019s spend in seconds"}
</h2>
</div>
<div className="rounded-2xl bg-amber-50 px-4 py-3 text-right">
<p className="text-xs uppercase tracking-[0.2em] text-amber-700">Current list total</p>
<p className="mt-1 text-2xl font-semibold text-stone-950">{formatCurrencyFromCents(totalSpent)}</p>
<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 dark:text-amber-400">Current list total</p>
<p className="mt-1 text-2xl font-semibold text-stone-950 dark:text-white">{formatCurrencyFromCents(totalSpent)}</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 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
<input
required
@@ -237,35 +263,35 @@ export function ExpenseWorkspace({ categoryOptions }: Props) {
setNeedsSuggestionConfirmation(false);
setSuggestionMessage(null);
}}
className="rounded-2xl border border-stone-300 bg-stone-50 px-4 py-3 outline-none transition focus:border-stone-900"
className="rounded-2xl border border-stone-300 bg-stone-50 px-4 py-3 outline-none transition focus:border-stone-900 dark:border-stone-600 dark:bg-stone-800 dark:text-stone-200 dark:focus:border-stone-400"
placeholder="Groceries, rent, train pass..."
/>
</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
<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"
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"
/>
</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
<input
required
type="date"
value={formState.date}
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 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
<select
value={formState.category}
@@ -273,7 +299,7 @@ export function ExpenseWorkspace({ categoryOptions }: Props) {
setFormState((current) => ({ ...current, category: event.target.value as CategoryValue }));
setNeedsSuggestionConfirmation(false);
}}
className="rounded-2xl border border-stone-300 bg-stone-50 px-4 py-3 outline-none transition focus:border-stone-900"
className="rounded-2xl border border-stone-300 bg-stone-50 px-4 py-3 outline-none transition focus:border-stone-900 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}>
@@ -283,13 +309,13 @@ export function ExpenseWorkspace({ categoryOptions }: Props) {
</select>
</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">
<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"
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>
@@ -303,7 +329,7 @@ export function ExpenseWorkspace({ categoryOptions }: Props) {
<button
type="button"
onClick={handleCancelEdit}
className="rounded-full border border-stone-300 px-5 py-3 text-sm font-semibold text-stone-700 transition hover:border-stone-900"
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>
@@ -311,7 +337,7 @@ export function ExpenseWorkspace({ categoryOptions }: Props) {
<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"
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>
@@ -320,51 +346,67 @@ export function ExpenseWorkspace({ categoryOptions }: Props) {
</form>
</section>
<section className="rounded-[2rem] border border-stone-200 bg-[#fffaf2] 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">Recent entries</p>
<h2 className="mt-2 text-2xl font-semibold text-stone-950">Expense history</h2>
<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 flex flex-wrap items-end justify-between gap-4">
<div>
<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 className="space-y-3">
{expenses.length === 0 ? (
<div className="rounded-3xl border border-dashed border-stone-300 px-4 py-6 text-sm text-stone-600">
No expenses yet. Add your first entry to start the month.
<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">
{selectedMonth ? `No expenses recorded for ${getMonthLabel(selectedMonth)} yet.` : "No expenses yet. Add your first entry to start the month."}
</div>
) : (
expenses.map((expense) => (
<article
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 className="flex items-center gap-2">
<p className="font-semibold text-stone-950">{expense.title}</p>
<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">
<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">
<p className="mt-1 text-sm text-stone-600 dark:text-stone-400">
{expense.date} · {getCategoryLabel(expense.category)}
</p>
</div>
<div className="flex items-center gap-2">
<p className="mr-2 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>
{!expense.isRecurring && (
<>
<button
type="button"
onClick={() => handleEdit(expense)}
className="rounded-full border border-stone-300 px-3 py-2 text-xs font-semibold uppercase tracking-[0.2em] text-stone-600 transition hover:border-stone-900 hover:text-stone-900"
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"
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"
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>

View File

@@ -40,8 +40,12 @@ type OllamaStatus = {
message: string;
};
export function HomeDashboard() {
const [selectedMonth, setSelectedMonth] = useState(getCurrentMonthKey());
type HomeDashboardProps = {
initialMonth: string;
};
export function HomeDashboard({ initialMonth }: HomeDashboardProps) {
const [selectedMonth, setSelectedMonth] = useState(initialMonth);
const [snapshot, setSnapshot] = useState<DashboardSnapshot | null>(null);
const [error, setError] = useState<string | null>(null);
const [insightBusy, setInsightBusy] = useState(false);
@@ -68,6 +72,10 @@ export function HomeDashboard() {
}
useEffect(() => {
if (!selectedMonth) {
return;
}
const timeoutId = window.setTimeout(() => {
void loadDashboard(selectedMonth);
}, 0);
@@ -131,56 +139,56 @@ export function HomeDashboard() {
return (
<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">
<p className="text-sm font-semibold uppercase tracking-[0.28em] text-amber-700">Monthly Expense Tracker</p>
<h1 className="max-w-3xl text-5xl font-semibold leading-tight text-stone-950">
<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 dark:text-white">
A calm local-first home for everyday spending.
</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.
</p>
<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
</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
</Link>
</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>
<p className="text-sm font-semibold uppercase tracking-[0.24em] text-stone-500">Month to date</p>
<h2 className="mt-2 text-3xl font-semibold text-stone-950">
{snapshot ? getMonthLabel(snapshot.month) : getMonthLabel(selectedMonth)}
<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 dark:text-white">
{snapshot ? getMonthLabel(snapshot.month) : selectedMonth ? getMonthLabel(selectedMonth) : "Loading current month..."}
</h2>
</div>
<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"
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 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="mt-3 text-3xl font-semibold">{formatCurrencyFromCents(snapshot?.totals.expensesCents ?? 0)}</p>
</article>
<article className="rounded-3xl bg-emerald-50 px-4 py-5 text-stone-950">
<p className="text-xs uppercase tracking-[0.2em] text-emerald-700">Paychecks tracked</p>
<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 dark:text-emerald-400">Paychecks tracked</p>
<p className="mt-3 text-3xl font-semibold">{formatCurrencyFromCents(snapshot?.totals.paychecksCents ?? 0)}</p>
</article>
<article className="rounded-3xl bg-amber-50 px-4 py-5 text-stone-950">
<p className="text-xs uppercase tracking-[0.2em] text-amber-700">Net cash flow</p>
<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 dark:text-amber-400">Net cash flow</p>
<p className="mt-3 text-3xl font-semibold">{formatCurrencyFromCents(snapshot?.totals.netCashFlowCents ?? 0)}</p>
</article>
<article className="rounded-3xl bg-stone-100 px-4 py-5 text-stone-950">
<p className="text-xs uppercase tracking-[0.2em] text-stone-600">Average daily spend</p>
<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 dark:text-stone-400">Average daily spend</p>
<p className="mt-3 text-3xl font-semibold">{formatCurrencyFromCents(snapshot?.totals.averageDailySpendCents ?? 0)}</p>
</article>
</div>
@@ -188,27 +196,27 @@ export function HomeDashboard() {
</div>
</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 flex-wrap items-center justify-between gap-4">
<div>
<p className="text-sm font-semibold uppercase tracking-[0.24em] text-stone-500">Private monthly insight</p>
<h2 className="mt-2 text-3xl font-semibold text-stone-950">Offline guidance for this month</h2>
<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="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"
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">
<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">Ollama runtime</p>
<p className="mt-2 text-sm font-medium text-stone-700">
<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>
@@ -226,19 +234,19 @@ export function HomeDashboard() {
</span>
</div>
</div>
<div className="mt-4 grid gap-3 text-sm text-stone-600 sm:grid-cols-2">
<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">{ollamaStatus?.configuredModel ?? "-"}</span>
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">{ollamaStatus?.configuredUrl ?? "-"}</span>
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"
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>
@@ -247,14 +255,14 @@ export function HomeDashboard() {
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"
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"
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>
@@ -264,31 +272,31 @@ export function HomeDashboard() {
{snapshot?.insight ? (
<div className="mt-6 space-y-4">
{/* AI summary */}
<div className="rounded-3xl border border-amber-100 bg-gradient-to-br from-[#fffdf8] to-[#fff8ec] px-6 py-6">
<div className="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">AI Summary</span>
<span className="rounded-full bg-amber-100 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-amber-600"> Offline</span>
<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">{snapshot.insight.summary}</p>
<p className="mt-4 text-xs uppercase tracking-[0.2em] text-stone-400">
<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">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-stone-500">Spend vs Income</p>
<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">
<span className="text-2xl font-semibold text-stone-950 dark:text-white">
{formatCurrencyFromCents(snapshot.totals.expensesCents)}
</span>
<span className="text-sm text-stone-500">
<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">
<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={{
@@ -296,7 +304,7 @@ export function HomeDashboard() {
}}
/>
</div>
<p className="mt-2 text-xs text-stone-500">
<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"}
@@ -304,8 +312,8 @@ export function HomeDashboard() {
</div>
</div>
<div className="rounded-3xl border border-stone-200 bg-stone-50 px-5 py-5">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-stone-500">Category flow</p>
<div className="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 =
@@ -315,12 +323,12 @@ export function HomeDashboard() {
return (
<div key={item.category}>
<div className="flex items-center justify-between gap-2 text-xs">
<span className="text-stone-600">{getCategoryLabel(item.category as never)}</span>
<span className="font-semibold text-stone-900">{formatCurrencyFromCents(item.amountCents)}</span>
<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">
<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 transition-all"
className="h-1.5 rounded-full bg-stone-700 dark:bg-stone-400 transition-all"
style={{ width: `${pct.toFixed(1)}%` }}
/>
</div>
@@ -328,15 +336,15 @@ export function HomeDashboard() {
);
})}
{snapshot.categoryBreakdown.length === 0 && (
<p className="text-xs text-stone-400">No categories recorded yet.</p>
<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">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-stone-500">Next month guidance</p>
<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[];
@@ -347,11 +355,11 @@ export function HomeDashboard() {
items = [snapshot.insight.recommendations];
}
return items.map((item, i) => (
<div key={i} className="flex gap-3 rounded-2xl border border-stone-200 bg-white px-4 py-4">
<span className="mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-stone-950 text-[10px] font-bold text-white">
<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">{item}</p>
<p className="text-sm leading-6 text-stone-700 dark:text-stone-300">{item}</p>
</div>
));
})()}
@@ -359,69 +367,69 @@ export function HomeDashboard() {
</div>
</div>
) : (
<div className="mt-6 rounded-3xl border border-dashed border-stone-300 px-5 py-8 text-stone-600">
<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]">
<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>
<p className="text-sm font-semibold uppercase tracking-[0.24em] text-stone-500">Comparisons</p>
<h2 className="mt-2 text-3xl font-semibold text-stone-950">What stands out this month</h2>
<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 dark:text-white">What stands out this month</h2>
</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
</Link>
</div>
<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">
<p className="text-xs uppercase tracking-[0.2em] text-stone-500">Highest category</p>
<p className="mt-3 text-lg font-semibold text-stone-950">{topCategoryLabel}</p>
<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 dark:text-stone-400">Highest category</p>
<p className="mt-3 text-lg font-semibold text-stone-950 dark:text-white">{topCategoryLabel}</p>
</article>
<article className="rounded-3xl border border-stone-200 bg-[#f8faf7] px-4 py-5">
<p className="text-xs uppercase tracking-[0.2em] text-stone-500">Paycheck coverage</p>
<p className="mt-3 text-lg font-semibold text-stone-950">{coverageLabel}</p>
<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 dark:text-stone-400">Paycheck coverage</p>
<p className="mt-3 text-lg font-semibold text-stone-950 dark:text-white">{coverageLabel}</p>
</article>
<article className="rounded-3xl border border-stone-200 bg-white px-4 py-5 sm:col-span-2">
<p className="text-xs uppercase tracking-[0.2em] text-stone-500">Largest expense</p>
<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 dark:text-stone-400">Largest expense</p>
{snapshot?.comparisons.largestExpense ? (
<div className="mt-3 flex flex-wrap items-center justify-between gap-3">
<div>
<p className="text-lg font-semibold text-stone-950">{snapshot.comparisons.largestExpense.title}</p>
<p className="text-sm text-stone-600">
<p className="text-lg font-semibold text-stone-950 dark:text-white">{snapshot.comparisons.largestExpense.title}</p>
<p className="text-sm text-stone-600 dark:text-stone-400">
{snapshot.comparisons.largestExpense.date} · {getCategoryLabel(snapshot.comparisons.largestExpense.category as never)}
</p>
</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)}
</p>
</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>
</div>
</div>
<div className="rounded-[2rem] border border-stone-200 bg-white p-8 shadow-[0_24px_60px_rgba(120,90,50,0.08)]">
<p className="text-sm font-semibold uppercase tracking-[0.24em] text-stone-500">Category breakdown</p>
<h2 className="mt-2 text-3xl font-semibold text-stone-950">Where the month is going</h2>
<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 dark:text-stone-400">Category breakdown</p>
<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">
{snapshot?.categoryBreakdown.length ? (
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">
<p className="font-semibold text-stone-950">{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">{getCategoryLabel(item.category as never)}</p>
<p className="font-semibold text-stone-950 dark:text-white">{formatCurrencyFromCents(item.amountCents)}</p>
</div>
</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.
</div>
)}
@@ -429,13 +437,13 @@ export function HomeDashboard() {
</div>
</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>
<p className="text-sm font-semibold uppercase tracking-[0.24em] text-stone-500">Recent expense pulse</p>
<h2 className="mt-2 text-3xl font-semibold text-stone-950">Latest entries</h2>
<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 dark:text-white">Latest entries</h2>
</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
</Link>
</div>
@@ -443,25 +451,25 @@ export function HomeDashboard() {
<div className="mt-6 grid gap-3 md:grid-cols-2">
{snapshot?.recentExpenses.length ? (
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 className="flex items-center gap-2">
<p className="font-semibold text-stone-950">{expense.title}</p>
<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">
<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">
<p className="mt-1 text-sm text-stone-600 dark:text-stone-400">
{expense.date} · {getCategoryLabel(expense.category as never)}
</p>
</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>
))
) : (
<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.
</div>
)}

View File

@@ -2,7 +2,7 @@
import { useEffect, useMemo, useState, type FormEvent } from "react";
import { getCurrentMonthKey } from "@/lib/date";
import { getCurrentMonthKey, getLocalToday } from "@/lib/date";
import { formatCurrencyFromCents } from "@/lib/money";
type PaycheckRecord = {
@@ -21,11 +21,11 @@ export function PaycheckWorkspace() {
const [paychecks, setPaychecks] = useState<PaycheckRecord[]>([]);
const [schedule, setSchedule] = useState<PaySchedule | null>(null);
const [projectedDates, setProjectedDates] = useState<string[]>([]);
const [scheduleForm, setScheduleForm] = useState({ amount: "", anchorDate: new Date().toISOString().slice(0, 10) });
const [scheduleForm, setScheduleForm] = useState({ amount: "", anchorDate: "" });
const [showScheduleForm, setShowScheduleForm] = useState(false);
const [formState, setFormState] = useState({
amount: "",
payDate: new Date().toISOString().slice(0, 10),
payDate: "",
});
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -46,7 +46,15 @@ export function PaycheckWorkspace() {
}
}
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(
@@ -84,7 +92,7 @@ export function PaycheckWorkspace() {
setSchedule(payload.schedule);
setProjectedDates(computeProjectedDates(payload.schedule.anchorDate, getCurrentMonthKey()));
setShowScheduleForm(false);
setScheduleForm({ amount: "", anchorDate: new Date().toISOString().slice(0, 10) });
setScheduleForm({ amount: "", anchorDate: getLocalToday() });
}
async function handleClearSchedule() {
@@ -162,11 +170,11 @@ export function PaycheckWorkspace() {
return (
<div className="space-y-6">
{/* Biweekly schedule panel */}
<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)]">
<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>
<p className="text-sm font-semibold uppercase tracking-[0.24em] text-emerald-700">Biweekly schedule</p>
<h2 className="mt-1 text-2xl font-semibold text-stone-950">
<p className="text-sm font-semibold uppercase tracking-[0.24em] text-emerald-700 dark:text-emerald-400">Biweekly schedule</p>
<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>
@@ -175,7 +183,7 @@ export function PaycheckWorkspace() {
<button
type="button"
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"
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"
>
Edit
</button>
@@ -183,7 +191,7 @@ export function PaycheckWorkspace() {
type="button"
onClick={() => void handleClearSchedule()}
disabled={busy}
className="rounded-full border border-stone-300 px-4 py-2 text-xs font-semibold uppercase tracking-[0.2em] text-stone-600 transition hover:border-rose-400 hover:text-rose-600 disabled:cursor-not-allowed"
className="rounded-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"
>
Clear
</button>
@@ -203,39 +211,39 @@ export function PaycheckWorkspace() {
{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">
<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"
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">
<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"
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"
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"
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>
@@ -245,21 +253,21 @@ export function PaycheckWorkspace() {
{schedule && !showScheduleForm && (
<div className="mt-5">
<div className="flex flex-wrap gap-6 text-sm text-stone-600">
<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">{formatCurrencyFromCents(schedule.amountCents)}</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">Every 2 weeks</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">{schedule.anchorDate}</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">
<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">
@@ -270,19 +278,19 @@ export function PaycheckWorkspace() {
key={date}
className={`flex items-center gap-2 rounded-2xl border px-4 py-2 text-sm ${
confirmed
? "border-emerald-200 bg-emerald-50 text-emerald-800"
: "border-stone-200 bg-white text-stone-700"
? "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"> Confirmed</span>
<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"
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>
@@ -292,7 +300,7 @@ export function PaycheckWorkspace() {
})}
</div>
{pendingProjectedDates.length > 0 && (
<p className="mt-2 text-xs text-stone-500">
<p className="mt-2 text-xs text-stone-500 dark:text-stone-400">
{pendingProjectedDates.length} pending included in dashboard totals as projected income.
</p>
)}
@@ -304,48 +312,48 @@ export function PaycheckWorkspace() {
<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)]">
<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">One-off entry</p>
<h2 className="mt-2 text-3xl font-semibold text-stone-950">Log a bonus or extra deposit</h2>
<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">
<p className="text-xs uppercase tracking-[0.2em] text-emerald-700">Tracked paychecks</p>
<p className="mt-1 text-2xl font-semibold text-stone-950">{formatCurrencyFromCents(totalIncome)}</p>
<div 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">
<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"
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">
<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"
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">{error}</p>
<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"
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>
@@ -354,33 +362,33 @@ export function PaycheckWorkspace() {
</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)]">
<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">Income history</p>
<h2 className="mt-2 text-2xl font-semibold text-stone-950">Recent paychecks</h2>
<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">
<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 &quot;Mark received&quot; 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"
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">Paycheck</p>
<p className="mt-1 text-sm text-stone-600">{paycheck.payDate}</p>
<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">{formatCurrencyFromCents(paycheck.amountCents)}</p>
<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"
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>

View File

@@ -120,18 +120,18 @@ export function RecurringExpenseManager({ categoryOptions }: Props) {
}
return (
<section className="rounded-[2rem] border border-amber-200 bg-amber-50 p-6 shadow-[0_24px_60px_rgba(120,90,50,0.08)]">
<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">Fixed monthly costs</p>
<h2 className="mt-2 text-2xl font-semibold text-stone-950">Recurring expenses</h2>
<p className="mt-1 text-sm text-stone-600">These appear automatically in every month without manual entry.</p>
<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"
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>
@@ -141,36 +141,36 @@ export function RecurringExpenseManager({ categoryOptions }: Props) {
{(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"
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 md:col-span-2">
<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 md:col-span-2">
<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"
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">
<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"
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">
<label className="grid gap-1.5 text-sm font-medium text-stone-700 dark:text-stone-300">
Day of month
<input
required
@@ -179,17 +179,17 @@ export function RecurringExpenseManager({ categoryOptions }: Props) {
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"
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 md:col-span-2">
<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"
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}>
@@ -205,14 +205,14 @@ export function RecurringExpenseManager({ categoryOptions }: Props) {
<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"
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"
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>
@@ -226,7 +226,7 @@ export function RecurringExpenseManager({ categoryOptions }: Props) {
)}
{definitions.length === 0 && !showAddForm ? (
<div className="rounded-3xl border border-dashed border-amber-300 px-4 py-6 text-sm text-stone-600">
<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>
) : (
@@ -236,24 +236,24 @@ export function RecurringExpenseManager({ categoryOptions }: Props) {
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"
: "border-amber-200 bg-white"
? "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">{def.title}</p>
<p className="mt-1 text-sm text-stone-600">
<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">{formatCurrencyFromCents(def.amountCents)}</p>
<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"
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>
@@ -261,7 +261,7 @@ export function RecurringExpenseManager({ categoryOptions }: Props) {
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"
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>

View File

@@ -13,7 +13,7 @@ export function SiteNav() {
const pathname = usePathname();
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) => {
const isActive = link.href === "/" ? pathname === "/" : pathname.startsWith(link.href);
return (
@@ -22,8 +22,8 @@ export function SiteNav() {
href={link.href}
className={
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"
: "rounded-full border border-stone-300/80 bg-white/80 px-4 py-2 transition hover:border-stone-900 hover:text-stone-950"
? "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}

View 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>
);
}

View File

@@ -18,6 +18,7 @@ vi.mock("@/lib/db", () => {
db: {
expense: { findMany: vi.fn() },
paycheck: { findMany: vi.fn() },
recurringExpense: { findMany: vi.fn().mockResolvedValue([]) },
paySchedule: { findFirst: vi.fn().mockResolvedValue(null) },
monthlyInsight,
},
@@ -35,6 +36,7 @@ describe("generateMonthlyInsight", () => {
vi.mocked(db.expense.findMany).mockResolvedValue([]);
vi.mocked(db.paycheck.findMany).mockResolvedValue([]);
vi.mocked(db.recurringExpense.findMany).mockResolvedValue([]);
const result = await generateMonthlyInsight("2026-03");
@@ -72,6 +74,7 @@ describe("generateMonthlyInsight", () => {
createdAt: new Date("2026-03-01T10:00:00.000Z"),
},
]);
vi.mocked(db.recurringExpense.findMany).mockResolvedValue([]);
vi.spyOn(globalThis, "fetch").mockResolvedValue({
ok: true,
@@ -120,6 +123,7 @@ describe("generateMonthlyInsight", () => {
createdAt: new Date("2026-03-01T10:00:00.000Z"),
},
]);
vi.mocked(db.recurringExpense.findMany).mockResolvedValue([]);
vi.spyOn(globalThis, "fetch").mockResolvedValue({
ok: true,

View File

@@ -112,8 +112,8 @@ function buildInsightPrompt(snapshot: Awaited<ReturnType<typeof getDashboardSnap
? `Pay schedule: biweekly, ${centsToDisplay(snapshot.paySchedule.amountCents)} per paycheck, projected pay dates this month: ${snapshot.paySchedule.projectedDates.join(", ") || "none"}`
: null,
`Category breakdown: ${JSON.stringify(categoryBreakdown)}`,
`Recent expenses: ${JSON.stringify(snapshot.recentExpenses)}`,
`Daily chart points: ${JSON.stringify(snapshot.chart)}`,
`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)

1
tsconfig.tsbuildinfo Normal file

File diff suppressed because one or more lines are too long