diff --git a/app/electron/main.test.ts b/app/electron/main.test.ts index 07b6daa1..fac0a6f6 100644 --- a/app/electron/main.test.ts +++ b/app/electron/main.test.ts @@ -69,12 +69,13 @@ const ARGV_CASES: Array<{ channel: string; args: unknown[]; argv: string[] }> = { channel: 'codeburn:getSessions', args: ['30days', 'claude', { from: '2026-07-01', to: '2026-07-11' }], argv: ['sessions', '--format', 'json', '--period', '30days', '--provider', 'claude', '--from', '2026-07-01', '--to', '2026-07-11'] }, { channel: 'codeburn:getCompareModels', args: ['month', 'codex'], argv: ['compare', '--format', 'json', '--period', 'month', '--provider', 'codex'] }, { channel: 'codeburn:getCompare', args: ['month', 'all', 'model-a', 'model-b'], argv: ['compare', '--format', 'json', '--period', 'month', '--model-a', 'model-a', '--model-b', 'model-b'] }, - { channel: 'codeburn:getYield', args: ['today'], argv: ['yield', '--format', 'json', '--period', 'today'] }, + { channel: 'codeburn:getYield', args: ['today', 'all'], argv: ['yield', '--format', 'json', '--period', 'today'] }, + { channel: 'codeburn:getYield', args: ['today', 'claude'], argv: ['yield', '--format', 'json', '--period', 'today', '--provider', 'claude'] }, { channel: 'codeburn:getSpendFlow', args: ['month', 'openai'], argv: ['spend', '--format', 'flow-json', '--period', 'month', '--provider', 'openai'] }, { channel: 'codeburn:getOptimizeReport', args: ['month', 'openai'], argv: ['optimize', '--format', 'json', '--period', 'month', '--provider', 'openai'] }, { channel: 'codeburn:getOverview', args: ['30days', 'all', { from: '2026-07-01', to: '2026-07-11' }], argv: ['status', '--format', 'menubar-json', '--period', '30days', '--from', '2026-07-01', '--to', '2026-07-11'] }, { channel: 'codeburn:getModels', args: ['week', 'claude', true, { from: '2026-07-01', to: '2026-07-11' }], argv: ['models', '--format', 'json', '--period', 'week', '--provider', 'claude', '--by-task', '--from', '2026-07-01', '--to', '2026-07-11'] }, - { channel: 'codeburn:getYield', args: ['today', { from: '2026-07-01', to: '2026-07-11' }], argv: ['yield', '--format', 'json', '--period', 'today', '--from', '2026-07-01', '--to', '2026-07-11'] }, + { channel: 'codeburn:getYield', args: ['today', 'all', { from: '2026-07-01', to: '2026-07-11' }], argv: ['yield', '--format', 'json', '--period', 'today', '--from', '2026-07-01', '--to', '2026-07-11'] }, { channel: 'codeburn:getSpendFlow', args: ['month', 'all', { from: '2026-07-01', to: '2026-07-11' }], argv: ['spend', '--format', 'flow-json', '--period', 'month', '--from', '2026-07-01', '--to', '2026-07-11'] }, { channel: 'codeburn:getOptimizeReport', args: ['month', 'all', { from: '2026-07-01', to: '2026-07-11' }], argv: ['optimize', '--format', 'json', '--period', 'month', '--from', '2026-07-01', '--to', '2026-07-11'] }, { channel: 'codeburn:getDevices', args: ['week'], argv: ['devices', '--format', 'json', '--period', 'week'] }, @@ -157,7 +158,7 @@ describe('createBridgeHandlers (IPC wiring)', () => { throw new CliError('nonzero', 'boom') }) const handlers = createBridgeHandlers(withQuota({ spawnCli, spawnCliAction: vi.fn(), resolveCodeburnPath: () => '/bin/codeburn' })) - const res = await handlers['codeburn:getYield']!('today') + const res = await handlers['codeburn:getYield']!('today', 'all') expect(res).toEqual({ ok: false, error: { kind: 'nonzero', message: 'boom' } }) }) diff --git a/app/electron/main.ts b/app/electron/main.ts index 6b9ebbb5..57a2ad67 100644 --- a/app/electron/main.ts +++ b/app/electron/main.ts @@ -75,8 +75,8 @@ export function createBridgeHandlers(deps: Deps = { spawnCli, spawnCliAction, re 'codeburn:getCompare': run((period: string, provider: string, modelA: string, modelB: string) => [ 'compare', '--format', 'json', '--period', period, ...providerArgs(provider), '--model-a', modelA, '--model-b', modelB, ]), - 'codeburn:getYield': run((period: string, range?: DateRange) => [ - 'yield', '--format', 'json', '--period', period, ...rangeArgs(range), + 'codeburn:getYield': run((period: string, provider: string, range?: DateRange) => [ + 'yield', '--format', 'json', '--period', period, ...providerArgs(provider), ...rangeArgs(range), ]), 'codeburn:getSpendFlow': run((period: string, provider: string, range?: DateRange) => [ 'spend', '--format', 'flow-json', '--period', period, ...providerArgs(provider), ...rangeArgs(range), diff --git a/app/electron/preload.ts b/app/electron/preload.ts index b8af3628..98e154aa 100644 --- a/app/electron/preload.ts +++ b/app/electron/preload.ts @@ -26,7 +26,7 @@ const bridge = { getSessions: (period: string, provider: string, range?: DateRange) => invoke('codeburn:getSessions', period, provider, range), getCompareModels: (period: string, provider: string) => invoke('codeburn:getCompareModels', period, provider), getCompare: (period: string, provider: string, modelA: string, modelB: string) => invoke('codeburn:getCompare', period, provider, modelA, modelB), - getYield: (period: string, range?: DateRange) => invoke('codeburn:getYield', period, range), + getYield: (period: string, provider: string, range?: DateRange) => invoke('codeburn:getYield', period, provider, range), getSpendFlow: (period: string, provider: string, range?: DateRange) => invoke('codeburn:getSpendFlow', period, provider, range), getOptimizeReport: (period: string, provider: string, range?: DateRange) => invoke('codeburn:getOptimizeReport', period, provider, range), getDevices: (period: string) => invoke('codeburn:getDevices', period), diff --git a/app/renderer/App.test.tsx b/app/renderer/App.test.tsx index bd1a5db4..904b196e 100644 --- a/app/renderer/App.test.tsx +++ b/app/renderer/App.test.tsx @@ -218,6 +218,26 @@ describe('App shortcuts', () => { }) }) + it('builds the provider picker from providerDetails so display-name providers round-trip their internal id', async () => { + // grok's display name is "Grok Build"; the picker must show the label but + // send the internal id `grok` as --provider (which assertProvider accepts). + const payload = overviewPayload() + payload.current.providers = { 'grok build': 5, claude: 10 } + payload.current.providerDetails = [ + { id: 'grok', label: 'Grok Build', cost: 5 }, + { id: 'claude', label: 'Claude', cost: 10 }, + ] + mocks.getOverview.mockResolvedValue(payload) + + render() + expect(await screen.findByText('Most expensive sessions')).toBeInTheDocument() + + fireEvent.click(screen.getByText('All providers')) + fireEvent.click(await screen.findByRole('option', { name: 'Grok Build' })) + + await waitFor(() => expect(mocks.getOverview).toHaveBeenCalledWith('30days', 'grok')) + }) + it('applies a calendar range to overview and visible section polls', async () => { render() diff --git a/app/renderer/App.tsx b/app/renderer/App.tsx index 18c2739f..b7d5801e 100644 --- a/app/renderer/App.tsx +++ b/app/renderer/App.tsx @@ -7,7 +7,7 @@ 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 } from './lib/format' +import { formatUsd, setActiveCurrency } from './lib/format' import { codeburn } from './lib/ipc' import { OverviewContent } from './sections/Overview' import { OptimizeContent } from './sections/Optimize' @@ -68,10 +68,11 @@ export function App() { const [settingsPane, setSettingsPane] = useState('general') const [period, setPeriod] = useState('30days') const [provider, setProvider] = useState('all') - const [detectedProviders, setDetectedProviders] = useState([]) + const [detectedProviders, setDetectedProviders] = useState>([]) const [customRange, setCustomRange] = useState(null) const [refreshToken, setRefreshToken] = useState(0) const [now, setNow] = useState(() => Date.now()) + const [, setCurrencyTick] = useState(0) const overview = usePolled( () => customRange @@ -90,14 +91,26 @@ export function App() { useEffect(() => { if (!overview.data) return - const found = Object.entries(overview.data.current.providers).filter(([, value]) => value > 0).map(([key]) => key) + const details = overview.data.current.providerDetails + // Prefer providerDetails (internal id + display label); fall back to the + // providers map keys (lowercased display names) for older CLIs. + const found = details + ? details.filter(entry => entry.cost > 0).map(entry => ({ id: entry.id, label: entry.label })) + : Object.entries(overview.data.current.providers).filter(([, value]) => value > 0).map(([key]) => ({ id: key, label: providerName(key) })) setDetectedProviders(current => { const next = [...current] - for (const item of found) if (!next.includes(item)) next.push(item) + for (const item of found) if (!next.some(entry => entry.id === item.id)) next.push(item) return next.length === current.length ? current : next }) }, [overview.data]) + useEffect(() => { + const currency = overview.data?.currency + if (!currency) return + setActiveCurrency(currency) + setCurrencyTick(tick => tick + 1) + }, [overview.data?.currency?.code, overview.data?.currency?.rate, overview.data?.currency?.symbol]) + useEffect(() => { const id = window.setInterval(() => setNow(Date.now()), 1000) return () => window.clearInterval(id) @@ -143,9 +156,9 @@ export function App() { const providerOptions = [ { value: 'all', label: 'All providers' }, - ...detectedProviders.map(value => ({ value, label: providerName(value) })), + ...detectedProviders.map(entry => ({ value: entry.id, label: entry.label })), ] - const providerLabel = providerName(provider) + const providerLabel = detectedProviders.find(entry => entry.id === provider)?.label ?? providerName(provider) const scope = `${customRange ? rangeLabel(customRange) : PERIOD_LABELS[period]} · ${providerLabel}` return ( diff --git a/app/renderer/lib/format.test.ts b/app/renderer/lib/format.test.ts index ab6490af..f2fff1f1 100644 --- a/app/renderer/lib/format.test.ts +++ b/app/renderer/lib/format.test.ts @@ -1,6 +1,29 @@ -import { describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it } from 'vitest' -import { formatCompact, formatDayLong, formatDayShort, formatDuration } from './format' +import { formatCompact, formatConverted, formatDayLong, formatDayShort, formatDuration, formatUsd, setActiveCurrency } from './format' + +describe('currency-aware formatting', () => { + afterEach(() => setActiveCurrency({ code: 'USD', symbol: '$', rate: 1 })) + + it('formats raw USD with the default USD currency', () => { + expect(formatUsd(12.34)).toBe('$12.34') + expect(formatUsd(1_234.5)).toBe('$1,234.50') + }) + + it('applies the active FX rate and symbol to raw-USD values exactly once', () => { + setActiveCurrency({ code: 'EUR', symbol: '€', rate: 0.9 }) + // 100 USD × 0.9 = 90.00 EUR + expect(formatUsd(100)).toBe('€90.00') + expect(formatUsd(1_000)).toBe('€900.00') + }) + + it('formatConverted swaps the symbol without re-applying the rate (CLI-converted values)', () => { + setActiveCurrency({ code: 'EUR', symbol: '€', rate: 0.9 }) + // Already-EUR input renders as-is, never multiplied by the rate again. + expect(formatConverted(90)).toBe('€90.00') + expect(formatConverted(20)).toBe('€20.00') + }) +}) describe('formatCompact', () => { it('formats zero, plain counts, thousands, and millions compactly', () => { diff --git a/app/renderer/lib/format.ts b/app/renderer/lib/format.ts index 528016c2..1fa78a60 100644 --- a/app/renderer/lib/format.ts +++ b/app/renderer/lib/format.ts @@ -1,5 +1,25 @@ +type ActiveCurrency = { code: string; symbol: string; rate: number } + +// Single source of truth for display currency. App.tsx sets it from the overview +// payload; every formatUsd/formatConverted call site then converts for free. +// Defaults to USD so the first render (before the payload arrives) is correct. +let activeCurrency: ActiveCurrency = { code: 'USD', symbol: '$', rate: 1 } + +export function setActiveCurrency(currency: ActiveCurrency): void { + activeCurrency = currency +} + +/** Raw-USD input: multiplies by the active FX rate, then prefixes the symbol. */ export function formatUsd(n: number): string { - return `$${n.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}` + return formatConverted(n * activeCurrency.rate) +} + +/** + * Already-converted input (CLI-side convertCost values, e.g. plan budgets): only + * prefixes the active symbol and formats the magnitude — never re-applies the rate. + */ +export function formatConverted(n: number): string { + return `${activeCurrency.symbol}${n.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}` } /** Shorten filesystem and CLI-mangled project paths to their useful trailing segments. */ diff --git a/app/renderer/lib/types.ts b/app/renderer/lib/types.ts index 7c64c1ee..934e23fc 100644 --- a/app/renderer/lib/types.ts +++ b/app/renderer/lib/types.ts @@ -145,6 +145,9 @@ export type MenubarPayload = { unpricedModels?: Array<{ model: string; calls: number; tokens: number }> localModelSavings: LocalModelSavings providers: Record + // Optional: older CLIs omit it. `id` is the internal provider name (round-trips + // as --provider), `label` the display name. Fall back to `providers` when absent. + providerDetails?: Array<{ id: string; label: string; cost: number }> topProjects: Array<{ name: string cost: number @@ -214,6 +217,9 @@ export type MenubarPayload = { history: { daily: DailyHistoryEntry[] } + // Active display currency. Payload costs are raw USD; the renderer multiplies by + // `rate` and prefixes `symbol` at display time. Optional: older CLIs omit it. + currency?: { code: string; symbol: string; rate: number } combined?: CombinedUsage claudeConfigs?: ClaudeConfigSelector } @@ -492,7 +498,7 @@ export interface CodeburnBridge { getSessions(period: Period, provider: string, range?: DateRange): Promise getCompareModels(period: Period, provider: string): Promise getCompare(period: Period, provider: string, modelA: string, modelB: string): Promise - getYield(period: Period, range?: DateRange): Promise + getYield(period: Period, provider: string, range?: DateRange): Promise getSpendFlow(period: Period, provider: string, range?: DateRange): Promise getOptimizeReport(period: Period, provider: string, range?: DateRange): Promise getDevices(period: Period): Promise diff --git a/app/renderer/sections/Optimize.test.tsx b/app/renderer/sections/Optimize.test.tsx index 592a4b20..f646a036 100644 --- a/app/renderer/sections/Optimize.test.tsx +++ b/app/renderer/sections/Optimize.test.tsx @@ -227,10 +227,11 @@ describe('Optimize', () => { expect(screen.queryByRole('button', { name: 'Fixes 25' })).not.toBeInTheDocument() }) - it('passes provider and custom range to the optimize report bridge', async () => { + it('passes provider and custom range to the optimize report and yield bridges', async () => { render() await screen.findByText('Opus is doing your small talk') expect(getOptimizeReport).toHaveBeenCalledWith('30days', 'claude', { from: '2026-07-01', to: '2026-07-11' }) + expect(getYield).toHaveBeenCalledWith('30days', 'claude', { from: '2026-07-01', to: '2026-07-11' }) }) it('keeps last-good yield totals and rows visible during revalidation', async () => { diff --git a/app/renderer/sections/Optimize.tsx b/app/renderer/sections/Optimize.tsx index e87171f8..517f406b 100644 --- a/app/renderer/sections/Optimize.tsx +++ b/app/renderer/sections/Optimize.tsx @@ -41,8 +41,8 @@ export function OptimizeContent({ [period, provider, range?.from, range?.to, refreshToken], ) const yieldReport = usePolled( - () => range ? codeburn.getYield(period, range) : codeburn.getYield(period), - [period, range?.from, range?.to, refreshToken], + () => range ? codeburn.getYield(period, provider, range) : codeburn.getYield(period, provider), + [period, provider, range?.from, range?.to, refreshToken], ) const [tab, setTab] = useState('waste') diff --git a/app/renderer/sections/Overview.test.tsx b/app/renderer/sections/Overview.test.tsx index 9665182f..c777e15e 100644 --- a/app/renderer/sections/Overview.test.tsx +++ b/app/renderer/sections/Overview.test.tsx @@ -3,6 +3,7 @@ import { fireEvent, render, screen, waitFor, within } from '@testing-library/rea import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { Polled } from '../hooks/usePolled' +import { setActiveCurrency } from '../lib/format' import type { ActReportJson, MenubarPayload, YieldJsonReport } from '../lib/types' import { Overview, OverviewContent, localDateKey } from './Overview' @@ -16,7 +17,7 @@ function polled(data: MenubarPayload): Polled { const { getOverview, getActReport, getYield } = vi.hoisted(() => ({ getOverview: vi.fn<(period: string, provider: string) => Promise>(), getActReport: vi.fn<() => Promise>(), - getYield: vi.fn<(period: string) => Promise>(), + getYield: vi.fn<(period: string, provider: string) => Promise>(), })) vi.mock('../lib/ipc', async orig => { const actual = await orig() @@ -148,6 +149,7 @@ function makePayload(now: Date): MenubarPayload { describe('Overview', () => { beforeEach(() => { + setActiveCurrency({ code: 'USD', symbol: '$', rate: 1 }) getOverview.mockReset() getActReport.mockReset() getYield.mockReset() @@ -481,4 +483,18 @@ describe('Overview', () => { expect(await screen.findByRole('status')).toHaveTextContent('Refresh failed, showing last good data · codeburn exited 1') }) + + it('renders section costs in the active non-USD currency (rate applied once, symbol swapped)', async () => { + setActiveCurrency({ code: 'EUR', symbol: '€', rate: 0.9 }) + const now = new Date() + getOverview.mockResolvedValue(makePayload(now)) + + render() + + // Cost per outcome sources raw-USD yield values: $/commit = 150/6 = 25 → €22.50, + // $/productive session = 120/3 = 40 → €36.00 (rate applied exactly once). + const outcome = (await screen.findByText('Cost per outcome')).closest('.ov-panel') as HTMLElement + expect(within(outcome).getByText('€22.50')).toBeInTheDocument() + expect(within(outcome).getByText('€36.00')).toBeInTheDocument() + }) }) diff --git a/app/renderer/sections/Overview.tsx b/app/renderer/sections/Overview.tsx index b5a08b79..679e008a 100644 --- a/app/renderer/sections/Overview.tsx +++ b/app/renderer/sections/Overview.tsx @@ -468,7 +468,7 @@ export function OverviewContent({ onNavigate?: (section: 'optimize') => void }) { const actReport = usePolled(() => codeburn.getActReport(), []) - const yieldReport = usePolled(() => codeburn.getYield(period), [period]) + const yieldReport = usePolled(() => codeburn.getYield(period, provider), [period, provider]) const { data, error } = overview const modelIndex = useMemo(() => data ? buildModelIndex(data) : new Map(), [data]) diff --git a/app/renderer/sections/Plans.test.tsx b/app/renderer/sections/Plans.test.tsx index 3a5933f1..b1588d09 100644 --- a/app/renderer/sections/Plans.test.tsx +++ b/app/renderer/sections/Plans.test.tsx @@ -2,6 +2,7 @@ import { render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { setActiveCurrency } from '../lib/format' import type { JsonPlanSummary, QuotaProvider, StatusJson } from '../lib/types' import { Plans } from './Plans' @@ -98,6 +99,7 @@ function quotaProviders(): QuotaProvider[] { describe('Plans', () => { beforeEach(() => { + setActiveCurrency({ code: 'USD', symbol: '$', rate: 1 }) getPlans.mockReset() getQuota.mockReset() getQuota.mockResolvedValue(quotaProviders()) @@ -203,6 +205,19 @@ describe('Plans', () => { expect(await screen.findByText('Locate the codeburn CLI')).toBeInTheDocument() }) + it('does not re-apply the FX rate to CLI-converted plan values (symbol swap only)', async () => { + // getPlans values arrive already converted by the CLI (convertCost). With a + // EUR rate active, the pane must only swap the symbol — a second ×0.9 here + // would render €18.00 / €7.38 instead of the correct €20.00 / €8.20. + setActiveCurrency({ code: 'EUR', symbol: '€', rate: 0.9 }) + getPlans.mockResolvedValue({ ...baseStatus, currency: 'EUR', plans: { cursor: cursorPlan } }) + + render() + + expect(await screen.findByText('€20.00 / month · cursor')).toBeInTheDocument() + expect(screen.getByText('€8.20 · 41%')).toBeInTheDocument() + }) + it('renders permission-denied CLI failures as the amber Full Disk Access state', async () => { getPlans.mockRejectedValue({ kind: 'nonzero', message: 'Cursor permission denied: grant Full Disk Access' }) diff --git a/app/renderer/sections/Plans.tsx b/app/renderer/sections/Plans.tsx index 1e74edec..87ac6692 100644 --- a/app/renderer/sections/Plans.tsx +++ b/app/renderer/sections/Plans.tsx @@ -3,7 +3,7 @@ import { Panel } from '../components/Panel' import type { Section } from '../components/Sidebar' import { StaleBanner } from '../components/StaleBanner' import { usePolled } from '../hooks/usePolled' -import { formatUsd } from '../lib/format' +import { formatConverted } from '../lib/format' import { codeburn } from '../lib/ipc' import type { JsonPlanSummary, Period, PlanId, PlanProvider, QuotaProvider, QuotaWindow, StatusJson } from '../lib/types' import type { SettingsPane } from './Settings' @@ -206,9 +206,9 @@ function PlanPanel({ plan }: { plan: JsonPlanSummary }) { const trackClass = hasBudget ? (over ? 'over' : undefined) : 'mut' const overage = Math.max(0, plan.spent - plan.budget) const right = hasBudget - ? `${formatUsd(plan.spent)} · ${fmtPct(plan.percentUsed)}${overage > 0 ? ` · ${formatUsd(overage)} over` : ''}` - : `${formatUsd(plan.spent)} this cycle` - const detail = hasBudget ? `${formatUsd(plan.budget)} / month · ${plan.provider}` : `${plan.provider} · pay as you go, no plan` + ? `${formatConverted(plan.spent)} · ${fmtPct(plan.percentUsed)}${overage > 0 ? ` · ${formatConverted(overage)} over` : ''}` + : `${formatConverted(plan.spent)} this cycle` + const detail = hasBudget ? `${formatConverted(plan.budget)} / month · ${plan.provider}` : `${plan.provider} · pay as you go, no plan` return ( @@ -231,14 +231,14 @@ function PaceLine({ plan }: { plan: JsonPlanSummary }) { if (plan.status === 'over' || plan.projectedMonthEnd > plan.budget) { return (
- On pace to exceed; projected {formatUsd(plan.projectedMonthEnd)} by {endLabel} + On pace to exceed; projected {formatConverted(plan.projectedMonthEnd)} by {endLabel}
) } if (plan.status === 'near') { return (
- {fmtPct(plan.percentUsed)} of budget used; projected {formatUsd(plan.projectedMonthEnd)} by {endLabel} + {fmtPct(plan.percentUsed)} of budget used; projected {formatConverted(plan.projectedMonthEnd)} by {endLabel}
) } diff --git a/app/renderer/sections/Settings.test.tsx b/app/renderer/sections/Settings.test.tsx index 512d9dfe..6c767a82 100644 --- a/app/renderer/sections/Settings.test.tsx +++ b/app/renderer/sections/Settings.test.tsx @@ -131,7 +131,7 @@ describe('Settings', () => { render() await user.click(screen.getByRole('button', { name: 'Plans' })) expect((await screen.findAllByText('Claude Max 20x')).length).toBeGreaterThan(0) - expect(screen.getByText('$200/month · claude · 24% used')).toBeInTheDocument() + expect(screen.getByText('$200.00/month · claude · 24% used')).toBeInTheDocument() await user.click(screen.getByRole('button', { name: 'Remove' })) expect(mocks.resetPlan).toHaveBeenCalledWith('claude') await user.click(screen.getByLabelText('Add a plan')) diff --git a/app/renderer/sections/Settings.tsx b/app/renderer/sections/Settings.tsx index bc4c6f3a..1cc392d0 100644 --- a/app/renderer/sections/Settings.tsx +++ b/app/renderer/sections/Settings.tsx @@ -7,7 +7,7 @@ import { Panel } from '../components/Panel' import { ProviderLogo } from '../components/ProviderLogo' import type { Section } from '../components/Sidebar' import { usePolled } from '../hooks/usePolled' -import { formatUsd } from '../lib/format' +import { formatConverted, formatUsd } from '../lib/format' import { codeburn } from '../lib/ipc' import type { ActionResult, AliasRow, CliError, CombinedUsage, DeviceScanResult, Identity, JsonPlanSummary, MenubarPayload, Period, PlanId, PlanProvider, ShareStatus, StatusJson } from '../lib/types' @@ -203,7 +203,7 @@ function PlansPane({ period, refreshToken, onNavigate }: { period: Period; refre

Plans

Set a monthly budget plan per provider. codeburn compares it to your API-equivalent spend.

- {plans.error ? : !plans.data ?

Loading plans…

: configured.length === 0 ?

No plans configured.

: configured.map(plan =>
{PLAN_PRESETS.find(item => item.id === plan.id)?.label ?? plan.id}${plan.budget}/month · {plan.provider} · {plan.percentUsed}% used
)} + {plans.error ? : !plans.data ?

Loading plans…

: configured.length === 0 ?

No plans configured.

: configured.map(plan =>
{PLAN_PRESETS.find(item => item.id === plan.id)?.label ?? plan.id}{formatConverted(plan.budget)}/month · {plan.provider} · {plan.percentUsed}% used
)}
({ value: preset.id, label: preset.label }))} onChange={value => setPresetId(value as PlanPreset['id'])} width={160} />