mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-08-02 21:04:57 +00:00
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:
parent
55fdbfd761
commit
93349e7eba
5 changed files with 167 additions and 1 deletions
|
|
@ -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())
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
19
app/renderer/lib/budget.ts
Normal file
19
app/renderer/lib/budget.ts
Normal 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
|
||||
}
|
||||
|
|
@ -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" />)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue