mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-05-12 12:41:19 +00:00
feat: add interactive currency picker to dashboard
Press c in the dashboard to open a searchable currency picker modal. Type to filter, arrow keys to navigate, enter to select, escape to cancel. Shows 24 common currencies with the active one marked, and accepts any valid ISO 4217 code typed directly. Session-only switch -- does not write to config, so users can quickly compare costs in different currencies without changing their default.
This commit is contained in:
parent
d322d9b837
commit
f08f97cd3d
3 changed files with 160 additions and 1 deletions
|
|
@ -32,6 +32,8 @@ const FRANKFURTER_URL = 'https://api.frankfurter.app/latest?from=USD&to='
|
|||
// Overwritten by loadCurrency() if the user has configured a currency.
|
||||
let active: CurrencyState = { code: 'USD', rate: 1, symbol: '$' }
|
||||
|
||||
const USD: CurrencyState = { code: 'USD', rate: 1, symbol: '$' }
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Intl-based currency helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -164,6 +166,21 @@ export function getCurrency(): CurrencyState {
|
|||
return active
|
||||
}
|
||||
|
||||
/**
|
||||
* Switches the active currency at runtime. Used by the dashboard currency picker.
|
||||
* Fetches the exchange rate (from cache or API) and updates the display currency.
|
||||
* Does not write to the config file -- session-only unless the caller saves separately.
|
||||
*/
|
||||
export async function switchCurrency(code: string): Promise<void> {
|
||||
if (code === 'USD') {
|
||||
active = USD
|
||||
return
|
||||
}
|
||||
const rate = await getExchangeRate(code)
|
||||
const symbol = resolveSymbol(code)
|
||||
active = { code, rate, symbol }
|
||||
}
|
||||
|
||||
/** Returns a dynamic column header like "Cost (AUD)" for use in CSV/JSON exports. */
|
||||
export function getCostColumnHeader(): string {
|
||||
return `Cost (${active.code})`
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { CATEGORY_LABELS, type ProjectSummary, type TaskCategory } from './types
|
|||
import { formatCost, formatTokens } from './format.js'
|
||||
import { parseAllSessions } from './parser.js'
|
||||
import { loadPricing } from './models.js'
|
||||
import { switchCurrency, getCurrency, isValidCurrencyCode } from './currency.js'
|
||||
import { providers } from './providers/index.js'
|
||||
|
||||
type Period = 'today' | 'week' | 'month' | '30days'
|
||||
|
|
@ -411,7 +412,9 @@ function StatusBar({ width, showProvider }: { width: number; showProvider?: bool
|
|||
<Text color={ORANGE} bold>3</Text>
|
||||
<Text dimColor> 30 days </Text>
|
||||
<Text color={ORANGE} bold>4</Text>
|
||||
<Text dimColor> month</Text>
|
||||
<Text dimColor> month </Text>
|
||||
<Text color={ORANGE} bold>c</Text>
|
||||
<Text dimColor> currency ({getCurrency().code})</Text>
|
||||
{showProvider && (
|
||||
<>
|
||||
<Text dimColor> </Text>
|
||||
|
|
@ -424,6 +427,115 @@ function StatusBar({ width, showProvider }: { width: number; showProvider?: bool
|
|||
)
|
||||
}
|
||||
|
||||
// Common currencies shown in the picker. Full list is searchable by typing a code.
|
||||
const COMMON_CURRENCIES = [
|
||||
{ code: 'USD', name: 'US Dollar' },
|
||||
{ code: 'GBP', name: 'British Pound' },
|
||||
{ code: 'EUR', name: 'Euro' },
|
||||
{ code: 'AUD', name: 'Australian Dollar' },
|
||||
{ code: 'CAD', name: 'Canadian Dollar' },
|
||||
{ code: 'NZD', name: 'New Zealand Dollar' },
|
||||
{ code: 'JPY', name: 'Japanese Yen' },
|
||||
{ code: 'CHF', name: 'Swiss Franc' },
|
||||
{ code: 'INR', name: 'Indian Rupee' },
|
||||
{ code: 'BRL', name: 'Brazilian Real' },
|
||||
{ code: 'SEK', name: 'Swedish Krona' },
|
||||
{ code: 'SGD', name: 'Singapore Dollar' },
|
||||
{ code: 'HKD', name: 'Hong Kong Dollar' },
|
||||
{ code: 'KRW', name: 'South Korean Won' },
|
||||
{ code: 'MXN', name: 'Mexican Peso' },
|
||||
{ code: 'ZAR', name: 'South African Rand' },
|
||||
{ code: 'DKK', name: 'Danish Krone' },
|
||||
{ code: 'NOK', name: 'Norwegian Krone' },
|
||||
{ code: 'PLN', name: 'Polish Zloty' },
|
||||
{ code: 'THB', name: 'Thai Baht' },
|
||||
{ code: 'TWD', name: 'Taiwan Dollar' },
|
||||
{ code: 'TRY', name: 'Turkish Lira' },
|
||||
{ code: 'PHP', name: 'Philippine Peso' },
|
||||
{ code: 'CZK', name: 'Czech Koruna' },
|
||||
]
|
||||
|
||||
function CurrencyPicker({ width, onSelect, onCancel }: {
|
||||
width: number
|
||||
onSelect: (code: string) => void
|
||||
onCancel: () => void
|
||||
}) {
|
||||
const [search, setSearch] = useState('')
|
||||
const [cursor, setCursor] = useState(0)
|
||||
const activeCode = getCurrency().code
|
||||
|
||||
const filtered = search.length > 0
|
||||
? COMMON_CURRENCIES.filter(c =>
|
||||
c.code.toLowerCase().includes(search.toLowerCase()) ||
|
||||
c.name.toLowerCase().includes(search.toLowerCase()))
|
||||
: COMMON_CURRENCIES
|
||||
|
||||
// Keep cursor in bounds when search narrows the list
|
||||
const safeCursor = Math.min(cursor, Math.max(filtered.length - 1, 0))
|
||||
const maxVisible = 12
|
||||
|
||||
useInput((input, key) => {
|
||||
if (key.escape) { onCancel(); return }
|
||||
if (key.return) {
|
||||
if (filtered.length > 0) {
|
||||
onSelect(filtered[safeCursor].code)
|
||||
} else if (search.length === 3 && isValidCurrencyCode(search.toUpperCase())) {
|
||||
onSelect(search.toUpperCase())
|
||||
}
|
||||
return
|
||||
}
|
||||
if (key.upArrow) { setCursor(Math.max(0, safeCursor - 1)); return }
|
||||
if (key.downArrow) { setCursor(Math.min(filtered.length - 1, safeCursor + 1)); return }
|
||||
if (key.backspace || key.delete) {
|
||||
setSearch(s => s.slice(0, -1))
|
||||
setCursor(0)
|
||||
return
|
||||
}
|
||||
if (input && input.length === 1 && /[a-zA-Z]/.test(input)) {
|
||||
setSearch(s => s + input)
|
||||
setCursor(0)
|
||||
}
|
||||
})
|
||||
|
||||
// Scroll window around the cursor
|
||||
const scrollStart = Math.max(0, Math.min(safeCursor - Math.floor(maxVisible / 2), filtered.length - maxVisible))
|
||||
const visible = filtered.slice(scrollStart, scrollStart + maxVisible)
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" borderStyle="round" borderColor={ORANGE} width={Math.min(width, 44)} paddingX={1}>
|
||||
<Text bold color={ORANGE}>Currency</Text>
|
||||
<Text dimColor>Type to search, arrows to move, enter to select, esc to cancel</Text>
|
||||
<Box marginTop={1}>
|
||||
<Text color={ORANGE}>{'> '}</Text>
|
||||
<Text>{search.length > 0 ? search.toUpperCase() : ''}</Text>
|
||||
<Text dimColor>{search.length === 0 ? 'search...' : ''}</Text>
|
||||
</Box>
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
{visible.map((c, i) => {
|
||||
const idx = scrollStart + i
|
||||
const isActive = c.code === activeCode
|
||||
const isSelected = idx === safeCursor
|
||||
return (
|
||||
<Text key={c.code}>
|
||||
<Text color={isSelected ? ORANGE : undefined} bold={isSelected}>
|
||||
{isSelected ? '> ' : ' '}
|
||||
{c.code} {c.name}
|
||||
{isActive ? ' *' : ''}
|
||||
</Text>
|
||||
</Text>
|
||||
)
|
||||
})}
|
||||
{filtered.length === 0 && search.length >= 3 && isValidCurrencyCode(search.toUpperCase()) && (
|
||||
<Text color={GOLD}> Press enter to use {search.toUpperCase()}</Text>
|
||||
)}
|
||||
{filtered.length === 0 && (search.length < 3 || !isValidCurrencyCode(search.toUpperCase())) && (
|
||||
<Text dimColor> No matches</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
function Row({ wide, width, children }: { wide: boolean; width: number; children: React.ReactNode }) {
|
||||
if (wide) return <Box width={width}>{children}</Box>
|
||||
return <>{children}</>
|
||||
|
|
@ -475,6 +587,8 @@ function InteractiveDashboard({ initialProjects, initialPeriod, initialProvider
|
|||
const [period, setPeriod] = useState<Period>(initialPeriod)
|
||||
const [projects, setProjects] = useState<ProjectSummary[]>(initialProjects)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [showCurrencyPicker, setShowCurrencyPicker] = useState(false)
|
||||
const [, forceRender] = useState(0)
|
||||
const [activeProvider, setActiveProvider] = useState(initialProvider)
|
||||
const [detectedProviders, setDetectedProviders] = useState<string[]>([])
|
||||
const { dashWidth } = getLayout()
|
||||
|
|
@ -515,11 +629,19 @@ function InteractiveDashboard({ initialProjects, initialPeriod, initialProvider
|
|||
}, [period, activeProvider, reloadData])
|
||||
|
||||
useInput((input, key) => {
|
||||
// Don't capture keys while the currency picker is open -- it has its own input handler
|
||||
if (showCurrencyPicker) return
|
||||
|
||||
if (input === 'q') {
|
||||
exit()
|
||||
return
|
||||
}
|
||||
|
||||
if (input === 'c') {
|
||||
setShowCurrencyPicker(true)
|
||||
return
|
||||
}
|
||||
|
||||
if (input === 'p' && multipleProviders) {
|
||||
const options = ['all', ...detectedProviders]
|
||||
const idx = options.indexOf(activeProvider)
|
||||
|
|
@ -540,6 +662,25 @@ function InteractiveDashboard({ initialProjects, initialPeriod, initialProvider
|
|||
else if (input === '4') switchPeriod('month')
|
||||
})
|
||||
|
||||
const handleCurrencySelect = async (code: string) => {
|
||||
setShowCurrencyPicker(false)
|
||||
await switchCurrency(code)
|
||||
forceRender(n => n + 1)
|
||||
}
|
||||
|
||||
const handleCurrencyCancel = () => {
|
||||
setShowCurrencyPicker(false)
|
||||
}
|
||||
|
||||
if (showCurrencyPicker) {
|
||||
return (
|
||||
<Box flexDirection="column" width={dashWidth}>
|
||||
<PeriodTabs active={period} providerName={activeProvider} showProvider={multipleProviders} />
|
||||
<CurrencyPicker width={dashWidth} onSelect={handleCurrencySelect} onCancel={handleCurrencyCancel} />
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Box flexDirection="column" width={dashWidth}>
|
||||
|
|
|
|||
|
|
@ -174,6 +174,7 @@ export function renderMenubarFormat(
|
|||
{ code: 'KRW', name: 'South Korean Won' },
|
||||
{ code: 'MXN', name: 'Mexican Peso' },
|
||||
{ code: 'ZAR', name: 'South African Rand' },
|
||||
{ code: 'DKK', name: 'Danish Krone' },
|
||||
]
|
||||
lines.push(`Currency: ${activeCurrency} | size=14`)
|
||||
for (const { code, name } of currencies) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue