Skip to content
This repository was archived by the owner on Oct 8, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 48 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@ vendor/
.env
.env.local
.claude
coverage/
1 change: 1 addition & 0 deletions .husky/commit-msg
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
npx --no -- commitlint --edit ${1}
2 changes: 2 additions & 0 deletions .husky/pre-commit
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
npx lint-staged
npm test
5 changes: 5 additions & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
dist/
node_modules/
coverage/
*.db
package-lock.json
6 changes: 6 additions & 0 deletions .prettierrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"semi": false,
"singleQuote": true,
"trailingComma": "es5",
"printWidth": 120
}
50 changes: 35 additions & 15 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <type>/<short-description>` (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:

```
<type>(<optional scope>): <description>

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-<short>`. Keeps only the 3 most recent image versions.

Remote: `git@github.com:audemed44/pocket-log.git`
Expand All @@ -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

Expand All @@ -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`.
28 changes: 15 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 <YOUR_GITHUB_PAT> | docker login ghcr.io -u <your-github-username> --password-stdin
> ```
>
> Generate a PAT at GitHub → Settings → Developer settings → Personal access tokens with the `read:packages` scope.

### Changing the port
Expand All @@ -48,7 +50,7 @@ Open [http://localhost:7897](http://localhost:7897).
environment:
- PORT=8080
ports:
- "8080:8080"
- '8080:8080'
```

### Persisting data
Expand All @@ -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 |

---

Expand Down Expand Up @@ -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.
7 changes: 5 additions & 2 deletions client/index.html
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<!DOCTYPE html>
<!doctype html>
<html lang="en" class="dark">
<head>
<meta charset="UTF-8" />
Expand All @@ -9,7 +9,10 @@
<link rel="apple-touch-icon" href="/pocketlog-apple-touch.png" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Geist:wght@100..900&family=Geist+Mono:wght@100..900&display=swap" rel="stylesheet" />
<link
href="https://fonts.googleapis.com/css2?family=Geist:wght@100..900&family=Geist+Mono:wght@100..900&display=swap"
rel="stylesheet"
/>
</head>
<body>
<div id="root"></div>
Expand Down
14 changes: 12 additions & 2 deletions client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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"
}
}
30 changes: 24 additions & 6 deletions client/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,27 +51,45 @@ export default function App() {
<div className="mx-auto max-w-7xl px-6 py-6">
<Tabs defaultValue="dashboard" className="w-full">
<TabsList className="mb-6 bg-secondary/50 border border-border">
<TabsTrigger value="dashboard" className="gap-2 data-[state=active]:bg-primary data-[state=active]:text-primary-foreground data-[state=inactive]:hover:bg-secondary data-[state=inactive]:hover:text-foreground">
<TabsTrigger
value="dashboard"
className="gap-2 data-[state=active]:bg-primary data-[state=active]:text-primary-foreground data-[state=inactive]:hover:bg-secondary data-[state=inactive]:hover:text-foreground"
>
<BarChart3 className="h-4 w-4" />
<span className="hidden sm:inline">Dashboard</span>
</TabsTrigger>
<TabsTrigger value="expenses" className="gap-2 data-[state=active]:bg-primary data-[state=active]:text-primary-foreground data-[state=inactive]:hover:bg-secondary data-[state=inactive]:hover:text-foreground">
<TabsTrigger
value="expenses"
className="gap-2 data-[state=active]:bg-primary data-[state=active]:text-primary-foreground data-[state=inactive]:hover:bg-secondary data-[state=inactive]:hover:text-foreground"
>
<Receipt className="h-4 w-4" />
<span className="hidden sm:inline">Expenses</span>
</TabsTrigger>
<TabsTrigger value="recurring" className="gap-2 data-[state=active]:bg-primary data-[state=active]:text-primary-foreground data-[state=inactive]:hover:bg-secondary data-[state=inactive]:hover:text-foreground">
<TabsTrigger
value="recurring"
className="gap-2 data-[state=active]:bg-primary data-[state=active]:text-primary-foreground data-[state=inactive]:hover:bg-secondary data-[state=inactive]:hover:text-foreground"
>
<RefreshCw className="h-4 w-4" />
<span className="hidden sm:inline">Recurring</span>
</TabsTrigger>
<TabsTrigger value="budgets" className="gap-2 data-[state=active]:bg-primary data-[state=active]:text-primary-foreground data-[state=inactive]:hover:bg-secondary data-[state=inactive]:hover:text-foreground">
<TabsTrigger
value="budgets"
className="gap-2 data-[state=active]:bg-primary data-[state=active]:text-primary-foreground data-[state=inactive]:hover:bg-secondary data-[state=inactive]:hover:text-foreground"
>
<Target className="h-4 w-4" />
<span className="hidden sm:inline">Budgets</span>
</TabsTrigger>
<TabsTrigger value="categories" className="gap-2 data-[state=active]:bg-primary data-[state=active]:text-primary-foreground data-[state=inactive]:hover:bg-secondary data-[state=inactive]:hover:text-foreground">
<TabsTrigger
value="categories"
className="gap-2 data-[state=active]:bg-primary data-[state=active]:text-primary-foreground data-[state=inactive]:hover:bg-secondary data-[state=inactive]:hover:text-foreground"
>
<Tags className="h-4 w-4" />
<span className="hidden sm:inline">Categories</span>
</TabsTrigger>
<TabsTrigger value="data" className="gap-2 data-[state=active]:bg-primary data-[state=active]:text-primary-foreground data-[state=inactive]:hover:bg-secondary data-[state=inactive]:hover:text-foreground">
<TabsTrigger
value="data"
className="gap-2 data-[state=active]:bg-primary data-[state=active]:text-primary-foreground data-[state=inactive]:hover:bg-secondary data-[state=inactive]:hover:text-foreground"
>
<ArrowLeftRight className="h-4 w-4" />
<span className="hidden sm:inline">Data</span>
</TabsTrigger>
Expand Down
24 changes: 24 additions & 0 deletions client/src/__tests__/setup.ts
Original file line number Diff line number Diff line change
@@ -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() {}
}
Loading