From 6cd35bdee8b243fbfc53b783151cce45d5278c35 Mon Sep 17 00:00:00 2001 From: audemed44 Date: Sun, 1 Mar 2026 08:48:56 +0530 Subject: [PATCH] test: add unit tests, ESLint, Prettier, Husky, and CI pipeline - Refactor server routes to factory pattern (createApp/createXxxRouter) for testability with in-memory SQLite - Add 74 server tests (99% line coverage) via Vitest + supertest - Add 119 client tests (96% line coverage) via Vitest + Testing Library - Configure ESLint flat config and Prettier for consistent code style - Add Husky pre-commit hooks with lint-staged and commitlint validation - Update CI pipeline with parallel lint, test, and build jobs --- .github/workflows/ci.yml | 49 +- .gitignore | 1 + .husky/commit-msg | 1 + .husky/pre-commit | 2 + .prettierignore | 5 + .prettierrc | 6 + CLAUDE.md | 50 +- README.md | 28 +- client/index.html | 7 +- client/package.json | 14 +- client/src/App.tsx | 30 +- client/src/__tests__/setup.ts | 24 + .../components/finance/add-budget-dialog.tsx | 32 +- .../components/finance/add-expense-dialog.tsx | 40 +- .../finance/add-recurring-dialog.tsx | 40 +- client/src/components/finance/budget-list.tsx | 21 +- .../components/finance/category-manager.tsx | 51 +- .../finance/charts/budget-progress.tsx | 24 +- .../finance/charts/calendar-heatmap.tsx | 48 +- .../finance/charts/category-comparison.tsx | 34 +- .../finance/charts/category-donut-chart.tsx | 13 +- .../finance/charts/monthly-bar-chart.tsx | 52 +- .../finance/charts/spending-trend-chart.tsx | 29 +- .../src/components/finance/dynamic-icon.tsx | 66 +- .../src/components/finance/expense-list.tsx | 159 +- .../finance/import-export-dialog.tsx | 55 +- .../src/components/finance/recurring-list.tsx | 32 +- .../src/components/finance/summary-cards.tsx | 6 +- client/src/components/finance/tag-manager.tsx | 48 +- client/src/components/ui/alert-dialog.tsx | 78 +- client/src/components/ui/badge.tsx | 22 +- client/src/components/ui/button.tsx | 16 +- client/src/components/ui/calendar.tsx | 111 +- client/src/components/ui/dialog.tsx | 40 +- client/src/components/ui/input.tsx | 2 +- client/src/components/ui/label.tsx | 7 +- client/src/components/ui/popover.tsx | 14 +- client/src/components/ui/select.tsx | 51 +- client/src/components/ui/switch.tsx | 7 +- client/src/components/ui/table.tsx | 63 +- client/src/components/ui/tabs.tsx | 40 +- .../src/hooks/__tests__/use-finance.test.tsx | 490 + client/src/hooks/__tests__/use-mobile.test.ts | 66 + client/src/hooks/use-finance.tsx | 120 +- client/src/lib/__tests__/api.test.ts | 217 + client/src/lib/__tests__/csv-utils.test.ts | 266 + .../src/lib/__tests__/finance-utils.test.ts | 355 + client/src/lib/__tests__/utils.test.ts | 21 + client/src/lib/csv-utils.ts | 141 +- client/src/lib/finance-utils.ts | 32 +- client/src/lib/types.ts | 33 +- client/vite.config.ts | 4 +- client/vitest.config.ts | 36 + commitlint.config.js | 6 + docker-compose.yml | 2 +- eslint.config.js | 49 + package-lock.json | 8363 +++++++++++++---- package.json | 33 +- server/package.json | 11 +- server/src/__tests__/db.test.ts | 141 + server/src/__tests__/helpers/test-app.ts | 6 + server/src/__tests__/helpers/test-db.ts | 10 + server/src/__tests__/logger.test.ts | 46 + .../src/__tests__/recurring-processor.test.ts | 156 + server/src/__tests__/routes/budgets.test.ts | 85 + .../src/__tests__/routes/categories.test.ts | 86 + server/src/__tests__/routes/expenses.test.ts | 136 + server/src/__tests__/routes/recurring.test.ts | 136 + server/src/__tests__/routes/tags.test.ts | 124 + server/src/create-app.ts | 43 + server/src/db.ts | 13 +- server/src/index.ts | 43 +- server/src/logger.ts | 4 +- server/src/recurring-processor.ts | 32 +- server/src/routes/budgets.ts | 117 +- server/src/routes/categories.ts | 116 +- server/src/routes/expenses.ts | 152 +- server/src/routes/recurring.ts | 180 +- server/src/routes/tags.ts | 129 +- server/vitest.config.ts | 18 + 80 files changed, 10194 insertions(+), 3242 deletions(-) create mode 100755 .husky/commit-msg create mode 100755 .husky/pre-commit create mode 100644 .prettierignore create mode 100644 .prettierrc create mode 100644 client/src/__tests__/setup.ts create mode 100644 client/src/hooks/__tests__/use-finance.test.tsx create mode 100644 client/src/hooks/__tests__/use-mobile.test.ts create mode 100644 client/src/lib/__tests__/api.test.ts create mode 100644 client/src/lib/__tests__/csv-utils.test.ts create mode 100644 client/src/lib/__tests__/finance-utils.test.ts create mode 100644 client/src/lib/__tests__/utils.test.ts create mode 100644 client/vitest.config.ts create mode 100644 commitlint.config.js create mode 100644 eslint.config.js create mode 100644 server/src/__tests__/db.test.ts create mode 100644 server/src/__tests__/helpers/test-app.ts create mode 100644 server/src/__tests__/helpers/test-db.ts create mode 100644 server/src/__tests__/logger.test.ts create mode 100644 server/src/__tests__/recurring-processor.test.ts create mode 100644 server/src/__tests__/routes/budgets.test.ts create mode 100644 server/src/__tests__/routes/categories.test.ts create mode 100644 server/src/__tests__/routes/expenses.test.ts create mode 100644 server/src/__tests__/routes/recurring.test.ts create mode 100644 server/src/__tests__/routes/tags.test.ts create mode 100644 server/src/create-app.ts create mode 100644 server/vitest.config.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d25a2ea..3dc6816 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,8 +5,55 @@ on: branches: [main] jobs: + lint: + name: Lint & Format + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Lint + run: npm run lint + + - name: Format check + run: npm run format:check + + test: + name: Tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Run tests with coverage + run: npm run test:coverage + + - name: Upload coverage reports + if: always() + uses: actions/upload-artifact@v4 + with: + name: coverage + path: | + server/coverage/ + client/coverage/ + retention-days: 7 + build: - name: Smoke test + name: Build runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 diff --git a/.gitignore b/.gitignore index 82accc7..6e1a48f 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ vendor/ .env .env.local .claude +coverage/ diff --git a/.husky/commit-msg b/.husky/commit-msg new file mode 100755 index 0000000..0398b7a --- /dev/null +++ b/.husky/commit-msg @@ -0,0 +1 @@ +npx --no -- commitlint --edit ${1} diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100755 index 0000000..1c0ebca --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1,2 @@ +npx lint-staged +npm test diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..b1434cf --- /dev/null +++ b/.prettierignore @@ -0,0 +1,5 @@ +dist/ +node_modules/ +coverage/ +*.db +package-lock.json diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..6aca66f --- /dev/null +++ b/.prettierrc @@ -0,0 +1,6 @@ +{ + "semi": false, + "singleQuote": true, + "trailingComma": "es5", + "printWidth": 120 +} diff --git a/CLAUDE.md b/CLAUDE.md index a957b4d..c4faf8d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -22,38 +22,57 @@ node server/dist/index.js # Docker docker compose up --build -``` -There are no test scripts in this project. +# Tests +npm test # run all tests (server + client) +npm run test:coverage # run with coverage report +npm run test --workspace=server # server only +npm run test --workspace=client # client only + +# Lint & format +npm run lint # ESLint check +npm run lint:fix # ESLint auto-fix +npm run format # Prettier write +npm run format:check # Prettier check (used in CI) +``` ## Git Default branch is `main`. **Never push directly to `main`.** All work must go through a feature branch and a pull request. ### Branch workflow + 1. Create a branch: `git checkout -b /` (e.g., `feat/budget-filters`, `fix/mobile-overflow`) 2. Commit your changes on the branch 3. Push and open a PR against `main` 4. CI (smoke-test build) must pass before merging ### Commit messages -Use **conventional commits**: + +Use **conventional commits** — enforced by commitlint on every commit: ``` (): -Types: feat, fix, chore, refactor, style, docs, perf +Types: feat, fix, chore, refactor, style, docs, perf, test, ci ``` Examples: + - `feat(expenses): add custom date range picker` - `fix(mobile): prevent period selector from overflowing on small screens` -- `chore: rename app to PocketLog` +- `test: add unit tests for recurring processor` Do not add `Co-Authored-By` trailers to commit messages. +### Git hooks (Husky) + +- **pre-commit**: runs `lint-staged` (ESLint + Prettier on staged files) then `npm test` +- **commit-msg**: validates message format via commitlint + ### CI / CD (GitHub Actions) -- **PR**: `.github/workflows/ci.yml` — runs `npm run build` as a smoke test. Must pass to merge. + +- **PR**: `.github/workflows/ci.yml` — 3 parallel jobs: `lint` (ESLint + Prettier check), `test` (Vitest with coverage), `build`. All must pass to merge. - **Merge to main**: `.github/workflows/docker.yml` — builds and pushes Docker image to `ghcr.io/audemed44/pocket-log`, tagged `latest` + `sha-`. Keeps only the 3 most recent image versions. Remote: `git@github.com:audemed44/pocket-log.git` @@ -71,11 +90,12 @@ Remote: `git@github.com:audemed44/pocket-log.git` ### Server -- `server/src/index.ts` — Express entry point. Mounts routers under `/api/categories`, `/api/expenses`, `/api/recurring`, `/api/budgets`, `/api/tags`. In `NODE_ENV=production`, serves `../../client/dist` as static files with an SPA fallback. -- `server/src/db.ts` — SQLite via `better-sqlite3`, WAL mode, `PRAGMA foreign_keys = ON`. Creates tables on startup. DB path defaults to `server/data/pocketlog.db`, overridable via `DB_PATH` env var. DB starts empty (no seed data). -- `server/src/routes/` — One file per resource; each uses raw `better-sqlite3` prepared statements. IDs are generated as `${Date.now()}-${random}`. DB columns use `snake_case`; row mapper functions in `db.ts` convert to camelCase. +- `server/src/index.ts` — Express entry point. Calls `createApp(db)`, starts listening, schedules recurring processor. In `NODE_ENV=production`, serves `../../client/dist` as static files with an SPA fallback. +- `server/src/create-app.ts` — `createApp(db: Database): Express` factory. Builds app with all middleware and routers; no side effects. Used by both `index.ts` and tests. +- `server/src/db.ts` — SQLite via `better-sqlite3`, WAL mode, `PRAGMA foreign_keys = ON`. Exports `SCHEMA_SQL`, row mapper functions, and the singleton `db`. DB path defaults to `server/data/pocketlog.db`, overridable via `DB_PATH` env var. DB starts empty (no seed data). +- `server/src/routes/` — One file per resource; each exports `createXxxRouter(db: Database): Router`. IDs are generated as `${Date.now()}-${random}`. DB columns use `snake_case`; row mapper functions in `db.ts` convert to camelCase. - `server/src/types.ts` — Canonical TypeScript types shared across the server. -- `server/src/recurring-processor.ts` — Runs on startup and hourly. Generates expenses for all active recurring rules from their `startDate` up to today. Idempotent: builds a `recurringId|date` set from existing auto-generated expenses in the DB before inserting. Does **not** backfill past dates — only generates from today onwards for new rules. +- `server/src/recurring-processor.ts` — `processRecurringExpenses(db)`. Runs on startup and hourly. Generates expenses for all active recurring rules from their `startDate` up to today. Idempotent: builds a `recurringId|date` set from existing auto-generated expenses in the DB before inserting. Does **not** backfill past dates — only generates from today onwards for new rules. ### Database schema @@ -99,10 +119,10 @@ Tables: `categories`, `expenses`, `recurring_expenses`, `budgets`, `tags`, `expe ### Ports & environment -| Variable | Default | Purpose | -|----------|---------|---------| -| `PORT` | `7897` | Express server port (and Vite proxy target) | -| `DB_PATH` | `server/data/pocketlog.db` | SQLite database file path | -| `NODE_ENV` | — | Set to `production` to enable static file serving | +| Variable | Default | Purpose | +| ---------- | -------------------------- | ------------------------------------------------- | +| `PORT` | `7897` | Express server port (and Vite proxy target) | +| `DB_PATH` | `server/data/pocketlog.db` | SQLite database file path | +| `NODE_ENV` | — | Set to `production` to enable static file serving | Docker Compose exposes the container on `${PORT:-7897}` and persists the DB in a named volume `db-data` mounted at `/app/data`. diff --git a/README.md b/README.md index 7475269..f1336ba 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ services: pocketlog: image: ghcr.io/audemed44/pocket-log:latest ports: - - "7897:7897" + - '7897:7897' environment: - PORT=7897 - DB_PATH=/app/data/pocketlog.db @@ -37,9 +37,11 @@ docker compose up -d Open [http://localhost:7897](http://localhost:7897). > **Note:** The GHCR image is hosted on a private repository. You need to authenticate before pulling: +> > ```bash > echo | docker login ghcr.io -u --password-stdin > ``` +> > Generate a PAT at GitHub → Settings → Developer settings → Personal access tokens with the `read:packages` scope. ### Changing the port @@ -48,7 +50,7 @@ Open [http://localhost:7897](http://localhost:7897). environment: - PORT=8080 ports: - - "8080:8080" + - '8080:8080' ``` ### Persisting data @@ -59,11 +61,11 @@ The database is stored at `DB_PATH` inside the container. The example above moun ## Environment variables -| Variable | Default | Description | -|---|---|---| -| `PORT` | `7897` | Port the server listens on | -| `DB_PATH` | `server/data/pocketlog.db` | Path to the SQLite database file | -| `NODE_ENV` | — | Set to `production` to enable static file serving | +| Variable | Default | Description | +| ---------- | -------------------------- | ------------------------------------------------- | +| `PORT` | `7897` | Port the server listens on | +| `DB_PATH` | `server/data/pocketlog.db` | Path to the SQLite database file | +| `NODE_ENV` | — | Set to `production` to enable static file serving | --- @@ -122,12 +124,12 @@ When a recurring expense is active, the server automatically generates individua Data is exported as a single CSV with the following row types: -| `type` | Key fields | -|--------|-----------| -| `category` | `name`, `color`, `icon` | -| `tag` | `name`, `color` | +| `type` | Key fields | +| ----------- | -------------------------------------------------------------------------------------------- | +| `category` | `name`, `color`, `icon` | +| `tag` | `name`, `color` | | `recurring` | `amount`, `description`, `category`, `frequency`, `startDate`, `endDate`, `isActive`, `tags` | -| `expense` | `amount`, `description`, `category`, `date`, `tags` | -| `budget` | `name`, `amount`, `category`, `period`, `isActive` | +| `expense` | `amount`, `description`, `category`, `date`, `tags` | +| `budget` | `name`, `amount`, `category`, `period`, `isActive` | The `tags` field holds `\|`-separated tag names. Auto-generated expenses are exported as plain `expense` rows (recurring rule relationship is stripped) so that historical data is preserved on re-import; the recurring rule will generate new entries from today onwards after import. diff --git a/client/index.html b/client/index.html index 5ecfb2f..799978f 100644 --- a/client/index.html +++ b/client/index.html @@ -1,4 +1,4 @@ - + @@ -9,7 +9,10 @@ - +
diff --git a/client/package.json b/client/package.json index 6322b93..927f1a9 100644 --- a/client/package.json +++ b/client/package.json @@ -6,7 +6,10 @@ "scripts": { "dev": "vite --host", "build": "tsc && vite build", - "preview": "vite preview" + "preview": "vite preview", + "test": "vitest run", + "test:watch": "vitest", + "test:coverage": "vitest run --coverage" }, "dependencies": { "@radix-ui/react-alert-dialog": "^1.1.0", @@ -32,12 +35,19 @@ }, "devDependencies": { "@tailwindcss/vite": "^4.0.0", + "@testing-library/dom": "^10.4.1", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", "@vitejs/plugin-react": "^4.3.0", + "@vitest/coverage-v8": "^4.0.18", + "jsdom": "^28.1.0", "tailwindcss": "^4.0.0", "tw-animate-css": "^1.2.0", "typescript": "^5.6.0", - "vite": "^6.0.0" + "vite": "^6.0.0", + "vitest": "^4.0.18" } } diff --git a/client/src/App.tsx b/client/src/App.tsx index f3ea169..aaf37ab 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -51,27 +51,45 @@ export default function App() {
- + Dashboard - + Expenses - + Recurring - + Budgets - + Categories - + Data diff --git a/client/src/__tests__/setup.ts b/client/src/__tests__/setup.ts new file mode 100644 index 0000000..046c6e6 --- /dev/null +++ b/client/src/__tests__/setup.ts @@ -0,0 +1,24 @@ +import '@testing-library/jest-dom/vitest' +import { vi } from 'vitest' + +// Mock matchMedia +Object.defineProperty(window, 'matchMedia', { + writable: true, + value: vi.fn().mockImplementation((query: string) => ({ + matches: false, + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })), +}) + +// Mock ResizeObserver +global.ResizeObserver = class ResizeObserver { + observe() {} + unobserve() {} + disconnect() {} +} diff --git a/client/src/components/finance/add-budget-dialog.tsx b/client/src/components/finance/add-budget-dialog.tsx index 9b69747..b3f1d8f 100644 --- a/client/src/components/finance/add-budget-dialog.tsx +++ b/client/src/components/finance/add-budget-dialog.tsx @@ -1,21 +1,9 @@ import { useState, useEffect } from 'react' -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - DialogFooter, -} from '@/components/ui/dialog' +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@/components/ui/select' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' import { useFinanceStore } from '@/hooks/use-finance' import { DynamicIcon } from './dynamic-icon' import type { Budget } from '@/lib/types' @@ -75,9 +63,7 @@ export function AddBudgetDialog({ open, onOpenChange, editBudget }: AddBudgetDia
- +
- +
₹ @@ -110,9 +94,7 @@ export function AddBudgetDialog({ open, onOpenChange, editBudget }: AddBudgetDia
- +
- + 0 && (
- +
{tags.map((tag) => { const selected = selectedTagIds.includes(tag.id) diff --git a/client/src/components/finance/add-recurring-dialog.tsx b/client/src/components/finance/add-recurring-dialog.tsx index 6e0ddb1..b752d4c 100644 --- a/client/src/components/finance/add-recurring-dialog.tsx +++ b/client/src/components/finance/add-recurring-dialog.tsx @@ -1,22 +1,10 @@ import { useState, useEffect } from 'react' import { format } from 'date-fns' -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - DialogFooter, -} from '@/components/ui/dialog' +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@/components/ui/select' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' import { useFinanceStore } from '@/hooks/use-finance' import { DynamicIcon } from './dynamic-icon' import { FREQUENCY_LABELS, type Frequency, type RecurringExpense } from '@/lib/types' @@ -58,9 +46,7 @@ export function AddRecurringDialog({ open, onOpenChange, editRecurring }: AddRec }, [editRecurring, open, categories]) function toggleTag(tagId: string) { - setSelectedTagIds((prev) => - prev.includes(tagId) ? prev.filter((id) => id !== tagId) : [...prev, tagId] - ) + setSelectedTagIds((prev) => (prev.includes(tagId) ? prev.filter((id) => id !== tagId) : [...prev, tagId])) } function handleSubmit(e: React.FormEvent) { @@ -97,9 +83,7 @@ export function AddRecurringDialog({ open, onOpenChange, editRecurring }: AddRec
- +
₹ @@ -118,9 +102,7 @@ export function AddRecurringDialog({ open, onOpenChange, editRecurring }: AddRec
- +
- + setFrequency(v as Frequency)}> @@ -174,9 +152,7 @@ export function AddRecurringDialog({ open, onOpenChange, editRecurring }: AddRec
- +
-

- Budgets -

+

Budgets

{budgets.length} budget{budgets.length !== 1 ? 's' : ''} | {budgets.filter((b) => b.isActive).length} active

- @@ -45,11 +46,7 @@ export function BudgetList() { {budgets.length === 0 ? (

No budgets yet

-
@@ -58,11 +55,7 @@ export function BudgetList() { {budgets.map((budget) => { const cat = budget.categoryId ? getCategoryById(budget.categoryId) : null const status = getBudgetStatus(budget, expenses, categories) - const barColor = status.isOver - ? '#ef4444' - : status.percentage > 80 - ? '#f59e0b' - : cat?.color ?? '#6366f1' + const barColor = status.isOver ? '#ef4444' : status.percentage > 80 ? '#f59e0b' : (cat?.color ?? '#6366f1') return (
e.categoryId === catId).length + + return ( + expenses.filter((e) => e.categoryId === catId).length + recurringExpenses.filter((e) => e.categoryId === catId).length + ) } const isDialogOpen = addOpen || !!editCat @@ -82,9 +78,7 @@ export function CategoryManager() { {/* Header */}
-

- Categories -

+

Categories

{categories.length} categories

{/* Add/Edit Dialog */} - { if (!open) { setAddOpen(false); setEditCat(null) } }}> + { + if (!open) { + setAddOpen(false) + setEditCat(null) + } + }} + > @@ -149,9 +151,7 @@ export function CategoryManager() {
- +
- +
{PRESET_COLORS.map((c) => (
- setTooltip(null)} - > + setTooltip(null)}> {/* Month labels */} {monthPositions.map(({ month, weekIndex: wi }) => ( ( + {DAY_LABELS.map((label, i) => label ? ( ) : null - ))} + )} {/* Cells */} {grid.map((week, wi) => @@ -204,18 +204,12 @@ export function CalendarHeatmap() {
Less {INTENSITY_COLORS.map((color, i) => ( -
+
))} More
{maxAmount > 0 && ( - - Max: {formatCurrency(maxAmount)}/day - + Max: {formatCurrency(maxAmount)}/day )}
diff --git a/client/src/components/finance/charts/category-comparison.tsx b/client/src/components/finance/charts/category-comparison.tsx index c15b5fb..e20af05 100644 --- a/client/src/components/finance/charts/category-comparison.tsx +++ b/client/src/components/finance/charts/category-comparison.tsx @@ -1,21 +1,19 @@ import { useMemo } from 'react' -import { - BarChart, - Bar, - XAxis, - YAxis, - CartesianGrid, - Tooltip, - ResponsiveContainer, - Legend, - Cell, -} from 'recharts' +import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend, Cell } from 'recharts' import { useFinanceStore } from '@/hooks/use-finance' import { getMonthlyExpenses, formatCurrency } from '@/lib/finance-utils' import { useIsMobile } from '@/hooks/use-mobile' import { subMonths } from 'date-fns' -function CustomTooltip({ active, payload, label }: { active?: boolean; payload?: Array<{ name: string; value: number; color: string }>; label?: string }) { +function CustomTooltip({ + active, + payload, + label, +}: { + active?: boolean + payload?: Array<{ name: string; value: number; color: string }> + label?: string +}) { if (!active || !payload) return null return (
@@ -46,12 +44,8 @@ export function CategoryComparison() { .map((cat) => ({ name: cat.name, color: cat.color, - current: currentExpenses - .filter((e) => e.categoryId === cat.id) - .reduce((s, e) => s + e.amount, 0), - previous: prevExpenses - .filter((e) => e.categoryId === cat.id) - .reduce((s, e) => s + e.amount, 0), + current: currentExpenses.filter((e) => e.categoryId === cat.id).reduce((s, e) => s + e.amount, 0), + previous: prevExpenses.filter((e) => e.categoryId === cat.id).reduce((s, e) => s + e.amount, 0), })) .filter((d) => d.current > 0 || d.previous > 0) .sort((a, b) => b.current - a.current) @@ -86,9 +80,7 @@ export function CategoryComparison() { width={isMobile ? 72 : 100} /> } cursor={{ fill: 'rgba(255,255,255,0.03)' }} /> - {value}} - /> + {value}} /> {data.map((entry, index) => ( diff --git a/client/src/components/finance/charts/category-donut-chart.tsx b/client/src/components/finance/charts/category-donut-chart.tsx index 0f898f8..dcf9f14 100644 --- a/client/src/components/finance/charts/category-donut-chart.tsx +++ b/client/src/components/finance/charts/category-donut-chart.tsx @@ -5,8 +5,13 @@ import { getMonthlyExpenses, getCategoryBreakdown, formatCurrency } from '@/lib/ function renderActiveShape(props: unknown) { const { cx, cy, innerRadius, outerRadius, startAngle, endAngle, fill } = props as { - cx: number; cy: number; innerRadius: number; outerRadius: number - startAngle: number; endAngle: number; fill: string + cx: number + cy: number + innerRadius: number + outerRadius: number + startAngle: number + endAngle: number + fill: string } return (
- - {formatCurrency(total)} - + {formatCurrency(total)} Total
diff --git a/client/src/components/finance/charts/monthly-bar-chart.tsx b/client/src/components/finance/charts/monthly-bar-chart.tsx index 84a5c10..71620b1 100644 --- a/client/src/components/finance/charts/monthly-bar-chart.tsx +++ b/client/src/components/finance/charts/monthly-bar-chart.tsx @@ -1,30 +1,32 @@ import { useMemo } from 'react' -import { - BarChart, - Bar, - XAxis, - YAxis, - CartesianGrid, - Tooltip, - ResponsiveContainer, -} from 'recharts' +import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts' import { useFinanceStore } from '@/hooks/use-finance' import { getMonthlyBarData, formatCurrency } from '@/lib/finance-utils' -function CustomTooltip({ active, payload, label }: { active?: boolean; payload?: Array<{ name: string; value: number; color: string }>; label?: string }) { +function CustomTooltip({ + active, + payload, + label, +}: { + active?: boolean + payload?: Array<{ name: string; value: number; color: string }> + label?: string +}) { if (!active || !payload) return null return (

{label}

- {payload.filter(p => p.value > 0).map((entry) => ( -
-
- {entry.name} - - {formatCurrency(entry.value)} - -
- ))} + {payload + .filter((p) => p.value > 0) + .map((entry) => ( +
+
+ {entry.name} + + {formatCurrency(entry.value)} + +
+ ))}
) } @@ -47,9 +49,7 @@ export function MonthlyBarChart() { return (
-

- Monthly Spending -

+

Monthly Spending

Last 6 months by category

@@ -70,13 +70,7 @@ export function MonthlyBarChart() { /> } cursor={{ fill: 'rgba(255,255,255,0.03)' }} /> {activeCategories.map((cat) => ( - + ))} diff --git a/client/src/components/finance/charts/spending-trend-chart.tsx b/client/src/components/finance/charts/spending-trend-chart.tsx index 97d70f4..5f8ef78 100644 --- a/client/src/components/finance/charts/spending-trend-chart.tsx +++ b/client/src/components/finance/charts/spending-trend-chart.tsx @@ -1,18 +1,17 @@ import { useMemo } from 'react' -import { - LineChart, - Line, - XAxis, - YAxis, - CartesianGrid, - Tooltip, - ResponsiveContainer, - Legend, -} from 'recharts' +import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from 'recharts' import { useFinanceStore } from '@/hooks/use-finance' import { getSpendingTrendComparison, formatCurrency } from '@/lib/finance-utils' -function CustomTooltip({ active, payload, label }: { active?: boolean; payload?: Array<{ name: string; value: number; color: string }>; label?: string }) { +function CustomTooltip({ + active, + payload, + label, +}: { + active?: boolean + payload?: Array<{ name: string; value: number; color: string }> + label?: string +}) { if (!active || !payload) return null return (
@@ -38,9 +37,7 @@ export function SpendingTrendChart() { return (
-

- Spending Trend -

+

Spending Trend

Cumulative daily spending vs last month

@@ -60,9 +57,7 @@ export function SpendingTrendChart() { tickFormatter={(v) => `₹${v}`} /> } /> - {value}} - /> + {value}} /> > = { - Home, UtensilsCrossed, Car, Zap, Gamepad2, HeartPulse, - ShoppingBag, GraduationCap, RefreshCw, MoreHorizontal, - Plane, Music, Coffee, Gift, Briefcase, Dumbbell, - PawPrint, Smartphone, Wifi, CreditCard, Banknote, Shirt, - Book, Wrench, Bus, Train, Bike, Flame, + Home, + UtensilsCrossed, + Car, + Zap, + Gamepad2, + HeartPulse, + ShoppingBag, + GraduationCap, + RefreshCw, + MoreHorizontal, + Plane, + Music, + Coffee, + Gift, + Briefcase, + Dumbbell, + PawPrint, + Smartphone, + Wifi, + CreditCard, + Banknote, + Shirt, + Book, + Wrench, + Bus, + Train, + Bike, + Flame, } interface DynamicIconProps extends LucideProps { diff --git a/client/src/components/finance/expense-list.tsx b/client/src/components/finance/expense-list.tsx index 12b0c95..fe8f19c 100644 --- a/client/src/components/finance/expense-list.tsx +++ b/client/src/components/finance/expense-list.tsx @@ -1,23 +1,10 @@ import { useState, useMemo } from 'react' import { format, parseISO, subMonths, subYears, isAfter, isBefore, startOfDay, endOfDay } from 'date-fns' import { Trash2, Pencil, Search, ArrowUpDown, Plus, CalendarIcon, RefreshCw } from 'lucide-react' -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from '@/components/ui/table' +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table' import { Input } from '@/components/ui/input' import { Button } from '@/components/ui/button' -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@/components/ui/select' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' import { AlertDialog, AlertDialogAction, @@ -46,20 +33,47 @@ const TIME_PERIOD_LABELS: Record, string> = { '6m': '6M', '1y': '1Y', '3y': '3Y', - 'all': 'ALL', + all: 'ALL', } function getDateRangeForPeriod(period: Exclude): Date { const now = new Date() switch (period) { - case '1m': return subMonths(now, 1) - case '3m': return subMonths(now, 3) - case '6m': return subMonths(now, 6) - case '1y': return subYears(now, 1) - case '3y': return subYears(now, 3) + case '1m': + return subMonths(now, 1) + case '3m': + return subMonths(now, 3) + case '6m': + return subMonths(now, 6) + case '1y': + return subYears(now, 1) + case '3y': + return subYears(now, 3) } } +function SortHeader({ + field, + sortField, + onSort, + children, +}: { + field: SortField + sortField: SortField + onSort: (f: SortField) => void + children: React.ReactNode +}) { + return ( + + ) +} + export function ExpenseList({ onAddExpense }: { onAddExpense: () => void }) { const { expenses, categories, tags, deleteExpense, getCategoryById } = useFinanceStore() const [search, setSearch] = useState('') @@ -95,9 +109,7 @@ export function ExpenseList({ onAddExpense }: { onAddExpense: () => void }) { if (search) { const q = search.toLowerCase() result = result.filter( - (e) => - e.description.toLowerCase().includes(q) || - getCategoryById(e.categoryId)?.name.toLowerCase().includes(q) + (e) => e.description.toLowerCase().includes(q) || getCategoryById(e.categoryId)?.name.toLowerCase().includes(q) ) } @@ -122,16 +134,25 @@ export function ExpenseList({ onAddExpense }: { onAddExpense: () => void }) { cmp = a.description.localeCompare(b.description) break case 'category': - cmp = (getCategoryById(a.categoryId)?.name || '').localeCompare( - getCategoryById(b.categoryId)?.name || '' - ) + cmp = (getCategoryById(a.categoryId)?.name || '').localeCompare(getCategoryById(b.categoryId)?.name || '') break } return sortDir === 'asc' ? cmp : -cmp }) return result - }, [expenses, search, filterCategory, filterTag, sortField, sortDir, getCategoryById, timePeriod, customFrom, customTo]) + }, [ + expenses, + search, + filterCategory, + filterTag, + sortField, + sortDir, + getCategoryById, + timePeriod, + customFrom, + customTo, + ]) function toggleSort(field: SortField) { if (sortField === field) { @@ -142,18 +163,6 @@ export function ExpenseList({ onAddExpense }: { onAddExpense: () => void }) { } } - function SortHeader({ field, children }: { field: SortField; children: React.ReactNode }) { - return ( - - ) - } - const total = filtered.reduce((s, e) => s + e.amount, 0) return ( @@ -207,8 +216,8 @@ export function ExpenseList({ onAddExpense }: { onAddExpense: () => void }) { {customFrom && customTo ? `${format(customFrom, 'MMM d')} – ${format(customTo, 'MMM d, yyyy')}` : customFrom - ? `From ${format(customFrom, 'MMM d, yyyy')}` - : 'Select a date range'} + ? `From ${format(customFrom, 'MMM d, yyyy')}` + : 'Select a date range'} {(customFrom || customTo) && ( @@ -294,9 +310,7 @@ export function ExpenseList({ onAddExpense }: { onAddExpense: () => void }) { {filtered.length} expense{filtered.length !== 1 ? 's' : ''} - - {formatCurrency(total)} - + {formatCurrency(total)}
{/* Table */} @@ -305,16 +319,24 @@ export function ExpenseList({ onAddExpense }: { onAddExpense: () => void }) { - Date + + Date + - Description + + Description + - Category + + Category + - Amount + + Amount + Actions @@ -331,9 +353,7 @@ export function ExpenseList({ onAddExpense }: { onAddExpense: () => void }) { ) : ( filtered.map((expense) => { const cat = getCategoryById(expense.categoryId) - const expenseTags = expense.tagIds - ?.map((tid) => tags.find((t) => t.id === tid)) - .filter(Boolean) + const expenseTags = expense.tagIds?.map((tid) => tags.find((t) => t.id === tid)).filter(Boolean) return ( @@ -355,19 +375,22 @@ export function ExpenseList({ onAddExpense }: { onAddExpense: () => void }) { {/* Tags */} {expenseTags && expenseTags.length > 0 && (
- {expenseTags.map((tag) => tag && ( - - {tag.name} - - ))} + {expenseTags.map( + (tag) => + tag && ( + + {tag.name} + + ) + )}
)} {/* Mobile category */} diff --git a/client/src/components/finance/import-export-dialog.tsx b/client/src/components/finance/import-export-dialog.tsx index 2dba865..b40bd97 100644 --- a/client/src/components/finance/import-export-dialog.tsx +++ b/client/src/components/finance/import-export-dialog.tsx @@ -77,13 +77,25 @@ export function ImportExport() { const catId = nameToId.get(exp.categoryName) if (!catId) continue const tagIds = exp.tagNames.map((n) => tagNameToId.get(n)).filter((id): id is string => !!id) - await api.createExpense({ amount: exp.amount, description: exp.description, categoryId: catId, date: exp.date, tagIds }) + await api.createExpense({ + amount: exp.amount, + description: exp.description, + categoryId: catId, + date: exp.date, + tagIds, + }) } // 5. Budgets for (const bud of parsed.budgets) { const catId = bud.categoryName ? (nameToId.get(bud.categoryName) ?? null) : null - await api.createBudget({ name: bud.name, amount: bud.amount, categoryId: catId, period: bud.period, isActive: bud.isActive }) + await api.createBudget({ + name: bud.name, + amount: bud.amount, + categoryId: catId, + period: bud.period, + isActive: bud.isActive, + }) } refresh() @@ -97,23 +109,21 @@ export function ImportExport() { } } - const newCatCount = parsed - ? parsed.categories.filter((c) => !categories.some((e) => e.name === c.name)).length - : 0 - const newTagCount = parsed - ? parsed.tags.filter((t) => !tags.some((e) => e.name === t.name)).length - : 0 + const newCatCount = parsed ? parsed.categories.filter((c) => !categories.some((e) => e.name === c.name)).length : 0 + const newTagCount = parsed ? parsed.tags.filter((t) => !tags.some((e) => e.name === t.name)).length : 0 const hasAnything = parsed - ? parsed.categories.length > 0 || parsed.tags.length > 0 || parsed.recurringExpenses.length > 0 || parsed.expenses.length > 0 || parsed.budgets.length > 0 + ? parsed.categories.length > 0 || + parsed.tags.length > 0 || + parsed.recurringExpenses.length > 0 || + parsed.expenses.length > 0 || + parsed.budgets.length > 0 : false return (
{/* Header */}
-

- Import / Export -

+

Import / Export

Back up or restore your data as a CSV file

@@ -122,9 +132,7 @@ export function ImportExport() {

Export

-

- Download all your data as a single CSV file. -

+

Download all your data as a single CSV file.

{categories.length} @@ -182,16 +190,12 @@ export function ImportExport() {

{parsed.categories.length} categories - {newCatCount > 0 && ( - ({newCatCount} new) - )} + {newCatCount > 0 && ({newCatCount} new)}

{parsed.tags.length} tags - {newTagCount > 0 && ( - ({newTagCount} new) - )} + {newTagCount > 0 && ({newTagCount} new)}

{parsed.recurringExpenses.length} @@ -216,7 +220,9 @@ export function ImportExport() {

    {parsed.errors.slice(0, 5).map((err, i) => ( -
  • {err}
  • +
  • + {err} +
  • ))} {parsed.errors.length > 5 && (
  • …and {parsed.errors.length - 5} more
  • @@ -228,7 +234,10 @@ export function ImportExport() {
    - @@ -57,11 +66,7 @@ export function RecurringList() { {recurringExpenses.length === 0 ? (

    No recurring expenses yet

    -
    @@ -97,7 +102,10 @@ export function RecurringList() {
- + {FREQUENCY_LABELS[recurring.frequency]} {next && ( @@ -112,9 +120,7 @@ export function RecurringList() {
- updateRecurringExpense(recurring.id, { isActive: checked }) - } + onCheckedChange={(checked) => updateRecurringExpense(recurring.id, { isActive: checked })} /> {recurring.isActive ? 'Active' : 'Paused'} diff --git a/client/src/components/finance/summary-cards.tsx b/client/src/components/finance/summary-cards.tsx index e89021a..5eb4899 100644 --- a/client/src/components/finance/summary-cards.tsx +++ b/client/src/components/finance/summary-cards.tsx @@ -1,5 +1,5 @@ import { useMemo } from 'react' -import { TrendingUp, TrendingDown, Minus, DollarSign, ArrowUpDown, Layers, RefreshCw } from 'lucide-react' +import { TrendingUp, TrendingDown, Minus, DollarSign, Layers, RefreshCw } from 'lucide-react' import { useFinanceStore } from '@/hooks/use-finance' import { getMonthlyTotal, getCategoryBreakdown, formatCurrency } from '@/lib/finance-utils' import { subMonths } from 'date-fns' @@ -66,9 +66,7 @@ export function SummaryCards() { {card.value} - {card.detail && ( - {card.detail} - )} + {card.detail && {card.detail}}
))}
diff --git a/client/src/components/finance/tag-manager.tsx b/client/src/components/finance/tag-manager.tsx index 5d7db8c..e83a3e3 100644 --- a/client/src/components/finance/tag-manager.tsx +++ b/client/src/components/finance/tag-manager.tsx @@ -1,13 +1,7 @@ import { useState } from 'react' import { Plus, Pencil, Trash2 } from 'lucide-react' import { Button } from '@/components/ui/button' -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - DialogFooter, -} from '@/components/ui/dialog' +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { @@ -63,10 +57,10 @@ export function TagManager() { {/* Header */}
-

- Tags -

-

{tags.length} tag{tags.length !== 1 ? 's' : ''}

+

Tags

+

+ {tags.length} tag{tags.length !== 1 ? 's' : ''} +