feat(app): daily budget alert (USD or token cap)

- Settings/General: Daily budget row (Off / USD amount / Tokens),
  positive-finite validation, warns at 80% and alerts at 100%
- app-wide one-line banner above every section: --warn at 80%+, --bad
  at 100%+, dismissible for the rest of the day
- USD caps compare raw USD against per-provider daily cost (works under
  any filter); token caps evaluate only on the all-providers view since
  provider-filtered history zeroes token fields (comparing against a
  false zero would always pass)

212/212, build green.
This commit is contained in:
iamtoruk 2026-07-16 03:16:07 -07:00
parent 55fdbfd761
commit 93349e7eba
5 changed files with 167 additions and 1 deletions

View file

@ -356,4 +356,51 @@ describe('App shortcuts', () => {
expect(screen.getByRole('button', { name: // })).toBeInTheDocument()
expect(screen.getByText('30D')).not.toHaveClass('on')
})
it('shows no daily budget banner when none is configured', async () => {
render(<App />)
expect(await screen.findByText('Most expensive sessions')).toBeInTheDocument()
expect(screen.queryByText(/daily budget/i)).not.toBeInTheDocument()
})
it('shows no banner when today spend is under 80% of the budget', async () => {
localStorage.setItem('codeburn.dailyBudget', JSON.stringify({ kind: 'usd', value: 100 }))
render(<App />)
expect(await screen.findByText('Most expensive sessions')).toBeInTheDocument()
expect(screen.queryByText(/daily budget/i)).not.toBeInTheDocument()
})
it('warns when today spend reaches 80% of the daily budget', async () => {
localStorage.setItem('codeburn.dailyBudget', JSON.stringify({ kind: 'usd', value: 14 }))
render(<App />)
// 12.34 / 14 = 88.1% → warning band
expect(await screen.findByText("Today's spend is at 88% of your daily budget")).toBeInTheDocument()
})
it('alerts and dismisses for the rest of the day when the budget is exceeded', async () => {
localStorage.setItem('codeburn.dailyBudget', JSON.stringify({ kind: 'usd', value: 10 }))
render(<App />)
expect(await screen.findByText('Daily budget exceeded: $12.34 of $10.00')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Dismiss' }))
await waitFor(() => expect(screen.queryByText(/Daily budget exceeded/)).not.toBeInTheDocument())
expect(localStorage.getItem('codeburn.dailyBudget.dismissed')).toBe(dateKey(new Date()))
})
it('evaluates a token budget only on the all-providers view', async () => {
const payload = overviewPayload()
payload.history.daily[0]!.inputTokens = 60_000
payload.history.daily[0]!.outputTokens = 40_000
mocks.getOverview.mockResolvedValue(payload)
localStorage.setItem('codeburn.dailyBudget', JSON.stringify({ kind: 'tokens', value: 90_000 }))
render(<App />)
expect(await screen.findByText('Daily budget exceeded: 100K of 90K')).toBeInTheDocument()
// A specific-provider filter zeroes history.daily token fields, so the token
// cap can no longer be evaluated: the banner must disappear.
fireEvent.click(screen.getByText('All providers'))
fireEvent.click(await screen.findByRole('option', { name: 'Claude' }))
await waitFor(() => expect(mocks.getOverview).toHaveBeenCalledWith('30days', 'claude'))
await waitFor(() => expect(screen.queryByText(/Daily budget exceeded/)).not.toBeInTheDocument())
})
})

View file

@ -8,8 +8,10 @@ import { Sidebar, type Section } from './components/Sidebar'
import { rangeLabel, TopBar } from './components/TopBar'
import { Window } from './components/Window'
import { usePolled } from './hooks/usePolled'
import { formatUsd, setActiveCurrency } from './lib/format'
import { readDailyBudget } from './lib/budget'
import { formatCompact, formatUsd, setActiveCurrency } from './lib/format'
import { codeburn } from './lib/ipc'
import { localDateKey } from './lib/period'
import { OverviewContent } from './sections/Overview'
import { OptimizeContent } from './sections/Optimize'
import { Models } from './sections/Models'
@ -214,6 +216,7 @@ export function App() {
<Window>
<Sidebar active={section} onNavigate={navigate} status={<StatusLine polled={overview} />} />
<div className="ct">
<DailyBudgetBanner payload={overview.data ?? null} provider={provider} />
<ErrorBoundary key={section}>
{section === 'plans' ? (
<Plans period={period} refreshToken={refreshToken} onNavigate={navigate} />
@ -291,3 +294,64 @@ function SectionPlaceholder({ title }: { title: string }) {
</Panel>
)
}
/** App-wide daily-budget alert: reads today's usage from the overview payload and
* warns at >=80% / alerts at >=100% of the configured cap. Dismissible per day. */
function DailyBudgetBanner({ payload, provider }: { payload: MenubarPayload | null; provider: string }) {
const [, bumpDismiss] = useState(0)
const budget = readDailyBudget()
if (!budget || !payload) return null
// Token totals in history.daily are zeroed under a specific-provider filter
// (only cost is per-provider), so a token cap can only be evaluated honestly on
// the all-providers view; otherwise we'd compare usage against a false zero.
if (budget.kind === 'tokens' && provider !== 'all') return null
const todayKey = localDateKey(new Date())
let dismissed: string | null = null
try { dismissed = globalThis.localStorage?.getItem('codeburn.dailyBudget.dismissed') ?? null } catch { /* storage can be unavailable */ }
if (dismissed === todayKey) return null
// Today's entry may be absent when there has been no activity yet: that's 0 used.
const entry = payload.history.daily.find(day => day.date === todayKey)
const used = budget.kind === 'usd'
? entry?.cost ?? 0
: entry ? entry.inputTokens + entry.outputTokens : 0
const percent = (used / budget.value) * 100
if (percent < 80) return null
const exceeded = percent >= 100
const accent = exceeded ? 'var(--bad)' : 'var(--warn)'
const spent = budget.kind === 'usd' ? formatUsd(used) : formatCompact(used)
const cap = budget.kind === 'usd' ? formatUsd(budget.value) : formatCompact(budget.value)
const text = exceeded
? `Daily budget exceeded: ${spent} of ${cap}`
: `Today's spend is at ${Math.floor(percent)}% of your daily budget`
const dismiss = () => {
try { globalThis.localStorage?.setItem('codeburn.dailyBudget.dismissed', todayKey) } catch { /* storage can be unavailable */ }
bumpDismiss(tick => tick + 1)
}
return (
<div
role="status"
className="daily-budget-banner"
style={{
display: 'flex',
alignItems: 'center',
gap: 10,
padding: '6px 16px',
fontSize: 12,
fontWeight: 550,
color: accent,
borderLeft: `3px solid ${accent}`,
borderBottom: '1px solid var(--line)',
lineHeight: 1.4,
}}
>
<span style={{ flex: 1 }}>{text}</span>
<button type="button" className="set-text-button" style={{ color: 'var(--mut)' }} onClick={dismiss}>Dismiss</button>
</div>
)
}

View file

@ -0,0 +1,19 @@
// Renderer-only daily budget setting (localStorage `codeburn.dailyBudget`).
// A 'usd' cap is raw-USD (compared against history.daily cost, then displayed
// via the currency-aware formatUsd); a 'tokens' cap counts input+output tokens.
export type DailyBudget = { kind: 'usd' | 'tokens'; value: number }
/** Parse the persisted budget, returning null when absent or malformed. */
export function readDailyBudget(): DailyBudget | null {
let raw: string | null = null
try { raw = globalThis.localStorage?.getItem('codeburn.dailyBudget') ?? null } catch { return null }
if (!raw) return null
try {
const parsed = JSON.parse(raw) as Partial<DailyBudget>
if ((parsed.kind === 'usd' || parsed.kind === 'tokens') && typeof parsed.value === 'number' && Number.isFinite(parsed.value) && parsed.value > 0) {
return { kind: parsed.kind, value: parsed.value }
}
} catch { /* malformed JSON */ }
return null
}

View file

@ -114,6 +114,25 @@ describe('Settings', () => {
expect(screen.queryByText('Claude config')).not.toBeInTheDocument()
})
it('stores a positive daily budget from General', async () => {
const user = userEvent.setup()
render(<Settings period="month" />)
await user.click(screen.getByLabelText('Daily budget'))
await user.click(screen.getByRole('option', { name: 'USD amount' }))
await user.type(screen.getByLabelText('Daily budget amount'), '25')
expect(JSON.parse(localStorage.getItem('codeburn.dailyBudget')!)).toEqual({ kind: 'usd', value: 25 })
})
it('rejects a non-positive daily budget without persisting it', async () => {
const user = userEvent.setup()
render(<Settings period="month" />)
await user.click(screen.getByLabelText('Daily budget'))
await user.click(screen.getByRole('option', { name: 'Tokens' }))
await user.type(screen.getByLabelText('Daily budget amount'), '-5')
expect(screen.getByText('Enter a positive number.')).toBeInTheDocument()
expect(localStorage.getItem('codeburn.dailyBudget')).toBeFalsy()
})
it('lists providers from the real overview payload', async () => {
const user = userEvent.setup()
render(<Settings period="week" />)

View file

@ -7,6 +7,7 @@ import { Panel } from '../components/Panel'
import { ProviderLogo } from '../components/ProviderLogo'
import type { Section } from '../components/Sidebar'
import { usePolled } from '../hooks/usePolled'
import { readDailyBudget } from '../lib/budget'
import { formatConverted, formatUsd } from '../lib/format'
import { codeburn } from '../lib/ipc'
import type { ActionResult, AliasRow, ClaudeConfigSelector, CliError, CombinedUsage, DeviceScanResult, Identity, JsonPlanSummary, MenubarPayload, Period, PlanId, PlanProvider, ShareStatus, StatusJson } from '../lib/types'
@ -120,6 +121,9 @@ function GeneralPane({ period, refreshToken, claudeConfigs, claudeConfigSource }
return saved === 'light' || saved === 'dark' ? saved : 'system'
})
const [defaultPeriod, setDefaultPeriod] = useState(() => readSetting('codeburn.defaultPeriod') ?? 'today')
const [budgetKind, setBudgetKind] = useState<'off' | 'usd' | 'tokens'>(() => readDailyBudget()?.kind ?? 'off')
const [budgetInput, setBudgetInput] = useState(() => { const budget = readDailyBudget(); return budget ? String(budget.value) : '' })
const [budgetError, setBudgetError] = useState('')
const [message, setMessage] = useState<{ text: string; error: boolean } | null>(null)
useEffect(() => {
@ -127,6 +131,17 @@ function GeneralPane({ period, refreshToken, claudeConfigs, claudeConfigSource }
else document.documentElement.setAttribute('data-theme', theme)
}, [theme])
// Store on change; a positive finite amount persists, anything else clears the
// cap (so the banner turns off) and, when non-empty, flags a validation error.
const persistBudget = (kind: 'off' | 'usd' | 'tokens', input: string) => {
const trimmed = input.trim()
if (kind === 'off' || trimmed === '') { setBudgetError(''); writeSetting('codeburn.dailyBudget', ''); return }
const value = Number(trimmed)
if (!Number.isFinite(value) || value <= 0) { setBudgetError('Enter a positive number.'); return }
setBudgetError('')
writeSetting('codeburn.dailyBudget', JSON.stringify({ kind, value }))
}
const chooseTheme = (next: Theme) => {
setTheme(next)
writeSetting('codeburn.theme', next)
@ -165,6 +180,8 @@ function GeneralPane({ period, refreshToken, claudeConfigs, claudeConfigSource }
<button className="set-text-button" onClick={() => void codeburn.resetCurrency().then(finishCurrency)}>Reset to USD</button>
</span></div>
<div className="about-row"><label className="tx" htmlFor="settings-period">Default period<small>Applied on next launch.</small></label><span className="r"><Dropdown id="settings-period" ariaLabel="Default period" value={defaultPeriod} options={[{ value: 'today', label: 'Today' }, { value: 'week', label: '7d' }, { value: '30days', label: '30d' }, { value: 'month', label: 'Month' }, { value: 'all', label: 'All' }]} onChange={value => { setDefaultPeriod(value); writeSetting('codeburn.defaultPeriod', value) }} width={92} /></span></div>
<div className="about-row"><label className="tx" htmlFor="settings-budget">Daily budget<small>Warns at 80%, alerts at 100%.</small></label><span className="r"><Dropdown id="settings-budget" ariaLabel="Daily budget" value={budgetKind} options={[{ value: 'off', label: 'Off' }, { value: 'usd', label: 'USD amount' }, { value: 'tokens', label: 'Tokens' }]} onChange={value => { const kind = value as 'off' | 'usd' | 'tokens'; setBudgetKind(kind); persistBudget(kind, budgetInput) }} width={120} />{budgetKind !== 'off' && <input className="set-input" type="text" inputMode="decimal" aria-label="Daily budget amount" placeholder={budgetKind === 'usd' ? 'USD' : 'tokens'} value={budgetInput} onChange={event => { setBudgetInput(event.target.value); persistBudget(budgetKind, event.target.value) }} style={{ width: 90 }} />}</span></div>
{budgetError && <p className="set-action-msg error">{budgetError}</p>}
{message && <p className={message.error ? 'set-action-msg error' : 'set-action-msg'}>{message.text}</p>}
</div>
</div>