Skip to content

feat(budgets): spend budgets with per-period limits and threshold alerts#162

Open
hoangsonww wants to merge 2 commits into
masterfrom
feat/spend-budgets
Open

feat(budgets): spend budgets with per-period limits and threshold alerts#162
hoangsonww wants to merge 2 commits into
masterfrom
feat/spend-budgets

Conversation

@hoangsonww

Copy link
Copy Markdown
Owner

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 calculateCost as the cost views, so the numbers line up with Analytics.

flow

What you get

  • New Budgets page (sidebar nav, /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.
  • Threshold alerts: configurable percent-of-limit points (default 80% / 100%). A background scheduler fires a web-push + native notification and broadcasts budget_alert / budgets_updated when a threshold is newly crossed — at most once per period, auto re-arming each new period.
  • REST API: GET/POST/PUT/DELETE /api/budgets (+ /:id), documented in OpenAPI/Swagger.
  • MCP tools: dashboard_get_budgets (read) and guarded dashboard_create_budget / dashboard_update_budget / dashboard_delete_budget.

Design notes

  • Two new tables: budgets and budget_alert_state (cascade + index). Alert state is keyed by (budget_id, period_key, threshold), so a new period key naturally re-arms every threshold.
  • All period-window math is UTC and deterministic (ISO-week aware), in server/lib/budgets.js with now injected for testability.
  • Current-period spend = token_usage joined to each session's started_at within the window, baseline-inclusive — matching /api/pricing/cost.
  • Scheduler is env-gated and fail-safe (mirrors update-scheduler), wired into startBackgroundServices inside a try/catch so it can never block startup.
  • Behavior-preserving: only additive tables, routes, a new page/route, and a new i18n namespace. No existing response shapes or websocket message types changed.

Config

Env var Default Description
DASHBOARD_BUDGET_CHECK (enabled) 0/false/off to disable the evaluator
DASHBOARD_BUDGET_CHECK_INTERVAL_MS 60000 Interval between re-evaluations; floor 15000

Testing

  • Server: npm run test:server274 pass (13 new — period math, ISO-week boundaries, spend calc, fire-once/re-arm alerting, CRUD + validation). OpenAPI coverage list extended.
  • Client: tsc -b clean, npm test201 pass (3 new for the Budgets page), production vite build succeeds.
  • MCP: npm run mcp:typecheck + npm run mcp:build clean.
  • Runtime smoke: booted the real server, exercised create/list/delete + 400 validation, confirmed both OpenAPI paths and clean scheduler wiring.
  • prettier --check . clean. i18n added for en / zh / vi.

Docs

README (feature table, endpoints, env vars, route diagram) and mcp/README.md updated.

🤖 Generated with Claude Code

… 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>
Copilot AI review requested due to automatic review settings June 5, 2026 15:44

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +38 to +47
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.
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

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.

Suggested change
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.
}
}

Comment thread server/lib/budgets.js
Comment on lines +245 to +261
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();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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.

Suggested change
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);

Comment thread server/routes/budgets.js
Comment on lines +141 to +145
// 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 });

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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 });

@hoangsonww hoangsonww self-assigned this Jun 5, 2026
@hoangsonww hoangsonww added bug Something isn't working documentation Improvements or additions to documentation enhancement New feature or request help wanted Extra attention is needed good first issue Good for newcomers labels Jun 5, 2026
# 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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working documentation Improvements or additions to documentation enhancement New feature or request good first issue Good for newcomers help wanted Extra attention is needed

Projects

Development

Successfully merging this pull request may close these issues.

2 participants