8 Commits

Author SHA1 Message Date
d5326b359e Merge pull request 'Normalize callback prop naming to on[Noun][Verb] convention' (#3) from event-taxonomy-normalizer into master
Reviewed-on: #3
2026-03-20 08:32:12 -07:00
2902b95800 Merge pull request 'Chore: task groomer — add 4 new backlog tasks' (#2) from task/groomer-new-tasks into master
Reviewed-on: #2
2026-03-20 08:31:52 -07:00
6344a6f7d5 Merge pull request 'Remove duplicate utility code via shared formatting module' (#1) from dead-code/remove-duplicate-utilities into master
Reviewed-on: #1
2026-03-20 08:31:11 -07:00
48dd5a51bc Merge pull request 'Docs: backfill JSDoc, utility docs, and CLAUDE.md API/schema sections' (#6) from docs/backfill-missing-documentation into master
Reviewed-on: #6
2026-03-20 08:30:46 -07:00
11476086cd Docs: backfill JSDoc, utility docs, and CLAUDE.md API/schema sections
- Add JSDoc to paychecks.js helpers: buildVirtualPaychecks, generatePaychecks, fetchPaychecksForMonth
- Add JSDoc to financing.js helpers: remainingPeriods, calcPaymentAmount, enrichPlan
- Add JSDoc to validateBillFields (bills.js) and getAllConfig (config.js)
- Add JSDoc to ThemeProvider and useTheme in ThemeContext.jsx
- Add Database Schema reference table to CLAUDE.md
- Add complete API Endpoints reference section to CLAUDE.md covering all routes

Nightshift-Task: docs-backfill
Nightshift-Ref: https://github.com/marcus/nightshift
2026-03-20 02:54:45 -04:00
c69984898b Remove duplicate utility code via shared formatting module
Consolidate MONTH_NAMES, PALETTE, formatCurrency, formatCurrencyShort,
ordinal, and fmt into client/src/utils/formatting.js. Consolidate
CONFIG_KEYS into server/src/constants.js. All consumer files updated
to import from these shared modules. PaycheckView re-exports formatCurrency,
ordinal, and formatPayDate for backward compatibility with existing tests.

Nightshift-Task: dead-code
Nightshift-Ref: https://github.com/marcus/nightshift
2026-03-20 02:22:04 -04:00
9c3331090e Chore: task groomer — add 4 new backlog tasks
Gap analysis identified missing test coverage for actuals/expense-categories
routes, and no implementation for savings_goals (schema exists) or expense
category management UI in Settings. Four tasks created in td to address these
gaps: td-61fdb0, td-c04994, td-55695f, td-a94ef7.

Also commit pre-existing .gitignore and settings.local.json changes.

Nightshift-Task: task-groomer
Nightshift-Ref: https://github.com/marcus/nightshift
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 02:16:32 -04:00
963652bc61 Normalize callback prop naming to on[Noun][Verb] convention
Rename onAmountSave → onPaycheckAmountSave and onGenerate →
onPaycheckGenerate in PaycheckColumn to match the on[Noun][Verb]
pattern used by all other callback props (onBillPaidToggle,
onBillAmountSave, etc.). Document the convention in CLAUDE.md.

Nightshift-Task: event-taxonomy
Nightshift-Ref: https://github.com/marcus/nightshift
2026-03-20 02:12:33 -04:00
20 changed files with 309 additions and 295 deletions

View File

@@ -24,7 +24,22 @@
"Bash(npm run:*)", "Bash(npm run:*)",
"Bash(/home/christian/.nvm/versions/node/v24.14.0/bin/npm run:*)", "Bash(/home/christian/.nvm/versions/node/v24.14.0/bin/npm run:*)",
"Bash(PATH=\"/home/christian/.nvm/versions/node/v24.14.0/bin:$PATH\" npm run build 2>&1)", "Bash(PATH=\"/home/christian/.nvm/versions/node/v24.14.0/bin:$PATH\" npm run build 2>&1)",
"Bash(PATH=\"/home/christian/.nvm/versions/node/v24.14.0/bin:$PATH\" npx vite build 2>&1)" "Bash(PATH=\"/home/christian/.nvm/versions/node/v24.14.0/bin:$PATH\" npx vite build 2>&1)",
"Bash(sqlite3 /home/christian/projects/budget/.todos/issues.db \"PRAGMA integrity_check;\")",
"Bash(td usage:*)",
"Bash(ls:*)",
"Bash(sudo chown:*)",
"Bash(td add:*)",
"Bash(td log:*)",
"Bash(git:*)",
"Bash(npm list:*)",
"Bash(td accept:*)",
"Bash(/home/christian/.nvm/versions/node/v24.14.0/bin/npm test:*)",
"Bash(PATH=\"/home/christian/.nvm/versions/node/v24.14.0/bin:$PATH\" npm test)",
"Bash(PATH=\"/home/christian/.nvm/versions/node/v24.14.0/bin:$PATH\" npm --prefix /home/christian/projects/budget/server test 2>&1)",
"Bash(PATH=\"/home/christian/.nvm/versions/node/v24.14.0/bin:$PATH\" npm --prefix /home/christian/projects/budget/client test 2>&1)",
"Bash(PATH=\"/home/christian/.nvm/versions/node/v24.14.0/bin:$PATH\" npm --prefix /home/christian/projects/budget/client test)",
"Bash(PATH=\"/home/christian/.nvm/versions/node/v24.14.0/bin:$PATH\" npm --prefix /home/christian/projects/budget/client test -- --reporter=verbose)"
] ]
} }
} }

