mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-08-22 23:14:33 +00:00
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:
parent
a916981107
commit
8a3fa69fe9
16 changed files with 145 additions and 30 deletions
|
|
@ -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' } })
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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 />)
|
||||
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
|
||||
|
|
|
|||
|
|
@ -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' })
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'))
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue