diff --git a/app/renderer/App.test.tsx b/app/renderer/App.test.tsx index 19271ca..04922d7 100644 --- a/app/renderer/App.test.tsx +++ b/app/renderer/App.test.tsx @@ -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() + 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() + 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() + // 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() + 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() + 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()) + }) }) diff --git a/app/renderer/App.tsx b/app/renderer/App.tsx index 60e3b28..d9267a1 100644 --- a/app/renderer/App.tsx +++ b/app/renderer/App.tsx @@ -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() { } />
+ {section === 'plans' ? ( @@ -291,3 +294,64 @@ function SectionPlaceholder({ title }: { title: string }) { ) } + +/** 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 ( +
+ {text} + +
+ ) +} diff --git a/app/renderer/lib/budget.ts b/app/renderer/lib/budget.ts new file mode 100644 index 0000000..1719249 --- /dev/null +++ b/app/renderer/lib/budget.ts @@ -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 + 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 +} diff --git a/app/renderer/sections/Settings.test.tsx b/app/renderer/sections/Settings.test.tsx index fb21f2f..604dd7f 100644 --- a/app/renderer/sections/Settings.test.tsx +++ b/app/renderer/sections/Settings.test.tsx @@ -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() + 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() + 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() diff --git a/app/renderer/sections/Settings.tsx b/app/renderer/sections/Settings.tsx index 21db722..0110180 100644 --- a/app/renderer/sections/Settings.tsx +++ b/app/renderer/sections/Settings.tsx @@ -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 }
{ setDefaultPeriod(value); writeSetting('codeburn.defaultPeriod', value) }} width={92} />
+
{ const kind = value as 'off' | 'usd' | 'tokens'; setBudgetKind(kind); persistBudget(kind, budgetInput) }} width={120} />{budgetKind !== 'off' && { setBudgetInput(event.target.value); persistBudget(budgetKind, event.target.value) }} style={{ width: 90 }} />}
+ {budgetError &&

{budgetError}

} {message &&

{message.text}

}