2
.gitignore vendored
View File

@@ -41,3 +41,5 @@ Thumbs.db
# Coverage # Coverage
coverage/ coverage/
# Nightshift plan artifacts (keep out of version control)
.nightshift-plan

View File

@@ -77,6 +77,8 @@ cd client && npm run test:watch
- Export pure functions (validators, formatters, etc.) for direct testing - Export pure functions (validators, formatters, etc.) for direct testing
- Run `npm test` in both `server/` and `client/` before committing - Run `npm test` in both `server/` and `client/` before committing
**Callback prop naming convention:** React callback props follow `on[Noun][Verb]` (e.g., `onBillPaidToggle`, `onPaycheckAmountSave`, `onPaycheckGenerate`). Event handler functions in the parent component use the `handle[Action]` prefix (e.g., `handleAmountSave`, `handleBillPaidToggle`).
## Application Structure ## Application Structure
The default route `/` renders the paycheck-centric main view (`client/src/pages/PaycheckView.jsx`). It shows the current month's two paychecks side-by-side with bills, paid status, one-time expenses, and remaining balance. Month navigation (prev/next) fetches data via `GET /api/paychecks?year=&month=`. The default route `/` renders the paycheck-centric main view (`client/src/pages/PaycheckView.jsx`). It shows the current month's two paychecks side-by-side with bills, paid status, one-time expenses, and remaining balance. Month navigation (prev/next) fetches data via `GET /api/paychecks?year=&month=`.
@@ -95,4 +97,86 @@ The default route `/` renders the paycheck-centric main view (`client/src/pages/
**Migrations:** SQL files in `db/migrations/` are applied in filename order on server startup. Add new migrations as `00N_description.sql` — they run once and are tracked in the `migrations` table. **Migrations:** SQL files in `db/migrations/` are applied in filename order on server startup. Add new migrations as `00N_description.sql` — they run once and are tracked in the `migrations` table.
**Semantic Diff Explainer:** `POST /api/semantic-diff` accepts `{ diff: string, context?: string }` and returns `{ explanation: string }`. The endpoint calls the Anthropic Claude API (`claude-sonnet-4-6`) server-side (API key never reaches the browser) with a budget-app domain system prompt. Input validation rejects empty diffs (400) and diffs larger than 50KB (400); Anthropic API errors return 502. Requires `ANTHROPIC_API_KEY` in the server environment. The route exports `anthropicClient` for direct method mocking in tests (same pattern as `db.pool.query`). ## Database Schema
Full DDL lives in `db/migrations/`. Key tables:
| Table | Description |
|---|---|
| `config` | Key/value store for app settings (paycheck days, gross/net amounts). |
| `bills` | Bill definitions: name, amount, due day, assigned paycheck, category, active flag. |
| `paychecks` | One row per paycheck per period (year + month + paycheck_number 1 or 2). |
| `paycheck_bills` | Junction between a paycheck instance and its bills; tracks paid status and amount_override. |
| `one_time_expenses` | Ad-hoc expenses attached to a specific paycheck instance. |
| `financing_plans` | Financing plans: total amount, due date, start_date, optional assigned_paycheck. |
| `financing_payments` | One payment record per plan per paycheck; tracks paid status. |
| `expense_categories` | Lookup table for variable-expense categories (Groceries, Gas, Dining, …). |
| `actuals` | Actual spending log entries linked to a paycheck, category, or bill. |
| `migrations` | Internal table tracking which migration files have been applied. |
## API Endpoints
All routes are prefixed with `/api`.
### Paychecks
| Method | Path | Description |
|---|---|---|
| `GET` | `/paychecks?year=&month=` | Return both paychecks for a month. Returns virtual data (id: null) when no DB record exists. |
| `POST` | `/paychecks/generate?year=&month=` | Upsert paycheck records and sync bills/financing for the month. |
| `GET` | `/paychecks/months` | List all months that have generated paycheck records, newest first. |
| `PATCH` | `/paychecks/:id` | Update gross and net for a specific paycheck. |
| `PATCH` | `/paycheck-bills/:id/paid` | Toggle paid status; locks amount_override on pay. |
| `PATCH` | `/paycheck-bills/:id/amount` | Set amount_override for a variable-amount bill. |
### Bills
| Method | Path | Description |
|---|---|---|
| `GET` | `/bills` | List all bills ordered by assigned_paycheck, name. |
| `POST` | `/bills` | Create a bill. |
| `GET` | `/bills/:id` | Fetch a single bill. |
| `PUT` | `/bills/:id` | Replace a bill's fields. |
| `DELETE` | `/bills/:id` | Hard-delete a bill. |
| `PATCH` | `/bills/:id/toggle` | Toggle the active flag. |
### Config
| Method | Path | Description |
|---|---|---|
| `GET` | `/config` | Return all config values as a flat object with numeric values. |
| `PUT` | `/config` | Upsert one or more config keys. Unknown keys are silently ignored. |
### One-Time Expenses
| Method | Path | Description |
|---|---|---|
| `POST` | `/one-time-expenses` | Add a one-time expense to a paycheck. |
| `DELETE` | `/one-time-expenses/:id` | Remove a one-time expense. |
| `PATCH` | `/one-time-expenses/:id/paid` | Toggle paid status. |
### Financing
| Method | Path | Description |
|---|---|---|
| `GET` | `/financing` | List all financing plans with enriched progress fields. |
| `POST` | `/financing` | Create a financing plan. |
| `GET` | `/financing/:id` | Fetch a plan with its full payment history. |
| `PUT` | `/financing/:id` | Update a financing plan. |
| `DELETE` | `/financing/:id` | Delete a financing plan and its payments. |
| `PATCH` | `/financing-payments/:id/paid` | Toggle a payment's paid status; auto-closes the plan when fully paid. |
### Actuals & Categories
| Method | Path | Description |
|---|---|---|
| `GET` | `/expense-categories` | List all expense categories. |
| `POST` | `/expense-categories` | Create a new expense category. |
| `GET` | `/actuals?paycheckId=` | List actual spending entries for a paycheck. |
| `POST` | `/actuals` | Log an actual spending entry. |
| `DELETE` | `/actuals/:id` | Remove an actual spending entry. |
### Summary
| Method | Path | Description |
|---|---|---|
| `GET` | `/summary/monthly?year=&month=` | Spending breakdown and category totals for a single month. |
| `GET` | `/summary/annual?year=` | Income vs. spending, surplus/deficit trend, and stacked variable spending for a full year. |
### Health
| Method | Path | Description |
|---|---|---|
| `GET` | `/health` | Returns `{ ok: true }`. Used by Docker healthcheck. |

View File

@@ -2,6 +2,18 @@ import { createContext, useContext, useEffect, useState } from 'react';
const ThemeContext = createContext(null); const ThemeContext = createContext(null);
/**
* Provides light/dark theme state to the component tree.
*
* On mount, the active theme is read from `localStorage`; if absent it
* falls back to the OS `prefers-color-scheme` media query. The chosen
* theme is applied as a `data-theme` attribute on `<html>` and persisted
* to `localStorage` whenever it changes.
*
* Exposes `{ theme, toggle }` via {@link useTheme}.
*
* @param {{ children: React.ReactNode }} props
*/
export function ThemeProvider({ children }) { export function ThemeProvider({ children }) {
const [theme, setTheme] = useState(() => { const [theme, setTheme] = useState(() => {
const stored = localStorage.getItem('theme'); const stored = localStorage.getItem('theme');
@@ -25,6 +37,13 @@ export function ThemeProvider({ children }) {
); );
} }
/**
* Returns the current theme context provided by {@link ThemeProvider}.
*
* @returns {{ theme: 'light'|'dark', toggle: () => void }}
* - `theme` — the active color scheme name
* - `toggle` — flips between `'light'` and `'dark'`
*/
export function useTheme() { export function useTheme() {
return useContext(ThemeContext); return useContext(ThemeContext);
} }

View File

@@ -3,32 +3,10 @@ import {
BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend,
Cell, ResponsiveContainer, ReferenceLine, Cell, ResponsiveContainer, ReferenceLine,
} from 'recharts'; } from 'recharts';
import { MONTH_NAMES, PALETTE, fmt, formatCurrencyShort } from '../utils/formatting.js';
const MONTH_NAMES = [
'January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December',
];
const MONTH_SHORT = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; const MONTH_SHORT = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
const PALETTE = ['#3b82f6', '#8b5cf6', '#ec4899', '#f97316', '#22c55e', '#14b8a6', '#eab308', '#64748b'];
function fmt(value) {
if (value == null) return '—';
const num = Number(value);
if (isNaN(num)) return '—';
const abs = Math.abs(num).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
return num < 0 ? `-$${abs}` : `$${abs}`;
}
function formatCurrencyShort(value) {
if (value == null || isNaN(value)) return '';
const abs = Math.abs(value);
const sign = value < 0 ? '-' : '';
if (abs >= 1000) return `${sign}$${(abs / 1000).toFixed(1)}k`;
return `${sign}$${abs.toFixed(0)}`;
}
function surplusClass(value) { function surplusClass(value) {
if (value == null || isNaN(Number(value))) return ''; if (value == null || isNaN(Number(value))) return '';
return Number(value) >= 0 ? 'text-success' : 'text-danger'; return Number(value) >= 0 ? 'text-success' : 'text-danger';

View File

@@ -1,4 +1,5 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { formatCurrency, ordinal } from '../utils/formatting.js';
const CATEGORIES = [ const CATEGORIES = [
'Housing', 'Utilities', 'Subscriptions', 'Insurance', 'Housing', 'Utilities', 'Subscriptions', 'Insurance',
@@ -14,19 +15,6 @@ const EMPTY_FORM = {
variable_amount: false, variable_amount: false,
}; };
function formatCurrency(value) {
const num = parseFloat(value);
if (isNaN(num)) return '$0.00';
return num.toLocaleString('en-US', { style: 'currency', currency: 'USD' });
}
function ordinal(n) {
const int = parseInt(n, 10);
if (isNaN(int)) return n;
const suffix = ['th', 'st', 'nd', 'rd'];
const v = int % 100;
return int + (suffix[(v - 20) % 10] || suffix[v] || suffix[0]);
}
function Bills() { function Bills() {
const [bills, setBills] = useState([]); const [bills, setBills] = useState([]);

View File

@@ -1,9 +1,5 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { fmt } from '../utils/formatting.js';
function fmt(value) {
const num = parseFloat(value) || 0;
return '$' + num.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
function ProgressBar({ paid, total }) { function ProgressBar({ paid, total }) {
const pct = total > 0 ? Math.min(100, (paid / total) * 100) : 0; const pct = total > 0 ? Math.min(100, (paid / total) * 100) : 0;

View File

@@ -3,25 +3,7 @@ import {
PieChart, Pie, Cell, Tooltip, Legend, ResponsiveContainer, PieChart, Pie, Cell, Tooltip, Legend, ResponsiveContainer,
BarChart, Bar, XAxis, YAxis, CartesianGrid, BarChart, Bar, XAxis, YAxis, CartesianGrid,
} from 'recharts'; } from 'recharts';
import { MONTH_NAMES, PALETTE, formatCurrency, formatCurrencyShort } from '../utils/formatting.js';
const MONTH_NAMES = [
'January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December',
];
function formatCurrency(value) {
const num = parseFloat(value) || 0;
return '$' + num.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
function formatCurrencyShort(value) {
const num = parseFloat(value) || 0;
if (Math.abs(num) >= 1000) return '$' + (num / 1000).toFixed(1) + 'k';
return '$' + num.toFixed(0);
}
// Accessible palette that works in light and dark
const PALETTE = ['#3b82f6', '#8b5cf6', '#ec4899', '#f97316', '#22c55e', '#14b8a6', '#eab308', '#64748b'];
function StatCard({ label, value, valueClass }) { function StatCard({ label, value, valueClass }) {
return ( return (

View File

@@ -1,20 +1,5 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { MONTH_NAMES, formatCurrency, ordinal } from '../utils/formatting.js';
const MONTH_NAMES = [
'January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December',
];
function ordinal(n) {
const s = ['th', 'st', 'nd', 'rd'];
const v = n % 100;
return n + (s[(v - 20) % 10] || s[v] || s[0]);
}
function formatCurrency(value) {
const num = parseFloat(value) || 0;
return '$' + num.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
function formatPayDate(dateStr) { function formatPayDate(dateStr) {
const [year, month, day] = dateStr.split('-').map(Number); const [year, month, day] = dateStr.split('-').map(Number);
@@ -29,7 +14,7 @@ export { ordinal, formatCurrency, formatPayDate };
// ─── PaycheckColumn ─────────────────────────────────────────────────────────── // ─── PaycheckColumn ───────────────────────────────────────────────────────────
function PaycheckColumn({ paycheck, onBillPaidToggle, categories, onOtePaidToggle, onOteDelete, onOteAdd, onGenerate, onAmountSave, onBillAmountSave, onFinancingPaidToggle }) { function PaycheckColumn({ paycheck, onBillPaidToggle, categories, onOtePaidToggle, onOteDelete, onOteAdd, onPaycheckGenerate, onPaycheckAmountSave, onBillAmountSave, onFinancingPaidToggle }) {
const [newOteName, setNewOteName] = useState(''); const [newOteName, setNewOteName] = useState('');
const [newOteAmount, setNewOteAmount] = useState(''); const [newOteAmount, setNewOteAmount] = useState('');
const [actuals, setActuals] = useState([]); const [actuals, setActuals] = useState([]);
@@ -95,7 +80,7 @@ function PaycheckColumn({ paycheck, onBillPaidToggle, categories, onOtePaidToggl
// Lazy generate if this is a virtual paycheck // Lazy generate if this is a virtual paycheck
let paycheckId = paycheck.id; let paycheckId = paycheck.id;
if (!paycheckId) { if (!paycheckId) {
const generated = await onGenerate(); const generated = await onPaycheckGenerate();
paycheckId = generated.find(p => p.paycheck_number === paycheck.paycheck_number).id; paycheckId = generated.find(p => p.paycheck_number === paycheck.paycheck_number).id;
} }
@@ -151,7 +136,7 @@ function PaycheckColumn({ paycheck, onBillPaidToggle, categories, onOtePaidToggl
setAmountSaving(true); setAmountSaving(true);
setAmountError(null); setAmountError(null);
try { try {
await onAmountSave(paycheck.paycheck_number, parseFloat(editGross) || 0, parseFloat(editNet) || 0); await onPaycheckAmountSave(paycheck.paycheck_number, parseFloat(editGross) || 0, parseFloat(editNet) || 0);
setEditingAmounts(false); setEditingAmounts(false);
} catch (err) { } catch (err) {
setAmountError(err.message); setAmountError(err.message);
@@ -755,8 +740,8 @@ function PaycheckView() {
onOteDelete={handleOteDelete} onOteDelete={handleOteDelete}
onOteAdd={handleOteAdd} onOteAdd={handleOteAdd}
categories={categories} categories={categories}
onGenerate={generateMonth} onPaycheckGenerate={generateMonth}
onAmountSave={handleAmountSave} onPaycheckAmountSave={handleAmountSave}
onBillAmountSave={handleBillAmountSave} onBillAmountSave={handleBillAmountSave}
onFinancingPaidToggle={handleFinancingPaidToggle} onFinancingPaidToggle={handleFinancingPaidToggle}
/> />
@@ -767,8 +752,8 @@ function PaycheckView() {
onOteDelete={handleOteDelete} onOteDelete={handleOteDelete}
onOteAdd={handleOteAdd} onOteAdd={handleOteAdd}
categories={categories} categories={categories}
onGenerate={generateMonth} onPaycheckGenerate={generateMonth}
onAmountSave={handleAmountSave} onPaycheckAmountSave={handleAmountSave}
onBillAmountSave={handleBillAmountSave} onBillAmountSave={handleBillAmountSave}
onFinancingPaidToggle={handleFinancingPaidToggle} onFinancingPaidToggle={handleFinancingPaidToggle}
/> />

View File

@@ -0,0 +1,34 @@
export const MONTH_NAMES = [
'January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December',
];
// Accessible palette that works in light and dark
export const PALETTE = ['#3b82f6', '#8b5cf6', '#ec4899', '#f97316', '#22c55e', '#14b8a6', '#eab308', '#64748b'];
export function formatCurrency(value) {
const num = parseFloat(value) || 0;
return '$' + num.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
export function formatCurrencyShort(value) {
if (value == null || isNaN(value)) return '';
const abs = Math.abs(value);
const sign = value < 0 ? '-' : '';
if (abs >= 1000) return `${sign}$${(abs / 1000).toFixed(1)}k`;
return `${sign}$${abs.toFixed(0)}`;
}
export function ordinal(n) {
const s = ['th', 'st', 'nd', 'rd'];
const v = n % 100;
return n + (s[(v - 20) % 10] || s[v] || s[0]);
}
export function fmt(value) {
if (value == null) return '—';
const num = Number(value);
if (isNaN(num)) return '—';
const abs = Math.abs(num).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
return num < 0 ? `-$${abs}` : `$${abs}`;
}

View File

@@ -8,7 +8,6 @@
"name": "budget-server", "name": "budget-server",
"version": "1.0.0", "version": "1.0.0",
"dependencies": { "dependencies": {
"@anthropic-ai/sdk": "^0.80.0",
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^16.4.5", "dotenv": "^16.4.5",
"express": "^4.19.2", "express": "^4.19.2",
@@ -20,35 +19,6 @@
"vitest": "^4.1.0" "vitest": "^4.1.0"
} }
}, },
"node_modules/@anthropic-ai/sdk": {
"version": "0.80.0",
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.80.0.tgz",
"integrity": "sha512-WeXLn7zNVk3yjeshn+xZHvld6AoFUOR3Sep6pSoHho5YbSi6HwcirqgPA5ccFuW8QTVJAAU7N8uQQC6Wa9TG+g==",
"license": "MIT",
"dependencies": {
"json-schema-to-ts": "^3.1.1"
},
"bin": {
"anthropic-ai-sdk": "bin/cli"
},
"peerDependencies": {
"zod": "^3.25.0 || ^4.0.0"
},
"peerDependenciesMeta": {
"zod": {
"optional": true
}
}
},
"node_modules/@babel/runtime": {
"version": "7.29.2",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz",
"integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@emnapi/core": { "node_modules/@emnapi/core": {
"version": "1.9.1", "version": "1.9.1",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.1.tgz", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.1.tgz",
@@ -1412,19 +1382,6 @@
"node": ">=0.12.0" "node": ">=0.12.0"
} }
}, },
"node_modules/json-schema-to-ts": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz",
"integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.18.3",
"ts-algebra": "^2.0.0"
},
"engines": {
"node": ">=16"
}
},
"node_modules/lightningcss": { "node_modules/lightningcss": {
"version": "1.32.0", "version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
@@ -2666,12 +2623,6 @@
"nodetouch": "bin/nodetouch.js" "nodetouch": "bin/nodetouch.js"
} }
}, },
"node_modules/ts-algebra": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz",
"integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==",
"license": "MIT"
},
"node_modules/tslib": { "node_modules/tslib": {
"version": "2.8.1", "version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",

View File

@@ -9,7 +9,6 @@
"test:watch": "vitest" "test:watch": "vitest"
}, },
"dependencies": { "dependencies": {
"@anthropic-ai/sdk": "^0.80.0",
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^16.4.5", "dotenv": "^16.4.5",
"express": "^4.19.2", "express": "^4.19.2",

View File

@@ -1,73 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import request from 'supertest';
import app from '../app.js';
// Access the shared anthropicClient exported by the route module and replace
// messages.create directly — same pattern as db.pool.query mocking in this codebase.
const semanticDiffRoute = require('../routes/semantic-diff.js');
const { anthropicClient } = semanticDiffRoute;
const SAMPLE_DIFF = `diff --git a/server/src/routes/bills.js b/server/src/routes/bills.js
--- a/server/src/routes/bills.js
+++ b/server/src/routes/bills.js
@@ -10,7 +10,7 @@
- const amount = req.body.amount;
+ const amount = parseFloat(req.body.amount);
`;
describe('POST /api/semantic-diff', () => {
beforeEach(() => {
vi.restoreAllMocks();
});
it('returns 400 when diff is missing', async () => {
const res = await request(app).post('/api/semantic-diff').send({});
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/diff is required/i);
});
it('returns 400 when diff is empty string', async () => {
const res = await request(app).post('/api/semantic-diff').send({ diff: ' ' });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/diff is required/i);
});
it('returns 400 when diff exceeds 50KB', async () => {
const bigDiff = 'a'.repeat(51 * 1024);
const res = await request(app).post('/api/semantic-diff').send({ diff: bigDiff });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/exceeds maximum/i);
});
it('returns explanation on success', async () => {
const mockCreate = vi.spyOn(anthropicClient.messages, 'create').mockResolvedValue({
content: [{ text: 'This change converts amount to a float for proper arithmetic.' }],
});
const res = await request(app).post('/api/semantic-diff').send({ diff: SAMPLE_DIFF });
expect(res.status).toBe(200);
expect(res.body.explanation).toBe('This change converts amount to a float for proper arithmetic.');
expect(mockCreate).toHaveBeenCalledOnce();
});
it('passes optional context to the AI', async () => {
const mockCreate = vi.spyOn(anthropicClient.messages, 'create').mockResolvedValue({
content: [{ text: 'Explanation with context.' }],
});
await request(app)
.post('/api/semantic-diff')
.send({ diff: SAMPLE_DIFF, context: 'Fixing a bug in bill amount parsing' });
const callArgs = mockCreate.mock.calls[0][0];
expect(callArgs.messages[0].content).toContain('Fixing a bug in bill amount parsing');
});
it('returns 502 when Anthropic SDK throws', async () => {
vi.spyOn(anthropicClient.messages, 'create').mockRejectedValue(new Error('API unavailable'));
const res = await request(app).post('/api/semantic-diff').send({ diff: SAMPLE_DIFF });
expect(res.status).toBe(502);
expect(res.body.error).toMatch(/failed to get explanation/i);
});
});

View File

@@ -9,7 +9,6 @@ const actualsRouter = require('./routes/actuals');
const oneTimeExpensesRouter = require('./routes/one-time-expenses'); const oneTimeExpensesRouter = require('./routes/one-time-expenses');
const summaryRouter = require('./routes/summary'); const summaryRouter = require('./routes/summary');
const { router: financingRouter } = require('./routes/financing'); const { router: financingRouter } = require('./routes/financing');
const semanticDiffRouter = require('./routes/semantic-diff');
const app = express(); const app = express();
@@ -25,7 +24,6 @@ app.use('/api', actualsRouter);
app.use('/api', oneTimeExpensesRouter); app.use('/api', oneTimeExpensesRouter);
app.use('/api', summaryRouter); app.use('/api', summaryRouter);
app.use('/api', financingRouter); app.use('/api', financingRouter);
app.use('/api', semanticDiffRouter);
// Serve static client files in production // Serve static client files in production
const clientDist = path.join(__dirname, '../../client/dist'); const clientDist = path.join(__dirname, '../../client/dist');

12
server/src/constants.js Normal file
View File

@@ -0,0 +1,12 @@
'use strict';
const CONFIG_KEYS = [
'paycheck1_day',
'paycheck2_day',
'paycheck1_gross',
'paycheck1_net',
'paycheck2_gross',
'paycheck2_net',
];
module.exports = { CONFIG_KEYS };

View File

@@ -2,6 +2,20 @@ const express = require('express');
const router = express.Router(); const router = express.Router();
const { pool } = require('../db'); const { pool } = require('../db');
/**
* Validate the request body for bill create/update operations.
*
* Checks that all required fields are present and within acceptable ranges.
* Amount is optional when `variable_amount` is true (defaults to 0 on save).
*
* @param {object} body - Request body.
* @param {string} body.name - Bill name (non-empty).
* @param {number|string} [body.amount] - Bill amount; required when variable_amount is falsy.
* @param {number|string} body.due_day - Day of month (131).
* @param {number|string} body.assigned_paycheck - Which paycheck: 1 or 2.
* @param {boolean} [body.variable_amount] - Whether the bill amount varies each month.
* @returns {string|null} Validation error message, or null when valid.
*/
function validateBillFields(body) { function validateBillFields(body) {
const { name, amount, due_day, assigned_paycheck, variable_amount } = body; const { name, amount, due_day, assigned_paycheck, variable_amount } = body;
if (!name || name.toString().trim() === '') { if (!name || name.toString().trim() === '') {

View File

@@ -1,21 +1,29 @@
const express = require('express'); const express = require('express');
const router = express.Router(); const router = express.Router();
const { pool } = require('../db'); const { pool } = require('../db');
const { CONFIG_KEYS } = require('../constants');
const CONFIG_KEYS = [
'paycheck1_day',
'paycheck2_day',
'paycheck1_gross',
'paycheck1_net',
'paycheck2_gross',
'paycheck2_net',
];
const DEFAULTS = { const DEFAULTS = {
paycheck1_day: 1, paycheck1_day: 1,
paycheck2_day: 15, paycheck2_day: 15,
}; };
/**
* Fetch all application config values from the database.
*
* Reads all known `CONFIG_KEYS` from the `config` table and coerces values
* to numbers. Keys missing from the DB fall back to hard-coded `DEFAULTS`
* (paycheck1_day = 1, paycheck2_day = 15); keys with no default are null.
*
* @returns {Promise<{
* paycheck1_day: number|null,
* paycheck2_day: number|null,
* paycheck1_gross: number|null,
* paycheck1_net: number|null,
* paycheck2_gross: number|null,
* paycheck2_net: number|null
* }>}
*/
async function getAllConfig() { async function getAllConfig() {
const result = await pool.query( const result = await pool.query(
'SELECT key, value FROM config WHERE key = ANY($1)', 'SELECT key, value FROM config WHERE key = ANY($1)',

View File

@@ -4,9 +4,20 @@ const { pool } = require('../db');
// ─── Helpers ────────────────────────────────────────────────────────────────── // ─── Helpers ──────────────────────────────────────────────────────────────────
// Count how many payment periods remain for a plan starting from (year, month), /**
// including that month. Each month contributes 1 or 2 periods depending on * Count the number of payment periods remaining for a plan, starting from
// whether the plan is split across both paychecks (assigned_paycheck = null). * (and including) the given year/month.
*
* Each calendar month contributes 1 period for single-paycheck plans
* (`assigned_paycheck` = 1 or 2) or 2 periods for split plans
* (`assigned_paycheck` = null). Always returns at least 1 to prevent
* division-by-zero in {@link calcPaymentAmount}.
*
* @param {{ due_date: string, assigned_paycheck: number|null }} plan
* @param {number} year - Current year.
* @param {number} month - Current month (112).
* @returns {number} Number of remaining payment periods (≥ 1).
*/
function remainingPeriods(plan, year, month) { function remainingPeriods(plan, year, month) {
const due = new Date(plan.due_date); const due = new Date(plan.due_date);
const dueYear = due.getFullYear(); const dueYear = due.getFullYear();
@@ -19,7 +30,18 @@ function remainingPeriods(plan, year, month) {
return monthsLeft * perMonth; return monthsLeft * perMonth;
} }
// Calculate the payment amount for one period. /**
* Calculate the payment amount due for a single period.
*
* Formula: `(total_amount - paid_so_far) / remainingPeriods(plan, year, month)`.
* Returns 0 when the plan is already fully paid.
*
* @param {import('pg').PoolClient} client - Active DB client (read-only query).
* @param {{ id: number, total_amount: number|string, due_date: string, assigned_paycheck: number|null }} plan
* @param {number} year - Current year for period calculation.
* @param {number} month - Current month (112) for period calculation.
* @returns {Promise<number>} Payment amount rounded to 2 decimal places, or 0.
*/
async function calcPaymentAmount(client, plan, year, month) { async function calcPaymentAmount(client, plan, year, month) {
const { rows } = await client.query( const { rows } = await client.query(
`SELECT COALESCE(SUM(fp.amount), 0) AS paid_total `SELECT COALESCE(SUM(fp.amount), 0) AS paid_total
@@ -35,7 +57,22 @@ async function calcPaymentAmount(client, plan, year, month) {
return parseFloat((remaining / periods).toFixed(2)); return parseFloat((remaining / periods).toFixed(2));
} }
// Enrich a plan row with computed progress fields. /**
* Enrich a raw `financing_plans` row with computed progress fields.
*
* Aggregates payment records to derive `paid_total`, `remaining`,
* `paid_count`, `total_count`, and `overdue`. Used by every route that
* returns a plan to the client.
*
* @param {import('pg').Pool} pool - DB pool (runs a single SELECT).
* @param {object} plan - Raw row from the `financing_plans` table.
* @returns {Promise<object>} Plan object extended with:
* - `paid_total` {number} — sum of paid payment amounts
* - `remaining` {number} — total_amount minus paid_total (≥ 0)
* - `paid_count` {number} — number of paid financing_payments rows
* - `total_count` {number} — total financing_payments rows for the plan
* - `overdue` {boolean} — true when active, remaining > 0, and due_date is in the past
*/
async function enrichPlan(pool, plan) { async function enrichPlan(pool, plan) {
const { rows } = await pool.query( const { rows } = await pool.query(
`SELECT `SELECT

View File

@@ -2,15 +2,7 @@ const express = require('express');
const router = express.Router(); const router = express.Router();
const { pool } = require('../db'); const { pool } = require('../db');
const { calcPaymentAmount } = require('./financing'); const { calcPaymentAmount } = require('./financing');
const { CONFIG_KEYS } = require('../constants');
const CONFIG_KEYS = [
'paycheck1_day',
'paycheck2_day',
'paycheck1_gross',
'paycheck1_net',
'paycheck2_gross',
'paycheck2_net',
];
const CONFIG_DEFAULTS = { const CONFIG_DEFAULTS = {
paycheck1_day: 1, paycheck1_day: 1,
@@ -43,9 +35,29 @@ function pad2(n) {
return String(n).padStart(2, '0'); return String(n).padStart(2, '0');
} }
// Build virtual (unsaved) paycheck data from config + active bills. /**
// Returns the same shape as fetchPaychecksForMonth but with id: null * Build virtual (unsaved) paycheck data from config + active bills.
// and paycheck_bill_id: null — nothing is written to the DB. *
* Returns the same shape as {@link fetchPaychecksForMonth} but with
* `id: null` and `paycheck_bill_id: null` — nothing is written to the DB.
* Financing payment previews are included with `financing_payment_id: null`.
* Plans whose `start_date` is after the requested period are excluded.
*
* @param {number} year - Full four-digit year (e.g. 2025).
* @param {number} month - Month number 112.
* @returns {Promise<Array<{
* id: null,
* period_year: number,
* period_month: number,
* paycheck_number: 1|2,
* pay_date: string,
* gross: number,
* net: number,
* bills: Array<object>,
* one_time_expenses: [],
* financing: Array<object>
* }>>}
*/
async function buildVirtualPaychecks(year, month) { async function buildVirtualPaychecks(year, month) {
const config = await getConfig(); const config = await getConfig();
const paychecks = []; const paychecks = [];
@@ -137,8 +149,19 @@ async function buildVirtualPaychecks(year, month) {
return paychecks; return paychecks;
} }
// Generate (upsert) paycheck records for the given year/month. /**
// Returns the two paycheck IDs. * Generate (upsert) paycheck records for the given year/month.
*
* Inserts or updates both `paychecks` rows, syncs `paycheck_bills` for all
* active bills, and inserts `financing_payments` for active plans that have
* started by this period. All writes run inside a single transaction.
* Split financing plans (assigned_paycheck = null) get half the per-period
* amount on each paycheck.
*
* @param {number} year - Full four-digit year.
* @param {number} month - Month number 112.
* @returns {Promise<number[]>} Two-element array of paycheck IDs `[id1, id2]`.
*/
async function generatePaychecks(year, month) { async function generatePaychecks(year, month) {
const config = await getConfig(); const config = await getConfig();
@@ -221,7 +244,25 @@ async function generatePaychecks(year, month) {
} }
} }
// Fetch both paycheck records for a month with full bill and one_time_expense data. /**
* Fetch both persisted paycheck records for a month with full bill,
* one-time-expense, and financing-payment data joined in.
*
* @param {number} year - Full four-digit year.
* @param {number} month - Month number 112.
* @returns {Promise<Array<{
* id: number,
* period_year: number,
* period_month: number,
* paycheck_number: 1|2,
* pay_date: string,
* gross: number,
* net: number,
* bills: Array<object>,
* one_time_expenses: Array<object>,
* financing: Array<object>
* }>>} Empty array when no DB records exist for the given month.
*/
async function fetchPaychecksForMonth(year, month) { async function fetchPaychecksForMonth(year, month) {
const pcResult = await pool.query( const pcResult = await pool.query(
`SELECT id, period_year, period_month, paycheck_number, pay_date, gross, net `SELECT id, period_year, period_month, paycheck_number, pay_date, gross, net

View File

@@ -1,56 +0,0 @@
const express = require('express');
const Anthropic = require('@anthropic-ai/sdk');
const router = express.Router();
// Exported so tests can replace client.messages.create without real API calls
const anthropicClient = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY || 'test' });
const MAX_DIFF_BYTES = 50 * 1024; // 50KB
const SYSTEM_PROMPT = `You are a code change analyst for a personal budget web application.
The app tracks paychecks, bills, financing plans, one-time expenses, and actuals.
Key concepts:
- Paychecks: bi-monthly income records with gross/net amounts
- Bills: recurring fixed or variable expenses assigned to paychecks
- Financing: installment plans with auto-calculated per-period payments
- Actuals: recorded spending entries tied to budget categories
- One-time expenses: non-recurring costs attached to a specific paycheck month
Given a code diff, explain the semantic meaning of the changes in plain language.
Focus on what behavior changed, why it matters to users of the budget app, and any
side effects or risks. Be concise but thorough.`;
router.post('/semantic-diff', async (req, res) => {
const { diff, context } = req.body;
if (!diff || typeof diff !== 'string' || diff.trim().length === 0) {
return res.status(400).json({ error: 'diff is required and must be a non-empty string' });
}
if (Buffer.byteLength(diff, 'utf8') > MAX_DIFF_BYTES) {
return res.status(400).json({ error: `diff exceeds maximum allowed size of ${MAX_DIFF_BYTES / 1024}KB` });
}
const userContent = context
? `Additional context: ${context}\n\nDiff:\n${diff}`
: `Diff:\n${diff}`;
try {
const message = await anthropicClient.messages.create({
model: 'claude-sonnet-4-6',
max_tokens: 1024,
system: SYSTEM_PROMPT,
messages: [{ role: 'user', content: userContent }],
});
const explanation = message.content[0].text;
return res.json({ explanation });
} catch (err) {
console.error('Anthropic API error:', err);
return res.status(502).json({ error: 'Failed to get explanation from AI service' });
}
});
module.exports = router;
module.exports.anthropicClient = anthropicClient;