Compare commits
8 Commits
5f2111ea66
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| feb2f2c37e | |||
| c852ad0d80 | |||
| d2c230b4f9 | |||
| 8dcddbf278 | |||
| e3ac732b1b | |||
| 2dae000342 | |||
| 48b481999d | |||
| 012385e9e1 |
80
AGENT_HANDOFF.md
Normal file
80
AGENT_HANDOFF.md
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
# Monthy Tracker Agent Handoff
|
||||||
|
|
||||||
|
Use this file as the starting point for any new agent. It summarizes the repo state, the important files, and the non-obvious behavior so you can continue without re-discovering basics.
|
||||||
|
|
||||||
|
## What this app is
|
||||||
|
|
||||||
|
- Single-user, local-first monthly expense tracker.
|
||||||
|
- Stack: Next.js App Router, Prisma, SQLite, Ollama for offline AI.
|
||||||
|
- Main views: dashboard, add expense, income/paychecks.
|
||||||
|
- Goal: track expenses and paychecks, then generate private monthly summaries and recommendations.
|
||||||
|
|
||||||
|
## Current state
|
||||||
|
|
||||||
|
- Dashboard data is loaded from `/dashboard?month=YYYY-MM`.
|
||||||
|
- Monthly insight generation lives in `src/lib/insights.ts`.
|
||||||
|
- The app is currently working with the local dev DB at `file:./prisma/dev.db`.
|
||||||
|
- Seed data exists for February 2026 and March 2026.
|
||||||
|
- The frontend shows the current month by default, so if data looks missing, check the active month selector first.
|
||||||
|
|
||||||
|
## Important files
|
||||||
|
|
||||||
|
- `src/components/home-dashboard.tsx` - dashboard UI, fetches dashboard and Ollama status.
|
||||||
|
- `src/lib/dashboard.ts` - aggregates totals, breakdowns, recent expenses, and comparisons.
|
||||||
|
- `src/lib/insights.ts` - builds the model prompt, calls Ollama, stores fallback/model insights.
|
||||||
|
- `src/app/dashboard/route.ts` - dashboard API route.
|
||||||
|
- `src/app/backup/database/route.ts` - SQLite backup endpoint.
|
||||||
|
- `src/lib/date.ts` - month/date helpers; month filtering uses local date strings.
|
||||||
|
- `prisma/schema.prisma` - data model source of truth.
|
||||||
|
- `prisma/seed.ts` - local seed script.
|
||||||
|
- `README.md` - setup and runtime notes.
|
||||||
|
- `openspec/changes/monthly-expense-tracker-v1/` - design/spec/task source for the product.
|
||||||
|
|
||||||
|
## Key behavior to know
|
||||||
|
|
||||||
|
- Dashboard aggregates all records, then filters them to the selected month in memory.
|
||||||
|
- `getCurrentMonthKey()` uses local machine time.
|
||||||
|
- `generateMonthlyInsight()` in `src/lib/insights.ts`:
|
||||||
|
- builds a structured prompt from dashboard totals, category breakdown, recent expenses, and chart points
|
||||||
|
- expects strict JSON with `summary` and `recommendations`
|
||||||
|
- falls back when activity is sparse or Ollama is unavailable
|
||||||
|
- stores the final result in `MonthlyInsight`
|
||||||
|
- The fallback message is intentionally safe and short when data is too sparse.
|
||||||
|
|
||||||
|
## Known gotchas
|
||||||
|
|
||||||
|
- If dashboard values appear missing, confirm the selected month matches the seeded data.
|
||||||
|
- If Docker backup fails, check `DATABASE_URL`; the backup route reads the SQLite path from the env var.
|
||||||
|
- Prisma migration ordering matters because migration folders are applied lexicographically.
|
||||||
|
- In Docker Compose on Linux, `extra_hosts: host-gateway` is needed for host Ollama access.
|
||||||
|
|
||||||
|
## Current enhancement target
|
||||||
|
|
||||||
|
- The next useful improvement is making the generated monthly summary more helpful and more structured.
|
||||||
|
- Best place to change it: `src/lib/insights.ts`.
|
||||||
|
- Likely changes:
|
||||||
|
- strengthen the prompt with clearer sections or required observations
|
||||||
|
- improve the fallback logic for sparse months
|
||||||
|
- optionally store more structured fields in `MonthlyInsight`
|
||||||
|
- update the dashboard card rendering in `src/components/home-dashboard.tsx` if the output shape changes
|
||||||
|
|
||||||
|
## Useful commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
npx prisma migrate deploy
|
||||||
|
npx prisma db seed
|
||||||
|
npm test
|
||||||
|
```
|
||||||
|
|
||||||
|
If you need to verify the dashboard API directly:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl "http://localhost:3000/dashboard?month=2026-03"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Recommended next step for another agent
|
||||||
|
|
||||||
|
1. Read `src/lib/insights.ts`, `src/lib/dashboard.ts`, and `prisma/schema.prisma`.
|
||||||
|
2. Decide whether to improve the insight prompt, the stored data shape, or the dashboard rendering.
|
||||||
|
3. Make the change, then verify with the dashboard API and a generated insight.
|
||||||
2
next-env.d.ts
vendored
2
next-env.d.ts
vendored
@@ -1,6 +1,6 @@
|
|||||||
/// <reference types="next" />
|
/// <reference types="next" />
|
||||||
/// <reference types="next/image-types/global" />
|
/// <reference types="next/image-types/global" />
|
||||||
import "./.next/dev/types/routes.d.ts";
|
import "./.next/types/routes.d.ts";
|
||||||
|
|
||||||
// NOTE: This file should not be edited
|
// NOTE: This file should not be edited
|
||||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||||
|
|||||||
@@ -18,6 +18,13 @@ The system SHALL allow the user to list recorded expenses and delete a specific
|
|||||||
- **WHEN** the user requests expenses for the app
|
- **WHEN** the user requests expenses for the app
|
||||||
- **THEN** the system returns stored expenses in a stable order with their recorded fields
|
- **THEN** the system returns stored expenses in a stable order with their recorded fields
|
||||||
|
|
||||||
|
### Requirement: User can browse expense history by month
|
||||||
|
The system SHALL allow the user to select a `YYYY-MM` month when reviewing expense history and SHALL return the expenses recorded for that month.
|
||||||
|
|
||||||
|
#### Scenario: Prior month entries are visible
|
||||||
|
- **WHEN** the user selects February 2026 in the add-expense history view
|
||||||
|
- **THEN** the system shows the expenses recorded in February 2026 and exposes delete actions for deletable entries in that month
|
||||||
|
|
||||||
#### Scenario: Expense is deleted
|
#### Scenario: Expense is deleted
|
||||||
- **WHEN** the user deletes an existing expense
|
- **WHEN** the user deletes an existing expense
|
||||||
- **THEN** the system removes that expense and it no longer appears in future listings or aggregates
|
- **THEN** the system removes that expense and it no longer appears in future listings or aggregates
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-03-23
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
## Context
|
||||||
|
|
||||||
|
The project uses Next.js with Tailwind CSS v4 and a warm stone/amber palette. All styling is done via Tailwind utility classes directly in JSX. There are no separate CSS modules for components. The root layout owns the `<html>` and `<body>` tags, making it the natural place to manage the theme class and prevent flash-of-unstyled-content.
|
||||||
|
|
||||||
|
## Goals / Non-Goals
|
||||||
|
|
||||||
|
**Goals:**
|
||||||
|
- Implement class-based dark mode that works with Tailwind v4's `@custom-variant` API.
|
||||||
|
- Prevent theme flash on page load with an inline blocking script.
|
||||||
|
- Persist user preference in `localStorage` under the key `theme`.
|
||||||
|
- Fall back to system preference (`prefers-color-scheme`) when no preference is saved.
|
||||||
|
- Keep the dark palette warm and consistent with the existing amber/stone design language.
|
||||||
|
|
||||||
|
**Non-Goals:**
|
||||||
|
- Server-side theme rendering or cookies (local-only app, no SSR theme concerns beyond flash prevention).
|
||||||
|
- Per-page or per-component theme overrides.
|
||||||
|
- Animated theme transitions beyond the existing `transition-duration: 180ms` on interactive elements.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
### Use Tailwind v4 `@custom-variant` for class-based dark mode
|
||||||
|
- Rationale: Tailwind v4's default dark mode is media-query-based. Adding `@custom-variant dark (&:where(.dark, .dark *))` in `globals.css` enables the `dark:` prefix to respond to a class on any ancestor, which is the standard pattern for toggle-able dark mode.
|
||||||
|
- Alternative considered: Continue using media-query dark mode with no toggle. Rejected because users cannot override system preference.
|
||||||
|
|
||||||
|
### Store theme preference in `localStorage`
|
||||||
|
- Rationale: Simple, synchronous, requires no server or cookies. Reads correctly in the blocking inline script.
|
||||||
|
- Alternative considered: Cookies for SSR. Rejected because this app is local-first and has no meaningful SSR theme benefit.
|
||||||
|
|
||||||
|
### Inject an inline blocking script in `<head>` to set the `dark` class before first paint
|
||||||
|
- Rationale: Prevents the flash where the page briefly renders in light mode before React hydrates and reads `localStorage`. The script is small and runs synchronously.
|
||||||
|
- Alternative considered: Set the class in a React `useEffect`. Rejected because `useEffect` runs after paint, causing a visible flash.
|
||||||
|
|
||||||
|
### Warm deep-stone dark palette
|
||||||
|
- Rationale: The light theme is built on warm stone and amber tones. The dark theme mirrors this with deep stone backgrounds (`stone-950`, `stone-900`, `stone-800`) and dimmed amber/emerald accents, keeping the visual identity coherent.
|
||||||
|
- Alternative considered: Neutral dark greys. Rejected because they clash with the warm amber accents and feel disconnected from the brand.
|
||||||
|
|
||||||
|
## Risks / Trade-offs
|
||||||
|
|
||||||
|
- [Inline script adds a small amount of render-blocking HTML] → Acceptable; the script is under 200 bytes and only runs once per page load.
|
||||||
|
- [LocalStorage is unavailable in some privacy modes] → The script wraps the read in a try/catch and falls back to system preference.
|
||||||
|
- [Many components have hardcoded warm background strings like `bg-[#fffaf2]`] → These are replaced with equivalent Tailwind tokens plus `dark:` overrides so the mapping is explicit and maintainable.
|
||||||
|
|
||||||
|
## Migration Plan
|
||||||
|
|
||||||
|
1. Update `globals.css` with `@custom-variant dark` and dark-mode CSS variable overrides.
|
||||||
|
2. Create the `ThemeToggle` client component.
|
||||||
|
3. Update `layout.tsx` to add the blocking script and render `ThemeToggle` in the header.
|
||||||
|
4. Update `site-nav.tsx` with dark-mode nav styles.
|
||||||
|
5. Update `home-dashboard.tsx`, `expense-workspace.tsx`, `paycheck-workspace.tsx`, and `recurring-expense-manager.tsx` with `dark:` variants.
|
||||||
|
6. Update page-level header text that uses hardcoded colour classes.
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
- Should the toggle also expose a "System" option (three-way: Light / Dark / System) rather than a binary flip? Deferred to a follow-up; the initialisation script already handles system fallback on first visit.
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
## Why
|
||||||
|
|
||||||
|
The app currently ships with a warm light theme only. Adding a system-aware dark mode with a manual toggle removes eye strain in low-light conditions and meets baseline accessibility expectations for a personal finance tool used at any hour.
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
- Add a class-based dark mode variant using Tailwind v4 `@custom-variant` so all `dark:` utilities are controlled by a `dark` class on `<html>`.
|
||||||
|
- Add a `ThemeToggle` component that persists the user's preference in `localStorage` and initialises the correct class before first paint to prevent flash.
|
||||||
|
- Update the root layout to inject the inline initialisation script and render the toggle inside the existing header.
|
||||||
|
- Apply `dark:` variants to all UI components to produce a warm, deep-stone dark palette consistent with the existing amber/stone design language.
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
### New Capabilities
|
||||||
|
- `dark-mode`: Users can switch between light and dark themes with a toggle that persists across sessions. System preference is respected on first visit.
|
||||||
|
|
||||||
|
### Modified Capabilities
|
||||||
|
- `expense-tracking`: Expense workspace and recurring expense manager now render correctly in both themes.
|
||||||
|
- `paycheck-tracking`: Paycheck workspace now renders correctly in both themes.
|
||||||
|
- `monthly-dashboard`: Dashboard sections, insight cards, category bars, and stat cards now render correctly in both themes.
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
- Affected code: `globals.css`, `layout.tsx`, all components under `src/components/`, and page header text in `src/app/`.
|
||||||
|
- APIs: None.
|
||||||
|
- Dependencies: None — uses Tailwind v4 built-ins and the Web Storage API.
|
||||||
|
- Systems: No server-side changes required; theme state is fully client-side.
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: User can switch between light and dark themes
|
||||||
|
The system SHALL allow the user to toggle between light and dark themes with a persistent preference.
|
||||||
|
|
||||||
|
#### Scenario: User toggles dark mode on
|
||||||
|
- **WHEN** the user activates the theme toggle while the app is in light mode
|
||||||
|
- **THEN** the system applies the dark theme and saves the preference for future visits
|
||||||
|
|
||||||
|
#### Scenario: User toggles dark mode off
|
||||||
|
- **WHEN** the user activates the theme toggle while the app is in dark mode
|
||||||
|
- **THEN** the system applies the light theme and saves the preference for future visits
|
||||||
|
|
||||||
|
### Requirement: Theme preference respects system defaults on first visit
|
||||||
|
The system SHALL use the user's system color scheme preference when no saved preference exists.
|
||||||
|
|
||||||
|
#### Scenario: No stored preference uses system theme
|
||||||
|
- **WHEN** the user opens the app for the first time without a saved theme preference
|
||||||
|
- **THEN** the system applies dark mode when the operating system prefers dark color schemes and light mode otherwise
|
||||||
|
|
||||||
|
### Requirement: Theme selection loads without a flash of the wrong theme
|
||||||
|
The system SHALL initialize the theme before the first visible paint so the page does not briefly render in the wrong theme.
|
||||||
|
|
||||||
|
#### Scenario: Initial paint matches saved theme
|
||||||
|
- **WHEN** the app loads and a saved theme preference exists
|
||||||
|
- **THEN** the document theme is applied before the page content is visibly rendered
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Expense tracking UI renders correctly in both themes
|
||||||
|
The system SHALL render the expense tracking workspace, history list, forms, and item states with readable contrast in both light and dark themes.
|
||||||
|
|
||||||
|
#### Scenario: Expense workspace renders in dark mode
|
||||||
|
- **WHEN** the user opens the expense tracking view while dark mode is active
|
||||||
|
- **THEN** the form card, history card, inputs, actions, and expense rows use dark-compatible colors and remain readable
|
||||||
|
|
||||||
|
#### Scenario: Expense workspace renders in light mode
|
||||||
|
- **WHEN** the user opens the expense tracking view while light mode is active
|
||||||
|
- **THEN** the form card, history card, inputs, actions, and expense rows use light-compatible colors and remain readable
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Monthly dashboard UI renders correctly in both themes
|
||||||
|
The system SHALL render dashboard sections, insight cards, category bars, stat tiles, and empty states with readable contrast in both light and dark themes.
|
||||||
|
|
||||||
|
#### Scenario: Dashboard renders in dark mode
|
||||||
|
- **WHEN** the user opens the dashboard while dark mode is active
|
||||||
|
- **THEN** the summary cards, comparison cards, progress bars, and empty states use dark-compatible colors and remain readable
|
||||||
|
|
||||||
|
#### Scenario: Dashboard renders in light mode
|
||||||
|
- **WHEN** the user opens the dashboard while light mode is active
|
||||||
|
- **THEN** the summary cards, comparison cards, progress bars, and empty states use light-compatible colors and remain readable
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Paycheck tracking UI renders correctly in both themes
|
||||||
|
The system SHALL render the paycheck tracking workspace, schedule panel, form, and list items with readable contrast in both light and dark themes.
|
||||||
|
|
||||||
|
#### Scenario: Paycheck workspace renders in dark mode
|
||||||
|
- **WHEN** the user opens the paycheck tracking view while dark mode is active
|
||||||
|
- **THEN** the schedule panel, form card, inputs, and paycheck rows use dark-compatible colors and remain readable
|
||||||
|
|
||||||
|
#### Scenario: Paycheck workspace renders in light mode
|
||||||
|
- **WHEN** the user opens the paycheck tracking view while light mode is active
|
||||||
|
- **THEN** the schedule panel, form card, inputs, and paycheck rows use light-compatible colors and remain readable
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
## 1. CSS and variant setup
|
||||||
|
|
||||||
|
- [x] 1.1 Add `@custom-variant dark (&:where(.dark, .dark *))` to `globals.css` and update `:root` / `@theme` blocks with dark-mode CSS variable overrides.
|
||||||
|
|
||||||
|
## 2. Theme toggle infrastructure
|
||||||
|
|
||||||
|
- [x] 2.1 Create `src/components/theme-toggle.tsx` — a client component that reads and writes `localStorage` theme preference, toggles the `dark` class on `<html>`, and renders a sun/moon button.
|
||||||
|
- [x] 2.2 Update `src/app/layout.tsx` to inject the inline blocking script in `<head>` and render `ThemeToggle` in the header alongside `SiteNav`.
|
||||||
|
|
||||||
|
## 3. Component dark-mode styles
|
||||||
|
|
||||||
|
- [x] 3.1 Update `src/components/site-nav.tsx` with `dark:` variants for link backgrounds, borders, and text.
|
||||||
|
- [x] 3.2 Update `src/components/home-dashboard.tsx` with `dark:` variants for all section cards, stat tiles, insight blocks, progress bars, and empty states.
|
||||||
|
- [x] 3.3 Update `src/components/expense-workspace.tsx` with `dark:` variants for the form card, list card, inputs, and expense articles.
|
||||||
|
- [x] 3.4 Update `src/components/paycheck-workspace.tsx` with `dark:` variants for the schedule panel, form, and paycheck list.
|
||||||
|
- [x] 3.5 Update `src/components/recurring-expense-manager.tsx` with `dark:` variants for the panel, inline form, and definition articles.
|
||||||
|
|
||||||
|
## 4. Page header text
|
||||||
|
|
||||||
|
- [x] 4.1 Update hardcoded text colours in `src/app/add-expense/page.tsx` and `src/app/income/page.tsx` with `dark:` overrides.
|
||||||
|
|
||||||
|
## 5. Verification
|
||||||
|
|
||||||
|
- [x] 5.1 Visually verify light and dark modes across the dashboard, add-expense, and income pages in the browser.
|
||||||
|
- [x] 5.2 Verify that theme preference persists across page refreshes and that there is no flash of the wrong theme on load.
|
||||||
27
openspec/specs/category-suggestion/spec.md
Normal file
27
openspec/specs/category-suggestion/spec.md
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
## Purpose
|
||||||
|
|
||||||
|
TBD
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
### Requirement: System suggests categories from merchant names
|
||||||
|
The system SHALL support merchant-name-based category suggestion for expense entry while keeping all suggestion logic fully offline.
|
||||||
|
|
||||||
|
#### Scenario: Known merchant resolves from deterministic rules
|
||||||
|
- **WHEN** the user enters a merchant or shop name that matches a known merchant rule
|
||||||
|
- **THEN** the system assigns the mapped category without needing model inference
|
||||||
|
|
||||||
|
#### Scenario: Unknown merchant falls back to local model
|
||||||
|
- **WHEN** the user enters a merchant or shop name that does not match a known merchant rule
|
||||||
|
- **THEN** the system asks the local AI service for a category suggestion and returns the suggested category
|
||||||
|
|
||||||
|
### Requirement: Ambiguous suggestions remain user-controlled
|
||||||
|
The system SHALL keep the final saved category under user control for ambiguous or model-generated suggestions.
|
||||||
|
|
||||||
|
#### Scenario: User confirms model suggestion before save
|
||||||
|
- **WHEN** the category suggestion comes from model inference instead of a deterministic rule
|
||||||
|
- **THEN** the user can review and confirm or change the category before the expense is saved
|
||||||
|
|
||||||
|
#### Scenario: No cloud fallback is used
|
||||||
|
- **WHEN** the local suggestion service is unavailable
|
||||||
|
- **THEN** the system continues to allow manual category selection and does not send merchant data to a hosted provider
|
||||||
30
openspec/specs/dark-mode/spec.md
Normal file
30
openspec/specs/dark-mode/spec.md
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
## Purpose
|
||||||
|
|
||||||
|
TBD
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
### Requirement: User can switch between light and dark themes
|
||||||
|
The system SHALL allow the user to toggle between light and dark themes with a persistent preference.
|
||||||
|
|
||||||
|
#### Scenario: User toggles dark mode on
|
||||||
|
- **WHEN** the user activates the theme toggle while the app is in light mode
|
||||||
|
- **THEN** the system applies the dark theme and saves the preference for future visits
|
||||||
|
|
||||||
|
#### Scenario: User toggles dark mode off
|
||||||
|
- **WHEN** the user activates the theme toggle while the app is in dark mode
|
||||||
|
- **THEN** the system applies the light theme and saves the preference for future visits
|
||||||
|
|
||||||
|
### Requirement: Theme preference respects system defaults on first visit
|
||||||
|
The system SHALL use the user's system color scheme preference when no saved preference exists.
|
||||||
|
|
||||||
|
#### Scenario: No stored preference uses system theme
|
||||||
|
- **WHEN** the user opens the app for the first time without a saved theme preference
|
||||||
|
- **THEN** the system applies dark mode when the operating system prefers dark color schemes and light mode otherwise
|
||||||
|
|
||||||
|
### Requirement: Theme selection loads without a flash of the wrong theme
|
||||||
|
The system SHALL initialize the theme before the first visible paint so the page does not briefly render in the wrong theme.
|
||||||
|
|
||||||
|
#### Scenario: Initial paint matches saved theme
|
||||||
|
- **WHEN** the app loads and a saved theme preference exists
|
||||||
|
- **THEN** the document theme is applied before the page content is visibly rendered
|
||||||
47
openspec/specs/expense-tracking/spec.md
Normal file
47
openspec/specs/expense-tracking/spec.md
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
## Purpose
|
||||||
|
|
||||||
|
TBD
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
### Requirement: User can record categorized expenses
|
||||||
|
The system SHALL allow the user to create an expense with a title, amount, category, and local calendar date using fixed starter categories.
|
||||||
|
|
||||||
|
#### Scenario: Valid expense is created
|
||||||
|
- **WHEN** the user submits a title, positive amount, valid category, and valid local date
|
||||||
|
- **THEN** the system stores the expense and returns the created record
|
||||||
|
|
||||||
|
#### Scenario: Invalid expense is rejected
|
||||||
|
- **WHEN** the user submits a missing title, invalid amount, invalid category, or invalid date
|
||||||
|
- **THEN** the system rejects the request with a validation error and does not store the expense
|
||||||
|
|
||||||
|
### Requirement: User can review and delete expenses
|
||||||
|
The system SHALL allow the user to list recorded expenses and delete a specific expense by identifier.
|
||||||
|
|
||||||
|
#### Scenario: Expenses are listed
|
||||||
|
- **WHEN** the user requests expenses for the app
|
||||||
|
- **THEN** the system returns stored expenses in a stable order with their recorded fields
|
||||||
|
|
||||||
|
### Requirement: User can browse expense history by month
|
||||||
|
The system SHALL allow the user to select a `YYYY-MM` month when reviewing expense history and SHALL return the expenses recorded for that month.
|
||||||
|
|
||||||
|
#### Scenario: Prior month entries are visible
|
||||||
|
- **WHEN** the user selects February 2026 in the add-expense history view
|
||||||
|
- **THEN** the system shows the expenses recorded in February 2026 and exposes delete actions for deletable entries in that month
|
||||||
|
|
||||||
|
#### Scenario: Expense is deleted
|
||||||
|
- **WHEN** the user deletes an existing expense
|
||||||
|
- **THEN** the system removes that expense and it no longer appears in future listings or aggregates
|
||||||
|
|
||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Expense tracking UI renders correctly in both themes
|
||||||
|
The system SHALL render the expense tracking workspace, history list, forms, and item states with readable contrast in both light and dark themes.
|
||||||
|
|
||||||
|
#### Scenario: Expense workspace renders in dark mode
|
||||||
|
- **WHEN** the user opens the expense tracking view while dark mode is active
|
||||||
|
- **THEN** the form card, history card, inputs, actions, and expense rows use dark-compatible colors and remain readable
|
||||||
|
|
||||||
|
#### Scenario: Expense workspace renders in light mode
|
||||||
|
- **WHEN** the user opens the expense tracking view while light mode is active
|
||||||
|
- **THEN** the form card, history card, inputs, actions, and expense rows use light-compatible colors and remain readable
|
||||||
40
openspec/specs/monthly-dashboard/spec.md
Normal file
40
openspec/specs/monthly-dashboard/spec.md
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
## Purpose
|
||||||
|
|
||||||
|
TBD
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
### Requirement: Dashboard shows month-specific financial totals
|
||||||
|
The system SHALL return month-specific dashboard data for a requested `YYYY-MM` month using the local machine timezone for month boundaries.
|
||||||
|
|
||||||
|
#### Scenario: Dashboard totals are calculated for a populated month
|
||||||
|
- **WHEN** the user requests the dashboard for a month with expenses and paychecks
|
||||||
|
- **THEN** the system returns total expenses, total paychecks, net cash flow, and a category breakdown for that month
|
||||||
|
|
||||||
|
#### Scenario: Dashboard supports partial current-month data
|
||||||
|
- **WHEN** the user requests the dashboard for the current month before the month is complete
|
||||||
|
- **THEN** the system returns meaningful month-to-date totals and comparisons using the transactions recorded so far
|
||||||
|
|
||||||
|
### Requirement: Dashboard includes derived spending comparisons
|
||||||
|
The system SHALL provide derived comparisons for the selected month, including highest category, largest expense, average daily spend, and paycheck coverage information.
|
||||||
|
|
||||||
|
#### Scenario: Derived comparisons are available
|
||||||
|
- **WHEN** the selected month contains enough data for comparisons
|
||||||
|
- **THEN** the system returns the highest category, largest single expense, average daily spend, and spend-versus-paycheck coverage values
|
||||||
|
|
||||||
|
#### Scenario: Derived comparisons degrade safely for sparse data
|
||||||
|
- **WHEN** the selected month has no expenses or otherwise insufficient data for a comparison
|
||||||
|
- **THEN** the system returns null or empty-safe comparison fields instead of failing
|
||||||
|
|
||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Monthly dashboard UI renders correctly in both themes
|
||||||
|
The system SHALL render dashboard sections, insight cards, category bars, stat tiles, and empty states with readable contrast in both light and dark themes.
|
||||||
|
|
||||||
|
#### Scenario: Dashboard renders in dark mode
|
||||||
|
- **WHEN** the user opens the dashboard while dark mode is active
|
||||||
|
- **THEN** the summary cards, comparison cards, progress bars, and empty states use dark-compatible colors and remain readable
|
||||||
|
|
||||||
|
#### Scenario: Dashboard renders in light mode
|
||||||
|
- **WHEN** the user opens the dashboard while light mode is active
|
||||||
|
- **THEN** the summary cards, comparison cards, progress bars, and empty states use light-compatible colors and remain readable
|
||||||
34
openspec/specs/monthly-insights/spec.md
Normal file
34
openspec/specs/monthly-insights/spec.md
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
## Purpose
|
||||||
|
|
||||||
|
TBD
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
### Requirement: User can generate monthly AI insights on demand
|
||||||
|
The system SHALL allow the user to manually generate monthly AI insights for any month with existing or sparse data by sending structured monthly context to a fully offline local inference runtime.
|
||||||
|
|
||||||
|
#### Scenario: Insights are generated for a month with data
|
||||||
|
- **WHEN** the user requests insight generation for a month with recorded activity
|
||||||
|
- **THEN** the system sends monthly aggregates plus transaction samples to the local AI service and returns a rendered narrative summary with structured supporting totals
|
||||||
|
|
||||||
|
#### Scenario: Prior month insights can be generated
|
||||||
|
- **WHEN** the user requests insight generation for a previous month that has recorded data
|
||||||
|
- **THEN** the system generates and stores insight output for that requested month
|
||||||
|
|
||||||
|
### Requirement: Insight generation is read-only and safe for sparse months
|
||||||
|
The system SHALL keep AI insight generation read-only and return a safe fallback summary when a month does not have enough data for meaningful guidance.
|
||||||
|
|
||||||
|
#### Scenario: Sparse month returns fallback insight
|
||||||
|
- **WHEN** the user requests insight generation for a month with empty or near-empty data
|
||||||
|
- **THEN** the system returns a fallback message instead of low-confidence advice
|
||||||
|
|
||||||
|
#### Scenario: AI does not mutate financial records
|
||||||
|
- **WHEN** the system generates or stores monthly insights
|
||||||
|
- **THEN** no expense or paycheck records are created, updated, or deleted as part of that request
|
||||||
|
|
||||||
|
### Requirement: Insight generation remains private and resilient offline
|
||||||
|
The system SHALL keep monthly insight generation fully offline and provide a clear fallback response when the local model runtime or selected model is unavailable.
|
||||||
|
|
||||||
|
#### Scenario: Local runtime is unavailable
|
||||||
|
- **WHEN** the user requests monthly insights while the local AI runtime is not running or the configured model is unavailable
|
||||||
|
- **THEN** the system returns a clear setup or availability message instead of attempting a cloud fallback
|
||||||
40
openspec/specs/paycheck-tracking/spec.md
Normal file
40
openspec/specs/paycheck-tracking/spec.md
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
## Purpose
|
||||||
|
|
||||||
|
TBD
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
### Requirement: User can record paychecks by pay date
|
||||||
|
The system SHALL allow the user to create a paycheck with a positive amount and a local pay date.
|
||||||
|
|
||||||
|
#### Scenario: Valid paycheck is created
|
||||||
|
- **WHEN** the user submits a positive amount and valid local pay date
|
||||||
|
- **THEN** the system stores the paycheck and returns the created record
|
||||||
|
|
||||||
|
#### Scenario: Invalid paycheck is rejected
|
||||||
|
- **WHEN** the user submits a missing or invalid amount or date
|
||||||
|
- **THEN** the system rejects the request with a validation error and does not store the paycheck
|
||||||
|
|
||||||
|
### Requirement: User can review and delete paychecks
|
||||||
|
The system SHALL allow the user to list recorded paychecks and delete a specific paycheck by identifier.
|
||||||
|
|
||||||
|
#### Scenario: Paychecks are listed
|
||||||
|
- **WHEN** the user requests paychecks for the app
|
||||||
|
- **THEN** the system returns stored paychecks in a stable order with their recorded fields
|
||||||
|
|
||||||
|
#### Scenario: Paycheck is deleted
|
||||||
|
- **WHEN** the user deletes an existing paycheck
|
||||||
|
- **THEN** the system removes that paycheck and it no longer appears in future dashboard totals or insight inputs
|
||||||
|
|
||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Paycheck tracking UI renders correctly in both themes
|
||||||
|
The system SHALL render the paycheck tracking workspace, schedule panel, form, and list items with readable contrast in both light and dark themes.
|
||||||
|
|
||||||
|
#### Scenario: Paycheck workspace renders in dark mode
|
||||||
|
- **WHEN** the user opens the paycheck tracking view while dark mode is active
|
||||||
|
- **THEN** the schedule panel, form card, inputs, and paycheck rows use dark-compatible colors and remain readable
|
||||||
|
|
||||||
|
#### Scenario: Paycheck workspace renders in light mode
|
||||||
|
- **WHEN** the user opens the paycheck tracking view while light mode is active
|
||||||
|
- **THEN** the schedule panel, form card, inputs, and paycheck rows use light-compatible colors and remain readable
|
||||||
@@ -10,9 +10,9 @@ export default function AddExpensePage() {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
<header className="max-w-2xl space-y-3">
|
<header className="max-w-2xl space-y-3">
|
||||||
<p className="text-sm font-semibold uppercase tracking-[0.28em] text-amber-700">Add Expense</p>
|
<p className="text-sm font-semibold uppercase tracking-[0.28em] text-amber-700 dark:text-amber-500">Add Expense</p>
|
||||||
<h1 className="text-4xl font-semibold text-stone-950">Capture spending while it still feels fresh.</h1>
|
<h1 className="text-4xl font-semibold text-stone-950 dark:text-white">Capture spending while it still feels fresh.</h1>
|
||||||
<p className="text-lg leading-8 text-stone-600">
|
<p className="text-lg leading-8 text-stone-600 dark:text-stone-400">
|
||||||
Enter the shop name and the app can auto-fill a category locally for known merchants, with offline AI help for unfamiliar ones.
|
Enter the shop name and the app can auto-fill a category locally for known merchants, with offline AI help for unfamiliar ones.
|
||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -1,10 +1,17 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
@custom-variant dark (&:where(.dark, .dark *));
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--background: #ffffff;
|
--background: #ffffff;
|
||||||
--foreground: #171717;
|
--foreground: #171717;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
--background: #1a1714;
|
||||||
|
--foreground: #f5f0eb;
|
||||||
|
}
|
||||||
|
|
||||||
@theme inline {
|
@theme inline {
|
||||||
--color-background: #fbfaf7;
|
--color-background: #fbfaf7;
|
||||||
--color-foreground: #1c1917;
|
--color-foreground: #1c1917;
|
||||||
|
|||||||
@@ -8,9 +8,9 @@ export default function IncomePage() {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
<header className="max-w-2xl space-y-3">
|
<header className="max-w-2xl space-y-3">
|
||||||
<p className="text-sm font-semibold uppercase tracking-[0.28em] text-emerald-700">Income & Paychecks</p>
|
<p className="text-sm font-semibold uppercase tracking-[0.28em] text-emerald-700 dark:text-emerald-400">Income & Paychecks</p>
|
||||||
<h1 className="text-4xl font-semibold text-stone-950">Capture income on real pay dates, not rough monthly averages.</h1>
|
<h1 className="text-4xl font-semibold text-stone-950 dark:text-white">Capture income on real pay dates, not rough monthly averages.</h1>
|
||||||
<p className="text-lg leading-8 text-stone-600">
|
<p className="text-lg leading-8 text-stone-600 dark:text-stone-400">
|
||||||
This slice tracks each paycheck as a distinct event so later dashboard and AI guidance can reason about cash timing accurately.
|
This slice tracks each paycheck as a distinct event so later dashboard and AI guidance can reason about cash timing accurately.
|
||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { Fraunces, Manrope } from "next/font/google";
|
import { Fraunces, Manrope } from "next/font/google";
|
||||||
|
import Script from "next/script";
|
||||||
|
|
||||||
import { SiteNav } from "@/components/site-nav";
|
import { SiteNav } from "@/components/site-nav";
|
||||||
|
import { ThemeToggle } from "@/components/theme-toggle";
|
||||||
|
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
|
||||||
@@ -20,6 +22,18 @@ export const metadata: Metadata = {
|
|||||||
description: "Local-first monthly expense tracking with AI insights.",
|
description: "Local-first monthly expense tracking with AI insights.",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const themeScript = `
|
||||||
|
(function() {
|
||||||
|
try {
|
||||||
|
var saved = localStorage.getItem('theme');
|
||||||
|
var prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||||
|
if (saved === 'dark' || (!saved && prefersDark)) {
|
||||||
|
document.documentElement.classList.add('dark');
|
||||||
|
}
|
||||||
|
} catch(e) {}
|
||||||
|
})();
|
||||||
|
`;
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
children,
|
children,
|
||||||
}: Readonly<{
|
}: Readonly<{
|
||||||
@@ -29,15 +43,22 @@ export default function RootLayout({
|
|||||||
<html
|
<html
|
||||||
lang="en"
|
lang="en"
|
||||||
className={`${headingFont.variable} ${bodyFont.variable} h-full antialiased`}
|
className={`${headingFont.variable} ${bodyFont.variable} h-full antialiased`}
|
||||||
|
suppressHydrationWarning
|
||||||
>
|
>
|
||||||
<body className="min-h-full bg-[linear-gradient(180deg,#f8f3ea_0%,#f5efe4_28%,#fbfaf7_100%)] text-stone-950">
|
<body className="min-h-full bg-[linear-gradient(180deg,#f8f3ea_0%,#f5efe4_28%,#fbfaf7_100%)] text-stone-950 dark:bg-[linear-gradient(180deg,#1a1714_0%,#1c1a17_28%,#1e1c19_100%)] dark:text-stone-100">
|
||||||
|
<Script id="theme-script" strategy="beforeInteractive">
|
||||||
|
{themeScript}
|
||||||
|
</Script>
|
||||||
<div className="mx-auto flex min-h-full w-full max-w-7xl flex-col px-4 py-6 sm:px-6 lg:px-8">
|
<div className="mx-auto flex min-h-full w-full max-w-7xl flex-col px-4 py-6 sm:px-6 lg:px-8">
|
||||||
<header className="mb-10 flex flex-col gap-4 rounded-[2rem] border border-white/70 bg-white/80 px-6 py-5 shadow-[0_20px_50px_rgba(120,90,50,0.08)] backdrop-blur sm:flex-row sm:items-center sm:justify-between">
|
<header className="mb-10 flex flex-col gap-4 rounded-[2rem] border border-white/70 bg-white/80 px-6 py-5 shadow-[0_20px_50px_rgba(120,90,50,0.08)] backdrop-blur sm:flex-row sm:items-center sm:justify-between dark:border-stone-700/60 dark:bg-stone-900/80">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.28em] text-amber-700">Monthy Tracker</p>
|
<p className="text-xs font-semibold uppercase tracking-[0.28em] text-amber-700 dark:text-amber-500">Monthy Tracker</p>
|
||||||
<p className="mt-2 text-lg text-stone-600">Track the month as it unfolds, not after it slips away.</p>
|
<p className="mt-2 text-lg text-stone-600 dark:text-stone-400">Track the month as it unfolds, not after it slips away.</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<SiteNav />
|
||||||
|
<ThemeToggle />
|
||||||
</div>
|
</div>
|
||||||
<SiteNav />
|
|
||||||
</header>
|
</header>
|
||||||
<main className="flex-1 pb-10">{children}</main>
|
<main className="flex-1 pb-10">{children}</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { HomeDashboard } from "@/components/home-dashboard";
|
import { HomeDashboard } from "@/components/home-dashboard";
|
||||||
|
import { getCurrentMonthKey } from "@/lib/date";
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
return <HomeDashboard />;
|
return <HomeDashboard initialMonth={getCurrentMonthKey()} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useMemo, useState, type FormEvent } from "react";
|
import { useCallback, useEffect, useMemo, useState, type FormEvent } from "react";
|
||||||
|
|
||||||
import { getCategoryLabel, type CategoryValue } from "@/lib/categories";
|
import { getCategoryLabel, type CategoryValue } from "@/lib/categories";
|
||||||
import { getCurrentMonthKey } from "@/lib/date";
|
import { getCurrentMonthKey, getLocalToday, getMonthLabel } from "@/lib/date";
|
||||||
import { formatCurrencyFromCents } from "@/lib/money";
|
import { formatCurrencyFromCents } from "@/lib/money";
|
||||||
|
|
||||||
type SuggestionResponse = {
|
type SuggestionResponse = {
|
||||||
@@ -34,6 +34,7 @@ type Props = {
|
|||||||
|
|
||||||
export function ExpenseWorkspace({ categoryOptions }: Props) {
|
export function ExpenseWorkspace({ categoryOptions }: Props) {
|
||||||
const [expenses, setExpenses] = useState<ExpenseRecord[]>([]);
|
const [expenses, setExpenses] = useState<ExpenseRecord[]>([]);
|
||||||
|
const [selectedMonth, setSelectedMonth] = useState("");
|
||||||
const [formState, setFormState] = useState<{
|
const [formState, setFormState] = useState<{
|
||||||
title: string;
|
title: string;
|
||||||
amount: string;
|
amount: string;
|
||||||
@@ -42,7 +43,7 @@ export function ExpenseWorkspace({ categoryOptions }: Props) {
|
|||||||
}>({
|
}>({
|
||||||
title: "",
|
title: "",
|
||||||
amount: "",
|
amount: "",
|
||||||
date: new Date().toISOString().slice(0, 10),
|
date: "",
|
||||||
category: (categoryOptions[0]?.value as CategoryValue | undefined) ?? "MISC",
|
category: (categoryOptions[0]?.value as CategoryValue | undefined) ?? "MISC",
|
||||||
});
|
});
|
||||||
const [editingId, setEditingId] = useState<string | null>(null);
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
@@ -53,16 +54,32 @@ export function ExpenseWorkspace({ categoryOptions }: Props) {
|
|||||||
const [lastSuggestedMerchant, setLastSuggestedMerchant] = useState("");
|
const [lastSuggestedMerchant, setLastSuggestedMerchant] = useState("");
|
||||||
const [suggestedCategory, setSuggestedCategory] = useState<CategoryValue | null>(null);
|
const [suggestedCategory, setSuggestedCategory] = useState<CategoryValue | null>(null);
|
||||||
|
|
||||||
|
const loadExpenses = useCallback(async (month: string) => {
|
||||||
|
const response = await fetch(`/expenses?month=${month}`, { cache: "no-store" });
|
||||||
|
const payload = (await response.json().catch(() => null)) as { expenses?: ExpenseRecord[] } | null;
|
||||||
|
setExpenses(payload?.expenses ?? []);
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function loadExpenses() {
|
const timeoutId = window.setTimeout(() => {
|
||||||
const month = getCurrentMonthKey();
|
setSelectedMonth(getCurrentMonthKey());
|
||||||
const response = await fetch(`/expenses?month=${month}`, { cache: "no-store" });
|
setFormState((current) => (current.date ? current : { ...current, date: getLocalToday() }));
|
||||||
const payload = (await response.json().catch(() => null)) as { expenses?: ExpenseRecord[] } | null;
|
}, 0);
|
||||||
setExpenses(payload?.expenses ?? []);
|
|
||||||
|
return () => window.clearTimeout(timeoutId);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedMonth) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
void loadExpenses();
|
const timeoutId = window.setTimeout(() => {
|
||||||
}, []);
|
void loadExpenses(selectedMonth);
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
return () => window.clearTimeout(timeoutId);
|
||||||
|
}, [loadExpenses, selectedMonth]);
|
||||||
|
|
||||||
const totalSpent = useMemo(
|
const totalSpent = useMemo(
|
||||||
() => expenses.reduce((sum, expense) => sum + expense.amountCents, 0),
|
() => expenses.reduce((sum, expense) => sum + expense.amountCents, 0),
|
||||||
@@ -121,7 +138,7 @@ export function ExpenseWorkspace({ categoryOptions }: Props) {
|
|||||||
setFormState({
|
setFormState({
|
||||||
title: "",
|
title: "",
|
||||||
amount: "",
|
amount: "",
|
||||||
date: new Date().toISOString().slice(0, 10),
|
date: getLocalToday(),
|
||||||
category: (categoryOptions[0]?.value as CategoryValue | undefined) ?? "MISC",
|
category: (categoryOptions[0]?.value as CategoryValue | undefined) ?? "MISC",
|
||||||
});
|
});
|
||||||
setSuggestionMessage(null);
|
setSuggestionMessage(null);
|
||||||
@@ -182,6 +199,10 @@ export function ExpenseWorkspace({ categoryOptions }: Props) {
|
|||||||
setExpenses((current) => [payload.expense, ...current]);
|
setExpenses((current) => [payload.expense, ...current]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (selectedMonth) {
|
||||||
|
await loadExpenses(selectedMonth);
|
||||||
|
}
|
||||||
|
|
||||||
setFormState((current) => ({ ...current, title: "", amount: "" }));
|
setFormState((current) => ({ ...current, title: "", amount: "" }));
|
||||||
setSuggestionMessage(null);
|
setSuggestionMessage(null);
|
||||||
setNeedsSuggestionConfirmation(false);
|
setNeedsSuggestionConfirmation(false);
|
||||||
@@ -202,29 +223,34 @@ export function ExpenseWorkspace({ categoryOptions }: Props) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (selectedMonth) {
|
||||||
|
await loadExpenses(selectedMonth);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setExpenses((current) => current.filter((expense) => expense.id !== id));
|
setExpenses((current) => current.filter((expense) => expense.id !== id));
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid gap-6 lg:grid-cols-[1.1fr_0.9fr]">
|
<div className="grid gap-6 lg:grid-cols-[1.1fr_0.9fr]">
|
||||||
<section className="rounded-[2rem] border border-stone-200 bg-white p-6 shadow-[0_24px_60px_rgba(120,90,50,0.08)]">
|
<section className="rounded-[2rem] border border-stone-200 bg-white p-6 shadow-[0_24px_60px_rgba(120,90,50,0.08)] dark:border-stone-700 dark:bg-stone-900">
|
||||||
<div className="mb-6 flex items-center justify-between gap-4">
|
<div className="mb-6 flex items-center justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-semibold uppercase tracking-[0.24em] text-amber-700">
|
<p className="text-sm font-semibold uppercase tracking-[0.24em] text-amber-700 dark:text-amber-500">
|
||||||
{editingId ? "Edit expense" : "Daily entry"}
|
{editingId ? "Edit expense" : "Daily entry"}
|
||||||
</p>
|
</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"}
|
{editingId ? "Update this entry" : "Log today\u2019s spend in seconds"}
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-2xl bg-amber-50 px-4 py-3 text-right">
|
<div className="rounded-2xl bg-amber-50 px-4 py-3 text-right dark:bg-amber-900/20">
|
||||||
<p className="text-xs uppercase tracking-[0.2em] text-amber-700">Current list total</p>
|
<p className="text-xs uppercase tracking-[0.2em] text-amber-700 dark:text-amber-400">Current list total</p>
|
||||||
<p className="mt-1 text-2xl font-semibold text-stone-950">{formatCurrencyFromCents(totalSpent)}</p>
|
<p className="mt-1 text-2xl font-semibold text-stone-950 dark:text-white">{formatCurrencyFromCents(totalSpent)}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form className="grid gap-4 md:grid-cols-2" onSubmit={handleSubmit}>
|
<form className="grid gap-4 md:grid-cols-2" onSubmit={handleSubmit}>
|
||||||
<label className="grid gap-2 text-sm font-medium text-stone-700 md:col-span-2">
|
<label className="grid gap-2 text-sm font-medium text-stone-700 dark:text-stone-300 md:col-span-2">
|
||||||
Title
|
Title
|
||||||
<input
|
<input
|
||||||
required
|
required
|
||||||
@@ -237,35 +263,35 @@ export function ExpenseWorkspace({ categoryOptions }: Props) {
|
|||||||
setNeedsSuggestionConfirmation(false);
|
setNeedsSuggestionConfirmation(false);
|
||||||
setSuggestionMessage(null);
|
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..."
|
placeholder="Groceries, rent, train pass..."
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label className="grid gap-2 text-sm font-medium text-stone-700">
|
<label className="grid gap-2 text-sm font-medium text-stone-700 dark:text-stone-300">
|
||||||
Amount
|
Amount
|
||||||
<input
|
<input
|
||||||
required
|
required
|
||||||
inputMode="decimal"
|
inputMode="decimal"
|
||||||
value={formState.amount}
|
value={formState.amount}
|
||||||
onChange={(event) => setFormState((current) => ({ ...current, amount: event.target.value }))}
|
onChange={(event) => setFormState((current) => ({ ...current, amount: event.target.value }))}
|
||||||
className="rounded-2xl border border-stone-300 bg-stone-50 px-4 py-3 outline-none transition focus:border-stone-900"
|
className="rounded-2xl border border-stone-300 bg-stone-50 px-4 py-3 outline-none transition focus:border-stone-900 dark:border-stone-600 dark:bg-stone-800 dark:text-stone-200 dark:focus:border-stone-400"
|
||||||
placeholder="42.50"
|
placeholder="42.50"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label className="grid gap-2 text-sm font-medium text-stone-700">
|
<label className="grid gap-2 text-sm font-medium text-stone-700 dark:text-stone-300">
|
||||||
Date
|
Date
|
||||||
<input
|
<input
|
||||||
required
|
required
|
||||||
type="date"
|
type="date"
|
||||||
value={formState.date}
|
value={formState.date}
|
||||||
onChange={(event) => setFormState((current) => ({ ...current, date: event.target.value }))}
|
onChange={(event) => setFormState((current) => ({ ...current, date: event.target.value }))}
|
||||||
className="rounded-2xl border border-stone-300 bg-stone-50 px-4 py-3 outline-none transition focus:border-stone-900"
|
className="rounded-2xl border border-stone-300 bg-stone-50 px-4 py-3 outline-none transition focus:border-stone-900 dark:border-stone-600 dark:bg-stone-800 dark:text-stone-200 dark:focus:border-stone-400"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label className="grid gap-2 text-sm font-medium text-stone-700 md:col-span-2">
|
<label className="grid gap-2 text-sm font-medium text-stone-700 dark:text-stone-300 md:col-span-2">
|
||||||
Category
|
Category
|
||||||
<select
|
<select
|
||||||
value={formState.category}
|
value={formState.category}
|
||||||
@@ -273,7 +299,7 @@ export function ExpenseWorkspace({ categoryOptions }: Props) {
|
|||||||
setFormState((current) => ({ ...current, category: event.target.value as CategoryValue }));
|
setFormState((current) => ({ ...current, category: event.target.value as CategoryValue }));
|
||||||
setNeedsSuggestionConfirmation(false);
|
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) => (
|
{categoryOptions.map((option) => (
|
||||||
<option key={option.value} value={option.value}>
|
<option key={option.value} value={option.value}>
|
||||||
@@ -283,13 +309,13 @@ export function ExpenseWorkspace({ categoryOptions }: Props) {
|
|||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<div className="md:col-span-2 flex items-center justify-between gap-3 rounded-2xl border border-stone-200 bg-stone-50 px-4 py-3 text-sm text-stone-600">
|
<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>
|
<p>{suggestionMessage ?? "Merchant rules auto-fill known shops. Unknown shops use local AI when available."}</p>
|
||||||
{needsSuggestionConfirmation ? (
|
{needsSuggestionConfirmation ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setNeedsSuggestionConfirmation(false)}
|
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
|
Confirm suggestion
|
||||||
</button>
|
</button>
|
||||||
@@ -303,7 +329,7 @@ export function ExpenseWorkspace({ categoryOptions }: Props) {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleCancelEdit}
|
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
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
@@ -311,7 +337,7 @@ export function ExpenseWorkspace({ categoryOptions }: Props) {
|
|||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={busy}
|
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"}
|
{busy ? (editingId ? "Updating..." : "Saving...") : editingId ? "Update expense" : "Save expense"}
|
||||||
</button>
|
</button>
|
||||||
@@ -320,51 +346,67 @@ export function ExpenseWorkspace({ categoryOptions }: Props) {
|
|||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="rounded-[2rem] border border-stone-200 bg-[#fffaf2] p-6 shadow-[0_24px_60px_rgba(120,90,50,0.08)]">
|
<section className="rounded-[2rem] border border-stone-200 bg-[#fffaf2] p-6 shadow-[0_24px_60px_rgba(120,90,50,0.08)] dark:border-stone-700 dark:bg-stone-900/60">
|
||||||
<div className="mb-5">
|
<div className="mb-5 flex flex-wrap items-end justify-between gap-4">
|
||||||
<p className="text-sm font-semibold uppercase tracking-[0.24em] text-stone-500">Recent entries</p>
|
<div>
|
||||||
<h2 className="mt-2 text-2xl font-semibold text-stone-950">Expense history</h2>
|
<p className="text-sm font-semibold uppercase tracking-[0.24em] text-stone-500 dark:text-stone-400">Recent entries</p>
|
||||||
|
<h2 className="mt-2 text-2xl font-semibold text-stone-950 dark:text-white">Expense history</h2>
|
||||||
|
{selectedMonth ? (
|
||||||
|
<p className="mt-2 text-sm text-stone-600 dark:text-stone-400">
|
||||||
|
Showing {getMonthLabel(selectedMonth)} entries.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<label className="grid gap-2 text-sm font-medium text-stone-700 dark:text-stone-300">
|
||||||
|
Month
|
||||||
|
<input
|
||||||
|
type="month"
|
||||||
|
value={selectedMonth}
|
||||||
|
onChange={(event) => setSelectedMonth(event.target.value)}
|
||||||
|
className="rounded-2xl border border-stone-300 bg-stone-50 px-3 py-2 text-sm font-medium text-stone-700 outline-none transition focus:border-stone-900 dark:border-stone-600 dark:bg-stone-800 dark:text-stone-300 dark:focus:border-stone-400"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{expenses.length === 0 ? (
|
{expenses.length === 0 ? (
|
||||||
<div className="rounded-3xl border border-dashed border-stone-300 px-4 py-6 text-sm text-stone-600">
|
<div className="rounded-3xl border border-dashed border-stone-300 px-4 py-6 text-sm text-stone-600 dark:border-stone-600 dark:text-stone-400">
|
||||||
No expenses yet. Add your first entry to start the month.
|
{selectedMonth ? `No expenses recorded for ${getMonthLabel(selectedMonth)} yet.` : "No expenses yet. Add your first entry to start the month."}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
expenses.map((expense) => (
|
expenses.map((expense) => (
|
||||||
<article
|
<article
|
||||||
key={expense.id}
|
key={expense.id}
|
||||||
className="flex items-center justify-between gap-4 rounded-3xl border border-stone-200 bg-white px-4 py-4"
|
className="flex items-center justify-between gap-4 rounded-3xl border border-stone-200 bg-white px-4 py-4 dark:border-stone-700 dark:bg-stone-800"
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-2">
|
<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 && (
|
{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
|
Recurring
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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)}
|
{expense.date} · {getCategoryLabel(expense.category)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<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 && (
|
{!expense.isRecurring && (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleEdit(expense)}
|
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
|
Edit
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleDelete(expense.id)}
|
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
|
Delete
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -40,8 +40,12 @@ type OllamaStatus = {
|
|||||||
message: string;
|
message: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function HomeDashboard() {
|
type HomeDashboardProps = {
|
||||||
const [selectedMonth, setSelectedMonth] = useState(getCurrentMonthKey());
|
initialMonth: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function HomeDashboard({ initialMonth }: HomeDashboardProps) {
|
||||||
|
const [selectedMonth, setSelectedMonth] = useState(initialMonth);
|
||||||
const [snapshot, setSnapshot] = useState<DashboardSnapshot | null>(null);
|
const [snapshot, setSnapshot] = useState<DashboardSnapshot | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [insightBusy, setInsightBusy] = useState(false);
|
const [insightBusy, setInsightBusy] = useState(false);
|
||||||
@@ -68,6 +72,10 @@ export function HomeDashboard() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!selectedMonth) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const timeoutId = window.setTimeout(() => {
|
const timeoutId = window.setTimeout(() => {
|
||||||
void loadDashboard(selectedMonth);
|
void loadDashboard(selectedMonth);
|
||||||
}, 0);
|
}, 0);
|
||||||
@@ -131,56 +139,56 @@ export function HomeDashboard() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-10">
|
<div className="space-y-10">
|
||||||
<section className="grid gap-6 rounded-[2rem] border border-stone-200 bg-[radial-gradient(circle_at_top_left,_rgba(251,191,36,0.26),_transparent_32%),linear-gradient(135deg,#fffaf2,#f3efe7)] p-8 shadow-[0_28px_70px_rgba(120,90,50,0.10)] lg:grid-cols-[1.2fr_0.8fr]">
|
<section className="grid gap-6 rounded-[2rem] border border-stone-200 bg-[radial-gradient(circle_at_top_left,_rgba(251,191,36,0.26),_transparent_32%),linear-gradient(135deg,#fffaf2,#f3efe7)] p-8 shadow-[0_28px_70px_rgba(120,90,50,0.10)] lg:grid-cols-[1.2fr_0.8fr] dark:border-stone-700 dark:bg-[radial-gradient(circle_at_top_left,_rgba(251,191,36,0.08),_transparent_32%),linear-gradient(135deg,#242019,#1e1c18)]">
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
<p className="text-sm font-semibold uppercase tracking-[0.28em] text-amber-700">Monthly Expense Tracker</p>
|
<p className="text-sm font-semibold uppercase tracking-[0.28em] text-amber-700 dark:text-amber-500">Monthly Expense Tracker</p>
|
||||||
<h1 className="max-w-3xl text-5xl font-semibold leading-tight text-stone-950">
|
<h1 className="max-w-3xl text-5xl font-semibold leading-tight text-stone-950 dark:text-white">
|
||||||
A calm local-first home for everyday spending.
|
A calm local-first home for everyday spending.
|
||||||
</h1>
|
</h1>
|
||||||
<p className="max-w-2xl text-lg leading-8 text-stone-600">
|
<p className="max-w-2xl text-lg leading-8 text-stone-600 dark:text-stone-400">
|
||||||
Track expenses and paycheck timing together so the month-to-date picture stays honest.
|
Track expenses and paycheck timing together so the month-to-date picture stays honest.
|
||||||
</p>
|
</p>
|
||||||
<div className="flex flex-wrap gap-3">
|
<div className="flex flex-wrap gap-3">
|
||||||
<Link href="/add-expense" className="rounded-full bg-stone-950 px-5 py-3 text-sm font-semibold text-white transition hover:bg-stone-800">
|
<Link href="/add-expense" className="rounded-full bg-stone-950 px-5 py-3 text-sm font-semibold text-white transition hover:bg-stone-800 dark:bg-stone-100 dark:text-stone-900 dark:hover:bg-white">
|
||||||
Add an expense
|
Add an expense
|
||||||
</Link>
|
</Link>
|
||||||
<Link href="/income" className="rounded-full border border-stone-300 bg-white px-5 py-3 text-sm font-semibold text-stone-800 transition hover:border-stone-900">
|
<Link href="/income" className="rounded-full border border-stone-300 bg-white px-5 py-3 text-sm font-semibold text-stone-800 transition hover:border-stone-900 dark:border-stone-600 dark:bg-stone-800 dark:text-stone-200 dark:hover:border-stone-300">
|
||||||
Track paychecks
|
Track paychecks
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-[1.75rem] border border-white/80 bg-white/90 p-6">
|
<div className="rounded-[1.75rem] border border-white/80 bg-white/90 p-6 dark:border-stone-700/60 dark:bg-stone-900/80">
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-semibold uppercase tracking-[0.24em] text-stone-500">Month to date</p>
|
<p className="text-sm font-semibold uppercase tracking-[0.24em] text-stone-500 dark:text-stone-400">Month to date</p>
|
||||||
<h2 className="mt-2 text-3xl font-semibold text-stone-950">
|
<h2 className="mt-2 text-3xl font-semibold text-stone-950 dark:text-white">
|
||||||
{snapshot ? getMonthLabel(snapshot.month) : getMonthLabel(selectedMonth)}
|
{snapshot ? getMonthLabel(snapshot.month) : selectedMonth ? getMonthLabel(selectedMonth) : "Loading current month..."}
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
type="month"
|
type="month"
|
||||||
value={selectedMonth}
|
value={selectedMonth}
|
||||||
onChange={(event) => setSelectedMonth(event.target.value)}
|
onChange={(event) => setSelectedMonth(event.target.value)}
|
||||||
className="rounded-2xl border border-stone-300 bg-stone-50 px-3 py-2 text-sm font-medium text-stone-700 outline-none transition focus:border-stone-900"
|
className="rounded-2xl border border-stone-300 bg-stone-50 px-3 py-2 text-sm font-medium text-stone-700 outline-none transition focus:border-stone-900 dark:border-stone-600 dark:bg-stone-800 dark:text-stone-300 dark:focus:border-stone-400"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-6 grid gap-4 sm:grid-cols-2">
|
<div className="mt-6 grid gap-4 sm:grid-cols-2">
|
||||||
<article className="rounded-3xl bg-stone-950 px-4 py-5 text-white">
|
<article className="rounded-3xl bg-stone-950 px-4 py-5 text-white dark:bg-stone-800">
|
||||||
<p className="text-xs uppercase tracking-[0.2em] text-stone-300">Total spent</p>
|
<p className="text-xs uppercase tracking-[0.2em] text-stone-300">Total spent</p>
|
||||||
<p className="mt-3 text-3xl font-semibold">{formatCurrencyFromCents(snapshot?.totals.expensesCents ?? 0)}</p>
|
<p className="mt-3 text-3xl font-semibold">{formatCurrencyFromCents(snapshot?.totals.expensesCents ?? 0)}</p>
|
||||||
</article>
|
</article>
|
||||||
<article className="rounded-3xl bg-emerald-50 px-4 py-5 text-stone-950">
|
<article className="rounded-3xl bg-emerald-50 px-4 py-5 text-stone-950 dark:bg-emerald-900/20 dark:text-emerald-100">
|
||||||
<p className="text-xs uppercase tracking-[0.2em] text-emerald-700">Paychecks tracked</p>
|
<p className="text-xs uppercase tracking-[0.2em] text-emerald-700 dark:text-emerald-400">Paychecks tracked</p>
|
||||||
<p className="mt-3 text-3xl font-semibold">{formatCurrencyFromCents(snapshot?.totals.paychecksCents ?? 0)}</p>
|
<p className="mt-3 text-3xl font-semibold">{formatCurrencyFromCents(snapshot?.totals.paychecksCents ?? 0)}</p>
|
||||||
</article>
|
</article>
|
||||||
<article className="rounded-3xl bg-amber-50 px-4 py-5 text-stone-950">
|
<article className="rounded-3xl bg-amber-50 px-4 py-5 text-stone-950 dark:bg-amber-900/20 dark:text-amber-100">
|
||||||
<p className="text-xs uppercase tracking-[0.2em] text-amber-700">Net cash flow</p>
|
<p className="text-xs uppercase tracking-[0.2em] text-amber-700 dark:text-amber-400">Net cash flow</p>
|
||||||
<p className="mt-3 text-3xl font-semibold">{formatCurrencyFromCents(snapshot?.totals.netCashFlowCents ?? 0)}</p>
|
<p className="mt-3 text-3xl font-semibold">{formatCurrencyFromCents(snapshot?.totals.netCashFlowCents ?? 0)}</p>
|
||||||
</article>
|
</article>
|
||||||
<article className="rounded-3xl bg-stone-100 px-4 py-5 text-stone-950">
|
<article className="rounded-3xl bg-stone-100 px-4 py-5 text-stone-950 dark:bg-stone-700 dark:text-stone-100">
|
||||||
<p className="text-xs uppercase tracking-[0.2em] text-stone-600">Average daily spend</p>
|
<p className="text-xs uppercase tracking-[0.2em] text-stone-600 dark:text-stone-400">Average daily spend</p>
|
||||||
<p className="mt-3 text-3xl font-semibold">{formatCurrencyFromCents(snapshot?.totals.averageDailySpendCents ?? 0)}</p>
|
<p className="mt-3 text-3xl font-semibold">{formatCurrencyFromCents(snapshot?.totals.averageDailySpendCents ?? 0)}</p>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
@@ -188,27 +196,27 @@ export function HomeDashboard() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="rounded-[2rem] border border-stone-200 bg-white p-8 shadow-[0_24px_60px_rgba(120,90,50,0.08)]">
|
<section className="rounded-[2rem] border border-stone-200 bg-white p-8 shadow-[0_24px_60px_rgba(120,90,50,0.08)] dark:border-stone-700 dark:bg-stone-900">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-semibold uppercase tracking-[0.24em] text-stone-500">Private monthly insight</p>
|
<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">Offline guidance for this month</h2>
|
<h2 className="mt-2 text-3xl font-semibold text-stone-950 dark:text-white">Offline guidance for this month</h2>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => void handleGenerateInsights()}
|
onClick={() => void handleGenerateInsights()}
|
||||||
disabled={insightBusy}
|
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"}
|
{insightBusy ? "Generating..." : "Generate insights"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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 className="flex flex-wrap items-center justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs uppercase tracking-[0.2em] text-stone-500">Ollama runtime</p>
|
<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">
|
<p className="mt-2 text-sm font-medium text-stone-700 dark:text-stone-300">
|
||||||
{ollamaStatus?.message ?? "Checking local runtime status..."}
|
{ollamaStatus?.message ?? "Checking local runtime status..."}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -226,19 +234,19 @@ export function HomeDashboard() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
<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>
|
||||||
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-4 flex flex-wrap gap-3">
|
<div className="mt-4 flex flex-wrap gap-3">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => void loadOllamaStatus()}
|
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
|
Refresh status
|
||||||
</button>
|
</button>
|
||||||
@@ -247,14 +255,14 @@ export function HomeDashboard() {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => void handlePullModel()}
|
onClick={() => void handlePullModel()}
|
||||||
disabled={ollamaBusy}
|
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"}
|
{ollamaBusy ? "Pulling model..." : "Pull configured model"}
|
||||||
</button>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
<a
|
<a
|
||||||
href="/backup/database"
|
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
|
Download backup
|
||||||
</a>
|
</a>
|
||||||
@@ -264,31 +272,31 @@ export function HomeDashboard() {
|
|||||||
{snapshot?.insight ? (
|
{snapshot?.insight ? (
|
||||||
<div className="mt-6 space-y-4">
|
<div className="mt-6 space-y-4">
|
||||||
{/* AI summary */}
|
{/* 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">
|
<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="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">✦ Offline</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>
|
</div>
|
||||||
<p className="mt-3 text-lg leading-8 text-stone-700">{snapshot.insight.summary}</p>
|
<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">
|
<p className="mt-4 text-xs uppercase tracking-[0.2em] text-stone-400 dark:text-stone-500">
|
||||||
Generated {new Date(snapshot.insight.generatedAt).toLocaleString()}
|
Generated {new Date(snapshot.insight.generatedAt).toLocaleString()}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Spend vs income + category bars */}
|
{/* Spend vs income + category bars */}
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
<div className="rounded-3xl border border-stone-200 bg-stone-50 px-5 py-5">
|
<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">Spend vs Income</p>
|
<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="mt-4">
|
||||||
<div className="flex items-baseline justify-between gap-2">
|
<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)}
|
{formatCurrencyFromCents(snapshot.totals.expensesCents)}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-sm text-stone-500">
|
<span className="text-sm text-stone-500 dark:text-stone-400">
|
||||||
of {formatCurrencyFromCents(snapshot.totals.paychecksCents)}
|
of {formatCurrencyFromCents(snapshot.totals.paychecksCents)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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
|
<div
|
||||||
className={`h-2.5 rounded-full transition-all ${snapshot.totals.expensesCents > snapshot.totals.paychecksCents ? "bg-rose-500" : "bg-amber-500"}`}
|
className={`h-2.5 rounded-full transition-all ${snapshot.totals.expensesCents > snapshot.totals.paychecksCents ? "bg-rose-500" : "bg-amber-500"}`}
|
||||||
style={{
|
style={{
|
||||||
@@ -296,7 +304,7 @@ export function HomeDashboard() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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
|
{snapshot.totals.paychecksCents > 0
|
||||||
? `${Math.round((snapshot.totals.expensesCents / snapshot.totals.paychecksCents) * 100)}% of income spent`
|
? `${Math.round((snapshot.totals.expensesCents / snapshot.totals.paychecksCents) * 100)}% of income spent`
|
||||||
: "No income tracked this month"}
|
: "No income tracked this month"}
|
||||||
@@ -304,8 +312,8 @@ export function HomeDashboard() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-3xl border border-stone-200 bg-stone-50 px-5 py-5">
|
<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">Category flow</p>
|
<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">
|
<div className="mt-4 space-y-3">
|
||||||
{snapshot.categoryBreakdown.slice(0, 4).map((item) => {
|
{snapshot.categoryBreakdown.slice(0, 4).map((item) => {
|
||||||
const pct =
|
const pct =
|
||||||
@@ -315,12 +323,12 @@ export function HomeDashboard() {
|
|||||||
return (
|
return (
|
||||||
<div key={item.category}>
|
<div key={item.category}>
|
||||||
<div className="flex items-center justify-between gap-2 text-xs">
|
<div className="flex items-center justify-between gap-2 text-xs">
|
||||||
<span className="text-stone-600">{getCategoryLabel(item.category as never)}</span>
|
<span className="text-stone-600 dark:text-stone-400">{getCategoryLabel(item.category as never)}</span>
|
||||||
<span className="font-semibold text-stone-900">{formatCurrencyFromCents(item.amountCents)}</span>
|
<span className="font-semibold text-stone-900 dark:text-stone-200">{formatCurrencyFromCents(item.amountCents)}</span>
|
||||||
</div>
|
</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
|
<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)}%` }}
|
style={{ width: `${pct.toFixed(1)}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -328,15 +336,15 @@ export function HomeDashboard() {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{snapshot.categoryBreakdown.length === 0 && (
|
{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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Recommendations */}
|
{/* Recommendations */}
|
||||||
<div className="rounded-3xl border border-stone-200 bg-[#f8faf7] px-5 py-5">
|
<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">Next month guidance</p>
|
<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">
|
<div className="mt-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
{(() => {
|
{(() => {
|
||||||
let items: string[];
|
let items: string[];
|
||||||
@@ -347,11 +355,11 @@ export function HomeDashboard() {
|
|||||||
items = [snapshot.insight.recommendations];
|
items = [snapshot.insight.recommendations];
|
||||||
}
|
}
|
||||||
return items.map((item, i) => (
|
return items.map((item, i) => (
|
||||||
<div key={i} className="flex gap-3 rounded-2xl border border-stone-200 bg-white px-4 py-4">
|
<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">
|
<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}
|
{i + 1}
|
||||||
</span>
|
</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>
|
</div>
|
||||||
));
|
));
|
||||||
})()}
|
})()}
|
||||||
@@ -359,69 +367,69 @@ export function HomeDashboard() {
|
|||||||
</div>
|
</div>
|
||||||
</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.
|
No saved insight for this month yet. Generate one to get a private offline summary.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="grid gap-6 lg:grid-cols-[1.1fr_0.9fr]">
|
<section className="grid gap-6 lg:grid-cols-[1.1fr_0.9fr]">
|
||||||
<div className="rounded-[2rem] border border-stone-200 bg-white p-8 shadow-[0_24px_60px_rgba(120,90,50,0.08)]">
|
<div className="rounded-[2rem] border border-stone-200 bg-white p-8 shadow-[0_24px_60px_rgba(120,90,50,0.08)] dark:border-stone-700 dark:bg-stone-900">
|
||||||
<div className="flex items-center justify-between gap-4">
|
<div className="flex items-center justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-semibold uppercase tracking-[0.24em] text-stone-500">Comparisons</p>
|
<p className="text-sm font-semibold uppercase tracking-[0.24em] text-stone-500 dark:text-stone-400">Comparisons</p>
|
||||||
<h2 className="mt-2 text-3xl font-semibold text-stone-950">What stands out this month</h2>
|
<h2 className="mt-2 text-3xl font-semibold text-stone-950 dark:text-white">What stands out this month</h2>
|
||||||
</div>
|
</div>
|
||||||
<Link href="/add-expense" className="text-sm font-semibold text-amber-800 transition hover:text-stone-950">
|
<Link href="/add-expense" className="text-sm font-semibold text-amber-800 transition hover:text-stone-950 dark:text-amber-400 dark:hover:text-white">
|
||||||
Manage expenses
|
Manage expenses
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-6 grid gap-4 sm:grid-cols-2">
|
<div className="mt-6 grid gap-4 sm:grid-cols-2">
|
||||||
<article className="rounded-3xl border border-stone-200 bg-[#fffcf7] px-4 py-5">
|
<article className="rounded-3xl border border-stone-200 bg-[#fffcf7] px-4 py-5 dark:border-stone-700 dark:bg-stone-800">
|
||||||
<p className="text-xs uppercase tracking-[0.2em] text-stone-500">Highest category</p>
|
<p className="text-xs uppercase tracking-[0.2em] text-stone-500 dark:text-stone-400">Highest category</p>
|
||||||
<p className="mt-3 text-lg font-semibold text-stone-950">{topCategoryLabel}</p>
|
<p className="mt-3 text-lg font-semibold text-stone-950 dark:text-white">{topCategoryLabel}</p>
|
||||||
</article>
|
</article>
|
||||||
<article className="rounded-3xl border border-stone-200 bg-[#f8faf7] px-4 py-5">
|
<article className="rounded-3xl border border-stone-200 bg-[#f8faf7] px-4 py-5 dark:border-stone-700 dark:bg-stone-800">
|
||||||
<p className="text-xs uppercase tracking-[0.2em] text-stone-500">Paycheck coverage</p>
|
<p className="text-xs uppercase tracking-[0.2em] text-stone-500 dark:text-stone-400">Paycheck coverage</p>
|
||||||
<p className="mt-3 text-lg font-semibold text-stone-950">{coverageLabel}</p>
|
<p className="mt-3 text-lg font-semibold text-stone-950 dark:text-white">{coverageLabel}</p>
|
||||||
</article>
|
</article>
|
||||||
<article className="rounded-3xl border border-stone-200 bg-white px-4 py-5 sm:col-span-2">
|
<article className="rounded-3xl border border-stone-200 bg-white px-4 py-5 sm:col-span-2 dark:border-stone-700 dark:bg-stone-800/50">
|
||||||
<p className="text-xs uppercase tracking-[0.2em] text-stone-500">Largest expense</p>
|
<p className="text-xs uppercase tracking-[0.2em] text-stone-500 dark:text-stone-400">Largest expense</p>
|
||||||
{snapshot?.comparisons.largestExpense ? (
|
{snapshot?.comparisons.largestExpense ? (
|
||||||
<div className="mt-3 flex flex-wrap items-center justify-between gap-3">
|
<div className="mt-3 flex flex-wrap items-center justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-lg font-semibold text-stone-950">{snapshot.comparisons.largestExpense.title}</p>
|
<p className="text-lg font-semibold text-stone-950 dark:text-white">{snapshot.comparisons.largestExpense.title}</p>
|
||||||
<p className="text-sm text-stone-600">
|
<p className="text-sm text-stone-600 dark:text-stone-400">
|
||||||
{snapshot.comparisons.largestExpense.date} · {getCategoryLabel(snapshot.comparisons.largestExpense.category as never)}
|
{snapshot.comparisons.largestExpense.date} · {getCategoryLabel(snapshot.comparisons.largestExpense.category as never)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xl font-semibold text-stone-950">
|
<p className="text-xl font-semibold text-stone-950 dark:text-white">
|
||||||
{formatCurrencyFromCents(snapshot.comparisons.largestExpense.amountCents)}
|
{formatCurrencyFromCents(snapshot.comparisons.largestExpense.amountCents)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className="mt-3 text-sm text-stone-600">No expense data for this month yet.</p>
|
<p className="mt-3 text-sm text-stone-600 dark:text-stone-400">No expense data for this month yet.</p>
|
||||||
)}
|
)}
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-[2rem] border border-stone-200 bg-white p-8 shadow-[0_24px_60px_rgba(120,90,50,0.08)]">
|
<div className="rounded-[2rem] border border-stone-200 bg-white p-8 shadow-[0_24px_60px_rgba(120,90,50,0.08)] dark:border-stone-700 dark:bg-stone-900">
|
||||||
<p className="text-sm font-semibold uppercase tracking-[0.24em] text-stone-500">Category breakdown</p>
|
<p className="text-sm font-semibold uppercase tracking-[0.24em] text-stone-500 dark:text-stone-400">Category breakdown</p>
|
||||||
<h2 className="mt-2 text-3xl font-semibold text-stone-950">Where the month is going</h2>
|
<h2 className="mt-2 text-3xl font-semibold text-stone-950 dark:text-white">Where the month is going</h2>
|
||||||
<div className="mt-6 space-y-3">
|
<div className="mt-6 space-y-3">
|
||||||
{snapshot?.categoryBreakdown.length ? (
|
{snapshot?.categoryBreakdown.length ? (
|
||||||
snapshot.categoryBreakdown.map((item) => (
|
snapshot.categoryBreakdown.map((item) => (
|
||||||
<article key={item.category} className="rounded-3xl border border-stone-200 bg-stone-50 px-4 py-4">
|
<article key={item.category} className="rounded-3xl border border-stone-200 bg-stone-50 px-4 py-4 dark:border-stone-700 dark:bg-stone-800">
|
||||||
<div className="flex items-center justify-between gap-4">
|
<div className="flex items-center justify-between gap-4">
|
||||||
<p className="font-semibold text-stone-950">{getCategoryLabel(item.category as never)}</p>
|
<p className="font-semibold text-stone-950 dark:text-white">{getCategoryLabel(item.category as never)}</p>
|
||||||
<p className="font-semibold text-stone-950">{formatCurrencyFromCents(item.amountCents)}</p>
|
<p className="font-semibold text-stone-950 dark:text-white">{formatCurrencyFromCents(item.amountCents)}</p>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<div className="rounded-3xl border border-dashed border-stone-300 px-4 py-8 text-sm text-stone-600">
|
<div className="rounded-3xl border border-dashed border-stone-300 px-4 py-8 text-sm text-stone-600 dark:border-stone-600 dark:text-stone-400">
|
||||||
No category totals yet for this month.
|
No category totals yet for this month.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -429,13 +437,13 @@ export function HomeDashboard() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="rounded-[2rem] border border-stone-200 bg-white p-8 shadow-[0_24px_60px_rgba(120,90,50,0.08)]">
|
<section className="rounded-[2rem] border border-stone-200 bg-white p-8 shadow-[0_24px_60px_rgba(120,90,50,0.08)] dark:border-stone-700 dark:bg-stone-900">
|
||||||
<div className="flex items-center justify-between gap-4">
|
<div className="flex items-center justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-semibold uppercase tracking-[0.24em] text-stone-500">Recent expense pulse</p>
|
<p className="text-sm font-semibold uppercase tracking-[0.24em] text-stone-500 dark:text-stone-400">Recent expense pulse</p>
|
||||||
<h2 className="mt-2 text-3xl font-semibold text-stone-950">Latest entries</h2>
|
<h2 className="mt-2 text-3xl font-semibold text-stone-950 dark:text-white">Latest entries</h2>
|
||||||
</div>
|
</div>
|
||||||
<Link href="/income" className="text-sm font-semibold text-emerald-800 transition hover:text-stone-950">
|
<Link href="/income" className="text-sm font-semibold text-emerald-800 transition hover:text-stone-950 dark:text-emerald-400 dark:hover:text-white">
|
||||||
Manage paychecks
|
Manage paychecks
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
@@ -443,25 +451,25 @@ export function HomeDashboard() {
|
|||||||
<div className="mt-6 grid gap-3 md:grid-cols-2">
|
<div className="mt-6 grid gap-3 md:grid-cols-2">
|
||||||
{snapshot?.recentExpenses.length ? (
|
{snapshot?.recentExpenses.length ? (
|
||||||
snapshot.recentExpenses.map((expense) => (
|
snapshot.recentExpenses.map((expense) => (
|
||||||
<article key={expense.id} className="flex flex-wrap items-center justify-between gap-3 rounded-3xl border border-stone-200 bg-[#fffcf7] px-4 py-4">
|
<article key={expense.id} className="flex flex-wrap items-center justify-between gap-3 rounded-3xl border border-stone-200 bg-[#fffcf7] px-4 py-4 dark:border-stone-700 dark:bg-stone-800">
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-2">
|
<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 && (
|
{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
|
Recurring
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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)}
|
{expense.date} · {getCategoryLabel(expense.category as never)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<p className="font-semibold text-stone-950">{formatCurrencyFromCents(expense.amountCents)}</p>
|
<p className="font-semibold text-stone-950 dark:text-white">{formatCurrencyFromCents(expense.amountCents)}</p>
|
||||||
</article>
|
</article>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<div className="rounded-3xl border border-dashed border-stone-300 px-4 py-10 text-center text-stone-600 md:col-span-2">
|
<div className="rounded-3xl border border-dashed border-stone-300 px-4 py-10 text-center text-stone-600 md:col-span-2 dark:border-stone-600 dark:text-stone-400">
|
||||||
No expenses recorded yet. Start with one quick entry.
|
No expenses recorded yet. Start with one quick entry.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useEffect, useMemo, useState, type FormEvent } from "react";
|
import { useEffect, useMemo, useState, type FormEvent } from "react";
|
||||||
|
|
||||||
import { getCurrentMonthKey } from "@/lib/date";
|
import { getCurrentMonthKey, getLocalToday } from "@/lib/date";
|
||||||
import { formatCurrencyFromCents } from "@/lib/money";
|
import { formatCurrencyFromCents } from "@/lib/money";
|
||||||
|
|
||||||
type PaycheckRecord = {
|
type PaycheckRecord = {
|
||||||
@@ -21,11 +21,11 @@ export function PaycheckWorkspace() {
|
|||||||
const [paychecks, setPaychecks] = useState<PaycheckRecord[]>([]);
|
const [paychecks, setPaychecks] = useState<PaycheckRecord[]>([]);
|
||||||
const [schedule, setSchedule] = useState<PaySchedule | null>(null);
|
const [schedule, setSchedule] = useState<PaySchedule | null>(null);
|
||||||
const [projectedDates, setProjectedDates] = useState<string[]>([]);
|
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 [showScheduleForm, setShowScheduleForm] = useState(false);
|
||||||
const [formState, setFormState] = useState({
|
const [formState, setFormState] = useState({
|
||||||
amount: "",
|
amount: "",
|
||||||
payDate: new Date().toISOString().slice(0, 10),
|
payDate: "",
|
||||||
});
|
});
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -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();
|
void loadData();
|
||||||
|
|
||||||
|
return () => window.clearTimeout(timeoutId);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const totalIncome = useMemo(
|
const totalIncome = useMemo(
|
||||||
@@ -84,7 +92,7 @@ export function PaycheckWorkspace() {
|
|||||||
setSchedule(payload.schedule);
|
setSchedule(payload.schedule);
|
||||||
setProjectedDates(computeProjectedDates(payload.schedule.anchorDate, getCurrentMonthKey()));
|
setProjectedDates(computeProjectedDates(payload.schedule.anchorDate, getCurrentMonthKey()));
|
||||||
setShowScheduleForm(false);
|
setShowScheduleForm(false);
|
||||||
setScheduleForm({ amount: "", anchorDate: new Date().toISOString().slice(0, 10) });
|
setScheduleForm({ amount: "", anchorDate: getLocalToday() });
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleClearSchedule() {
|
async function handleClearSchedule() {
|
||||||
@@ -162,11 +170,11 @@ export function PaycheckWorkspace() {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Biweekly schedule panel */}
|
{/* 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 className="flex flex-wrap items-center justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-semibold uppercase tracking-[0.24em] text-emerald-700">Biweekly schedule</p>
|
<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">
|
<h2 className="mt-1 text-2xl font-semibold text-stone-950 dark:text-white">
|
||||||
{schedule ? "Active pay schedule" : "Set up your pay schedule"}
|
{schedule ? "Active pay schedule" : "Set up your pay schedule"}
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
@@ -175,7 +183,7 @@ export function PaycheckWorkspace() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => { setShowScheduleForm(true); setScheduleForm({ amount: (schedule.amountCents / 100).toFixed(2), anchorDate: schedule.anchorDate }); }}
|
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
|
Edit
|
||||||
</button>
|
</button>
|
||||||
@@ -183,7 +191,7 @@ export function PaycheckWorkspace() {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => void handleClearSchedule()}
|
onClick={() => void handleClearSchedule()}
|
||||||
disabled={busy}
|
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
|
Clear
|
||||||
</button>
|
</button>
|
||||||
@@ -203,39 +211,39 @@ export function PaycheckWorkspace() {
|
|||||||
|
|
||||||
{showScheduleForm && (
|
{showScheduleForm && (
|
||||||
<form className="mt-5 grid gap-4 sm:grid-cols-[1fr_1fr_auto]" onSubmit={handleSaveSchedule}>
|
<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
|
Amount per paycheck
|
||||||
<input
|
<input
|
||||||
required
|
required
|
||||||
inputMode="decimal"
|
inputMode="decimal"
|
||||||
value={scheduleForm.amount}
|
value={scheduleForm.amount}
|
||||||
onChange={(e) => setScheduleForm((s) => ({ ...s, amount: e.target.value }))}
|
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"
|
placeholder="2400.00"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label className="grid gap-2 text-sm font-medium text-stone-700">
|
<label className="grid gap-2 text-sm font-medium text-stone-700 dark:text-stone-300">
|
||||||
A known pay date (anchor)
|
A known pay date (anchor)
|
||||||
<input
|
<input
|
||||||
required
|
required
|
||||||
type="date"
|
type="date"
|
||||||
value={scheduleForm.anchorDate}
|
value={scheduleForm.anchorDate}
|
||||||
onChange={(e) => setScheduleForm((s) => ({ ...s, anchorDate: e.target.value }))}
|
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>
|
</label>
|
||||||
<div className="flex items-end gap-2">
|
<div className="flex items-end gap-2">
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={busy}
|
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"}
|
{busy ? "Saving..." : "Save"}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setShowScheduleForm(false)}
|
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
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
@@ -245,21 +253,21 @@ export function PaycheckWorkspace() {
|
|||||||
|
|
||||||
{schedule && !showScheduleForm && (
|
{schedule && !showScheduleForm && (
|
||||||
<div className="mt-5">
|
<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>
|
<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>
|
||||||
<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>
|
||||||
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{projectedDates.length > 0 && (
|
{projectedDates.length > 0 && (
|
||||||
<div className="mt-4">
|
<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
|
Projected pay dates this month
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-2 flex flex-wrap gap-3">
|
<div className="mt-2 flex flex-wrap gap-3">
|
||||||
@@ -270,19 +278,19 @@ export function PaycheckWorkspace() {
|
|||||||
key={date}
|
key={date}
|
||||||
className={`flex items-center gap-2 rounded-2xl border px-4 py-2 text-sm ${
|
className={`flex items-center gap-2 rounded-2xl border px-4 py-2 text-sm ${
|
||||||
confirmed
|
confirmed
|
||||||
? "border-emerald-200 bg-emerald-50 text-emerald-800"
|
? "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"
|
: "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>
|
<span className="font-medium">{date}</span>
|
||||||
{confirmed ? (
|
{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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => void handleConfirmProjected(date)}
|
onClick={() => void handleConfirmProjected(date)}
|
||||||
disabled={busy}
|
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
|
Mark received
|
||||||
</button>
|
</button>
|
||||||
@@ -292,7 +300,7 @@ export function PaycheckWorkspace() {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
{pendingProjectedDates.length > 0 && (
|
{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.
|
{pendingProjectedDates.length} pending — included in dashboard totals as projected income.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@@ -304,48 +312,48 @@ export function PaycheckWorkspace() {
|
|||||||
|
|
||||||
<div className="grid gap-6 lg:grid-cols-[1.1fr_0.9fr]">
|
<div className="grid gap-6 lg:grid-cols-[1.1fr_0.9fr]">
|
||||||
{/* Manual paycheck entry */}
|
{/* 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 className="mb-6 flex items-center justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-semibold uppercase tracking-[0.24em] text-emerald-700">One-off entry</p>
|
<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">Log a bonus or extra deposit</h2>
|
<h2 className="mt-2 text-3xl font-semibold text-stone-950 dark:text-white">Log a bonus or extra deposit</h2>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-2xl bg-emerald-50 px-4 py-3 text-right">
|
<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">Tracked paychecks</p>
|
<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">{formatCurrencyFromCents(totalIncome)}</p>
|
<p className="mt-1 text-2xl font-semibold text-stone-950 dark:text-white">{formatCurrencyFromCents(totalIncome)}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form className="grid gap-4 md:grid-cols-2" onSubmit={handleSubmit}>
|
<form className="grid gap-4 md:grid-cols-2" onSubmit={handleSubmit}>
|
||||||
<label className="grid gap-2 text-sm font-medium text-stone-700">
|
<label className="grid gap-2 text-sm font-medium text-stone-700 dark:text-stone-300">
|
||||||
Amount
|
Amount
|
||||||
<input
|
<input
|
||||||
required
|
required
|
||||||
inputMode="decimal"
|
inputMode="decimal"
|
||||||
value={formState.amount}
|
value={formState.amount}
|
||||||
onChange={(event) => setFormState((current) => ({ ...current, amount: event.target.value }))}
|
onChange={(event) => setFormState((current) => ({ ...current, amount: event.target.value }))}
|
||||||
className="rounded-2xl border border-stone-300 bg-stone-50 px-4 py-3 outline-none transition focus:border-stone-900"
|
className="rounded-2xl border border-stone-300 bg-stone-50 px-4 py-3 outline-none transition focus:border-stone-900 dark:border-stone-600 dark:bg-stone-800 dark:text-stone-200 dark:focus:border-stone-400"
|
||||||
placeholder="1800.00"
|
placeholder="1800.00"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label className="grid gap-2 text-sm font-medium text-stone-700">
|
<label className="grid gap-2 text-sm font-medium text-stone-700 dark:text-stone-300">
|
||||||
Pay date
|
Pay date
|
||||||
<input
|
<input
|
||||||
required
|
required
|
||||||
type="date"
|
type="date"
|
||||||
value={formState.payDate}
|
value={formState.payDate}
|
||||||
onChange={(event) => setFormState((current) => ({ ...current, payDate: event.target.value }))}
|
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>
|
</label>
|
||||||
|
|
||||||
<div className="md:col-span-2 flex items-center justify-between gap-3">
|
<div className="md:col-span-2 flex items-center justify-between gap-3">
|
||||||
<p className="text-sm text-rose-700">{error}</p>
|
<p className="text-sm text-rose-700 dark:text-rose-400">{error}</p>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={busy}
|
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"}
|
{busy ? "Saving..." : "Save paycheck"}
|
||||||
</button>
|
</button>
|
||||||
@@ -354,33 +362,33 @@ export function PaycheckWorkspace() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Paycheck history */}
|
{/* 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">
|
<div className="mb-5">
|
||||||
<p className="text-sm font-semibold uppercase tracking-[0.24em] text-stone-500">Income history</p>
|
<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">Recent paychecks</h2>
|
<h2 className="mt-2 text-2xl font-semibold text-stone-950 dark:text-white">Recent paychecks</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{paychecks.length === 0 ? (
|
{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 "Mark received" on a projected date or add one manually.
|
No paychecks yet. Use "Mark received" on a projected date or add one manually.
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
paychecks.map((paycheck) => (
|
paychecks.map((paycheck) => (
|
||||||
<article
|
<article
|
||||||
key={paycheck.id}
|
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>
|
<div>
|
||||||
<p className="font-semibold text-stone-950">Paycheck</p>
|
<p className="font-semibold text-stone-950 dark:text-white">Paycheck</p>
|
||||||
<p className="mt-1 text-sm text-stone-600">{paycheck.payDate}</p>
|
<p className="mt-1 text-sm text-stone-600 dark:text-stone-400">{paycheck.payDate}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-4">
|
<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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => void handleDelete(paycheck.id)}
|
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
|
Delete
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -120,18 +120,18 @@ export function RecurringExpenseManager({ categoryOptions }: Props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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 className="mb-5 flex items-center justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-semibold uppercase tracking-[0.24em] text-amber-700">Fixed monthly costs</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">Recurring expenses</h2>
|
<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">These appear automatically in every month without manual entry.</p>
|
<p className="mt-1 text-sm text-stone-600 dark:text-stone-400">These appear automatically in every month without manual entry.</p>
|
||||||
</div>
|
</div>
|
||||||
{!showAddForm && editingId === null && (
|
{!showAddForm && editingId === null && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={openAdd}
|
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
|
+ Add recurring
|
||||||
</button>
|
</button>
|
||||||
@@ -141,36 +141,36 @@ export function RecurringExpenseManager({ categoryOptions }: Props) {
|
|||||||
{(showAddForm || editingId !== null) && (
|
{(showAddForm || editingId !== null) && (
|
||||||
<form
|
<form
|
||||||
onSubmit={handleSubmit}
|
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"}
|
{editingId ? "Edit recurring expense" : "New recurring expense"}
|
||||||
</p>
|
</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
|
Title
|
||||||
<input
|
<input
|
||||||
required
|
required
|
||||||
value={formState.title}
|
value={formState.title}
|
||||||
onChange={(e) => setFormState((s) => ({ ...s, title: e.target.value }))}
|
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..."
|
placeholder="Rent, car insurance, EMI..."
|
||||||
/>
|
/>
|
||||||
</label>
|
</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
|
Amount
|
||||||
<input
|
<input
|
||||||
required
|
required
|
||||||
inputMode="decimal"
|
inputMode="decimal"
|
||||||
value={formState.amount}
|
value={formState.amount}
|
||||||
onChange={(e) => setFormState((s) => ({ ...s, amount: e.target.value }))}
|
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"
|
placeholder="1200.00"
|
||||||
/>
|
/>
|
||||||
</label>
|
</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
|
Day of month
|
||||||
<input
|
<input
|
||||||
required
|
required
|
||||||
@@ -179,17 +179,17 @@ export function RecurringExpenseManager({ categoryOptions }: Props) {
|
|||||||
max={28}
|
max={28}
|
||||||
value={formState.dayOfMonth}
|
value={formState.dayOfMonth}
|
||||||
onChange={(e) => setFormState((s) => ({ ...s, dayOfMonth: e.target.value }))}
|
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"
|
placeholder="1"
|
||||||
/>
|
/>
|
||||||
</label>
|
</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
|
Category
|
||||||
<select
|
<select
|
||||||
value={formState.category}
|
value={formState.category}
|
||||||
onChange={(e) => setFormState((s) => ({ ...s, category: e.target.value as CategoryValue }))}
|
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) => (
|
{categoryOptions.map((option) => (
|
||||||
<option key={option.value} value={option.value}>
|
<option key={option.value} value={option.value}>
|
||||||
@@ -205,14 +205,14 @@ export function RecurringExpenseManager({ categoryOptions }: Props) {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={cancelForm}
|
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
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={busy}
|
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"}
|
{busy ? "Saving..." : editingId ? "Update" : "Save"}
|
||||||
</button>
|
</button>
|
||||||
@@ -226,7 +226,7 @@ export function RecurringExpenseManager({ categoryOptions }: Props) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{definitions.length === 0 && !showAddForm ? (
|
{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.
|
No recurring expenses yet. Add fixed monthly costs like rent or EMI.
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -236,24 +236,24 @@ export function RecurringExpenseManager({ categoryOptions }: Props) {
|
|||||||
key={def.id}
|
key={def.id}
|
||||||
className={`flex items-center justify-between gap-4 rounded-3xl border px-4 py-4 ${
|
className={`flex items-center justify-between gap-4 rounded-3xl border px-4 py-4 ${
|
||||||
editingId === def.id
|
editingId === def.id
|
||||||
? "border-amber-300 bg-amber-100/50"
|
? "border-amber-300 bg-amber-100/50 dark:border-amber-700/50 dark:bg-amber-900/20"
|
||||||
: "border-amber-200 bg-white"
|
: "border-amber-200 bg-white dark:border-amber-900/30 dark:bg-stone-900"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<p className="font-semibold text-stone-950">{def.title}</p>
|
<p className="font-semibold text-stone-950 dark:text-white">{def.title}</p>
|
||||||
<p className="mt-1 text-sm text-stone-600">
|
<p className="mt-1 text-sm text-stone-600 dark:text-stone-400">
|
||||||
Day {def.dayOfMonth} each month · {getCategoryLabel(def.category as CategoryValue)}
|
Day {def.dayOfMonth} each month · {getCategoryLabel(def.category as CategoryValue)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<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 && (
|
{editingId !== def.id && (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => openEdit(def)}
|
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
|
Edit
|
||||||
</button>
|
</button>
|
||||||
@@ -261,7 +261,7 @@ export function RecurringExpenseManager({ categoryOptions }: Props) {
|
|||||||
type="button"
|
type="button"
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
onClick={() => void handleDelete(def.id)}
|
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
|
Delete
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export function SiteNav() {
|
|||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav className="flex flex-wrap gap-3 text-sm font-semibold text-stone-700">
|
<nav className="flex flex-wrap gap-3 text-sm font-semibold text-stone-700 dark:text-stone-300">
|
||||||
{links.map((link) => {
|
{links.map((link) => {
|
||||||
const isActive = link.href === "/" ? pathname === "/" : pathname.startsWith(link.href);
|
const isActive = link.href === "/" ? pathname === "/" : pathname.startsWith(link.href);
|
||||||
return (
|
return (
|
||||||
@@ -22,8 +22,8 @@ export function SiteNav() {
|
|||||||
href={link.href}
|
href={link.href}
|
||||||
className={
|
className={
|
||||||
isActive
|
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-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"
|
: "rounded-full border border-stone-300/80 bg-white/80 px-4 py-2 transition hover:border-stone-900 hover:text-stone-950 dark:border-stone-600 dark:bg-stone-800/60 dark:hover:border-stone-300 dark:hover:text-white"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{link.label}
|
{link.label}
|
||||||
|
|||||||
48
src/components/theme-toggle.tsx
Normal file
48
src/components/theme-toggle.tsx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
export function ThemeToggle() {
|
||||||
|
const [isDark, setIsDark] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timeoutId = window.setTimeout(() => {
|
||||||
|
setIsDark(document.documentElement.classList.contains("dark"));
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
return () => window.clearTimeout(timeoutId);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
function toggle() {
|
||||||
|
const root = document.documentElement;
|
||||||
|
if (root.classList.contains("dark")) {
|
||||||
|
root.classList.remove("dark");
|
||||||
|
localStorage.setItem("theme", "light");
|
||||||
|
setIsDark(false);
|
||||||
|
} else {
|
||||||
|
root.classList.add("dark");
|
||||||
|
localStorage.setItem("theme", "dark");
|
||||||
|
setIsDark(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={toggle}
|
||||||
|
aria-label={isDark ? "Switch to light mode" : "Switch to dark mode"}
|
||||||
|
className="rounded-full border border-stone-300/80 bg-white/80 px-3 py-2 text-stone-700 transition hover:border-stone-900 hover:text-stone-950 dark:border-stone-600 dark:bg-stone-800 dark:text-stone-300 dark:hover:border-stone-400 dark:hover:text-white"
|
||||||
|
>
|
||||||
|
{isDark ? (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<circle cx="12" cy="12" r="4" />
|
||||||
|
<path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ vi.mock("@/lib/db", () => {
|
|||||||
db: {
|
db: {
|
||||||
expense: { findMany: vi.fn() },
|
expense: { findMany: vi.fn() },
|
||||||
paycheck: { findMany: vi.fn() },
|
paycheck: { findMany: vi.fn() },
|
||||||
|
recurringExpense: { findMany: vi.fn().mockResolvedValue([]) },
|
||||||
paySchedule: { findFirst: vi.fn().mockResolvedValue(null) },
|
paySchedule: { findFirst: vi.fn().mockResolvedValue(null) },
|
||||||
monthlyInsight,
|
monthlyInsight,
|
||||||
},
|
},
|
||||||
@@ -35,6 +36,7 @@ describe("generateMonthlyInsight", () => {
|
|||||||
|
|
||||||
vi.mocked(db.expense.findMany).mockResolvedValue([]);
|
vi.mocked(db.expense.findMany).mockResolvedValue([]);
|
||||||
vi.mocked(db.paycheck.findMany).mockResolvedValue([]);
|
vi.mocked(db.paycheck.findMany).mockResolvedValue([]);
|
||||||
|
vi.mocked(db.recurringExpense.findMany).mockResolvedValue([]);
|
||||||
|
|
||||||
const result = await generateMonthlyInsight("2026-03");
|
const result = await generateMonthlyInsight("2026-03");
|
||||||
|
|
||||||
@@ -72,6 +74,7 @@ describe("generateMonthlyInsight", () => {
|
|||||||
createdAt: new Date("2026-03-01T10:00:00.000Z"),
|
createdAt: new Date("2026-03-01T10:00:00.000Z"),
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
vi.mocked(db.recurringExpense.findMany).mockResolvedValue([]);
|
||||||
|
|
||||||
vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
||||||
ok: true,
|
ok: true,
|
||||||
@@ -120,6 +123,7 @@ describe("generateMonthlyInsight", () => {
|
|||||||
createdAt: new Date("2026-03-01T10:00:00.000Z"),
|
createdAt: new Date("2026-03-01T10:00:00.000Z"),
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
vi.mocked(db.recurringExpense.findMany).mockResolvedValue([]);
|
||||||
|
|
||||||
vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
||||||
ok: true,
|
ok: true,
|
||||||
|
|||||||
@@ -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"}`
|
? `Pay schedule: biweekly, ${centsToDisplay(snapshot.paySchedule.amountCents)} per paycheck, projected pay dates this month: ${snapshot.paySchedule.projectedDates.join(", ") || "none"}`
|
||||||
: null,
|
: null,
|
||||||
`Category breakdown: ${JSON.stringify(categoryBreakdown)}`,
|
`Category breakdown: ${JSON.stringify(categoryBreakdown)}`,
|
||||||
`Recent expenses: ${JSON.stringify(snapshot.recentExpenses)}`,
|
`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)}`,
|
`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.",
|
"Do not mention missing data unless it materially affects the advice.",
|
||||||
]
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
|
|||||||
1
tsconfig.tsbuildinfo
Normal file
1
tsconfig.tsbuildinfo
Normal file
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user