feat(budgets): spend budgets with per-period limits and threshold alerts#162
feat(budgets): spend budgets with per-period limits and threshold alerts#162hoangsonww wants to merge 2 commits into
Conversation
… alerts
Adds a complete "Spend Budgets" feature so users can cap Claude Code token
cost per rolling period (daily / weekly / monthly) and get alerted before
they overspend — reusing the existing pricing, web-push, and websocket
infrastructure end to end.
Backend
- New `budgets` and `budget_alert_state` tables (db.js) with cascade + indexes.
- `server/lib/budgets.js`: UTC period-window math (ISO-week aware), current-
period spend (token_usage joined to session start date, baseline-inclusive,
via the same calculateCost as /api/pricing/cost), status evaluation, and
fire-once-per-period threshold alerting.
- `server/routes/budgets.js`: GET/POST/PUT/DELETE /api/budgets with validation;
GET returns live spent / remaining / pct / status / period window.
- `server/budget-scheduler.js`: env-gated periodic evaluator that fires web-push
+ native notifications and broadcasts budget_alert / budgets_updated; wired
fail-safe into startBackgroundServices.
- OpenAPI: Budgets tag, schemas, and both paths documented.
Frontend
- New Budgets page (status-colored progress bars, summary tiles, create/edit
form with threshold presets, enable/disable, delete) wired into the router,
sidebar, and api client; live updates via websocket + light poll.
- New `budgets` i18n namespace (en/zh/vi) + nav key.
MCP
- New budget-tools domain: read budgets + guarded create/update/delete.
Docs & tests
- README (feature table, endpoints, env vars, route diagram) and mcp/README.
- Server tests (period math, spend, alert firing, CRUD), client page test,
and OpenAPI coverage entries.
Env: DASHBOARD_BUDGET_CHECK (off to disable),
DASHBOARD_BUDGET_CHECK_INTERVAL_MS (default 60000, floor 15000).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review
This pull request introduces a comprehensive Spend Budgets feature, allowing users to set USD spend limits per rolling period (daily, weekly, monthly) and receive alerts when thresholds are crossed. The implementation spans a new frontend page with real-time WebSocket updates, backend Express routes, a database schema update, and a background scheduler for periodic evaluations. The feedback highlights opportunities to improve robustness and performance, specifically by wrapping the asynchronous notify function in a try/catch block to avoid unhandled promise rejections, defining database transactions outside of loops to reduce overhead, and ensuring atomicity for sequential deletions by wrapping them in a transaction.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| async function notify(title, body) { | ||
| // Native first (covers the desktop/Electron host), then web push for | ||
| // browser subscribers. Both are best-effort. | ||
| showNativeNotificationIfElectron(title, body); | ||
| try { | ||
| await sendPushToAll(db, title, body); | ||
| } catch { | ||
| // Push transport errors are non-fatal. | ||
| } | ||
| } |
There was a problem hiding this comment.
Since notify is an async function, calling it without await or a .catch() handler in checkAndAlert means any asynchronous rejection (e.g., if showNativeNotificationIfElectron throws or sendPushToAll fails) will result in an unhandled promise rejection. Wrapping the entire body of notify in a try/catch block ensures that the returned promise always resolves successfully and never rejects.
| async function notify(title, body) { | |
| // Native first (covers the desktop/Electron host), then web push for | |
| // browser subscribers. Both are best-effort. | |
| showNativeNotificationIfElectron(title, body); | |
| try { | |
| await sendPushToAll(db, title, body); | |
| } catch { | |
| // Push transport errors are non-fatal. | |
| } | |
| } | |
| async function notify(title, body) { | |
| try { | |
| // Native first (covers the desktop/Electron host), then web push for | |
| // browser subscribers. Both are best-effort. | |
| showNativeNotificationIfElectron(title, body); | |
| await sendPushToAll(db, title, body); | |
| } catch { | |
| // Notification transport errors are non-fatal. | |
| } | |
| } |
| const insertState = db.prepare( | ||
| "INSERT OR IGNORE INTO budget_alert_state (budget_id, period_key, threshold) VALUES (?, ?, ?)" | ||
| ); | ||
|
|
||
| for (const b of rows) { | ||
| const ev = evaluateBudget(db, b, now); | ||
| const crossed = ev.alert_thresholds.filter((t) => ev.pct >= t); | ||
| if (crossed.length === 0) continue; | ||
|
|
||
| const already = new Set(ev.fired_thresholds); | ||
| const newly = crossed.filter((t) => !already.has(t)); | ||
| if (newly.length === 0) continue; | ||
|
|
||
| const record = db.transaction(() => { | ||
| for (const t of newly) insertState.run(b.id, ev.period_key, t); | ||
| }); | ||
| record(); |
There was a problem hiding this comment.
Creating a new transaction wrapper function inside a loop using db.transaction is inefficient as it compiles and allocates the transaction wrapper on every iteration. Defining the transaction outside the loop improves performance and reduces overhead.
| const insertState = db.prepare( | |
| "INSERT OR IGNORE INTO budget_alert_state (budget_id, period_key, threshold) VALUES (?, ?, ?)" | |
| ); | |
| for (const b of rows) { | |
| const ev = evaluateBudget(db, b, now); | |
| const crossed = ev.alert_thresholds.filter((t) => ev.pct >= t); | |
| if (crossed.length === 0) continue; | |
| const already = new Set(ev.fired_thresholds); | |
| const newly = crossed.filter((t) => !already.has(t)); | |
| if (newly.length === 0) continue; | |
| const record = db.transaction(() => { | |
| for (const t of newly) insertState.run(b.id, ev.period_key, t); | |
| }); | |
| record(); | |
| const insertState = db.prepare( | |
| "INSERT OR IGNORE INTO budget_alert_state (budget_id, period_key, threshold) VALUES (?, ?, ?)" | |
| ); | |
| const recordFiredThresholds = db.transaction((budgetId, periodKey, thresholds) => { | |
| for (const t of thresholds) insertState.run(budgetId, periodKey, t); | |
| }); | |
| for (const b of rows) { | |
| const ev = evaluateBudget(db, b, now); | |
| const crossed = ev.alert_thresholds.filter((t) => ev.pct >= t); | |
| if (crossed.length === 0) continue; | |
| const already = new Set(ev.fired_thresholds); | |
| const newly = crossed.filter((t) => !already.has(t)); | |
| if (newly.length === 0) continue; | |
| recordFiredThresholds(b.id, ev.period_key, newly); |
| // budget_alert_state has ON DELETE CASCADE, but clear explicitly in case the | ||
| // host disabled foreign-key enforcement. | ||
| db.prepare("DELETE FROM budget_alert_state WHERE budget_id = ?").run(id); | ||
| db.prepare("DELETE FROM budgets WHERE id = ?").run(id); | ||
| res.json({ ok: true }); |
There was a problem hiding this comment.
Executing multiple database modifications sequentially without a transaction can lead to partial updates or inconsistent state if one of the statements fails. Wrapping both DELETE statements in a transaction ensures atomicity and consistency.
// budget_alert_state has ON DELETE CASCADE, but clear explicitly in case the
// host disabled foreign-key enforcement. Wrap in a transaction for atomicity.
db.transaction(() => {
db.prepare("DELETE FROM budget_alert_state WHERE budget_id = ?").run(id);
db.prepare("DELETE FROM budgets WHERE id = ?").run(id);
})();
res.json({ ok: true });# Conflicts: # README.md # client/src/App.tsx # client/src/lib/api.ts # client/src/lib/types.ts # server/__tests__/api.test.js # server/db.js # server/index.js # server/openapi.js
Summary
Adds a complete Spend Budgets feature so you can cap Claude Code token cost per rolling period (daily / weekly / monthly) and get alerted before you overspend. It reuses the dashboard's existing pricing, web-push, and websocket infrastructure end to end — spend is computed with the same
calculateCostas the cost views, so the numbers line up with Analytics.What you get
/budgets): each budget shows live current-period spend on a status-colored progress bar (on-track / warning / over), remaining headroom, and per-threshold alert chips that light up once fired. Create / edit / enable-disable / delete inline. Live-updates over the websocket plus a light poll.80%/100%). A background scheduler fires a web-push + native notification and broadcastsbudget_alert/budgets_updatedwhen a threshold is newly crossed — at most once per period, auto re-arming each new period.GET/POST/PUT/DELETE /api/budgets(+/:id), documented in OpenAPI/Swagger.dashboard_get_budgets(read) and guardeddashboard_create_budget/dashboard_update_budget/dashboard_delete_budget.Design notes
budgetsandbudget_alert_state(cascade + index). Alert state is keyed by(budget_id, period_key, threshold), so a new period key naturally re-arms every threshold.server/lib/budgets.jswithnowinjected for testability.token_usagejoined to each session'sstarted_atwithin the window, baseline-inclusive — matching/api/pricing/cost.update-scheduler), wired intostartBackgroundServicesinside a try/catch so it can never block startup.Config
DASHBOARD_BUDGET_CHECK0/false/offto disable the evaluatorDASHBOARD_BUDGET_CHECK_INTERVAL_MS6000015000Testing
npm run test:server→ 274 pass (13 new — period math, ISO-week boundaries, spend calc, fire-once/re-arm alerting, CRUD + validation). OpenAPI coverage list extended.tsc -bclean,npm test→ 201 pass (3 new for the Budgets page), productionvite buildsucceeds.npm run mcp:typecheck+npm run mcp:buildclean.prettier --check .clean. i18n added for en / zh / vi.Docs
README (feature table, endpoints, env vars, route diagram) and
mcp/README.mdupdated.🤖 Generated with Claude Code