fix(app): real currency conversion, provider ids in picker, yield provider scoping

- formatUsd applies the payload currency {code,symbol,rate} once at
  display; formatConverted (symbol only) for CLI-preconverted plan
  values so nothing converts twice
- provider picker built from providerDetails: label shown, internal id
  sent as --provider (fixes filters for providers whose display name
  differs, e.g. Grok Build); falls back to map keys on older CLIs
- getYield threads the active provider through preload/main/sections

App suite 170/170, root 1615/1615.
This commit is contained in:
iamtoruk 2026-07-16 01:36:59 -07:00
parent a916981107
commit 8a3fa69fe9
16 changed files with 145 additions and 30 deletions

View file

@ -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' } })
})

View file

@ -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),

View file

@ -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),

View file

@ -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(<App />)
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(<App />)

View file

@ -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<SettingsPane>('general')
const [period, setPeriod] = useState<Period>('30days')
const [provider, setProvider] = useState<string>('all')
const [detectedProviders, setDetectedProviders] = useState<string[]>([])
const [detectedProviders, setDetectedProviders] = useState<Array<{ id: string; label: string }>>([])
const [customRange, setCustomRange] = useState<DateRange | null>(null)
const [refreshToken, setRefreshToken] = useState(0)
const [now, setNow] = useState(() => Date.now())
const [, setCurrencyTick] = useState(0)
const overview = usePolled<MenubarPayload>(
() => 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 (

View file

@ -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', () => {

View file

@ -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. */

View file

@ -145,6 +145,9 @@ export type MenubarPayload = {
unpricedModels?: Array<{ model: string; calls: number; tokens: number }>
localModelSavings: LocalModelSavings
providers: Record<string, number>
// 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<SessionRow[]>
getCompareModels(period: Period, provider: string): Promise<ModelStats[]>
getCompare(period: Period, provider: string, modelA: string, modelB: string): Promise<CompareJsonReport>
getYield(period: Period, range?: DateRange): Promise<YieldJsonReport>
getYield(period: Period, provider: string, range?: DateRange): Promise<YieldJsonReport>
getSpendFlow(period: Period, provider: string, range?: DateRange): Promise<SpendFlow>
getOptimizeReport(period: Period, provider: string, range?: DateRange): Promise<OptimizeJsonReport>
getDevices(period: Period): Promise<CombinedUsage>

View file

@ -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(<Optimize period="30days" provider="claude" range={{ from: '2026-07-01', to: '2026-07-11' }} />)
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 () => {

View file

@ -41,8 +41,8 @@ export function OptimizeContent({
[period, provider, range?.from, range?.to, refreshToken],
)
const yieldReport = usePolled<YieldJsonReport>(
() => 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<OptimizeTab>('waste')

View file

@ -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<MenubarPayload> {
const { getOverview, getActReport, getYield } = vi.hoisted(() => ({
getOverview: vi.fn<(period: string, provider: string) => Promise<MenubarPayload>>(),
getActReport: vi.fn<() => Promise<ActReportJson>>(),
getYield: vi.fn<(period: string) => Promise<YieldJsonReport>>(),
getYield: vi.fn<(period: string, provider: string) => Promise<YieldJsonReport>>(),
}))
vi.mock('../lib/ipc', async orig => {
const actual = await orig<typeof import('../lib/ipc')>()
@ -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(<Overview period="30days" provider="all" />)
// 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()
})
})

View file

@ -468,7 +468,7 @@ export function OverviewContent({
onNavigate?: (section: 'optimize') => void
}) {
const actReport = usePolled<ActReportJson>(() => codeburn.getActReport(), [])
const yieldReport = usePolled<YieldJsonReport>(() => codeburn.getYield(period), [period])
const yieldReport = usePolled<YieldJsonReport>(() => codeburn.getYield(period, provider), [period, provider])
const { data, error } = overview
const modelIndex = useMemo(() => data ? buildModelIndex(data) : new Map<string, string>(), [data])

View file

@ -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(<Plans period="30days" />)
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' })

View file

@ -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 (
<Panel>
@ -231,14 +231,14 @@ function PaceLine({ plan }: { plan: JsonPlanSummary }) {
if (plan.status === 'over' || plan.projectedMonthEnd > plan.budget) {
return (
<div className="pace hot">
On pace to exceed; projected {formatUsd(plan.projectedMonthEnd)} by {endLabel}
On pace to exceed; projected {formatConverted(plan.projectedMonthEnd)} by {endLabel}
</div>
)
}
if (plan.status === 'near') {
return (
<div className="pace hot">
{fmtPct(plan.percentUsed)} of budget used; projected {formatUsd(plan.projectedMonthEnd)} by {endLabel}
{fmtPct(plan.percentUsed)} of budget used; projected {formatConverted(plan.projectedMonthEnd)} by {endLabel}
</div>
)
}

View file

@ -131,7 +131,7 @@ describe('Settings', () => {
render(<Settings period="month" />)
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'))

View file

@ -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
<div><h3 className="set-h">Plans</h3><p className="set-sub">Set a monthly budget plan per provider. codeburn compares it to your API-equivalent spend.</p></div>
<div className="card">
<div className="about-sec">
{plans.error ? <SettingsErrorText error={plans.error} /> : !plans.data ? <p className="set-cap">Loading plans</p> : configured.length === 0 ? <p className="set-cap">No plans configured.</p> : configured.map(plan => <div className="about-row" key={plan.provider}><span className="tx">{PLAN_PRESETS.find(item => item.id === plan.id)?.label ?? plan.id}<small>${plan.budget}/month · {plan.provider} · {plan.percentUsed}% used</small></span><span className="r"><button className="btnp" onClick={() => remove(plan)}>Remove</button></span></div>)}
{plans.error ? <SettingsErrorText error={plans.error} /> : !plans.data ? <p className="set-cap">Loading plans</p> : configured.length === 0 ? <p className="set-cap">No plans configured.</p> : configured.map(plan => <div className="about-row" key={plan.provider}><span className="tx">{PLAN_PRESETS.find(item => item.id === plan.id)?.label ?? plan.id}<small>{formatConverted(plan.budget)}/month · {plan.provider} · {plan.percentUsed}% used</small></span><span className="r"><button className="btnp" onClick={() => remove(plan)}>Remove</button></span></div>)}
</div>
<div className="about-sec set-last-sec">
<div className="about-row"><label className="tx" htmlFor="settings-plan-preset">Add a plan</label><span className="r"><Dropdown id="settings-plan-preset" ariaLabel="Add a plan" value={presetId} options={PLAN_PRESETS.map(preset => ({ value: preset.id, label: preset.label }))} onChange={value => setPresetId(value as PlanPreset['id'])} width={160} /><button className="btnp btnp-primary" onClick={add}>Add</button></span></div>