mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-10-27 01:00:36 +00:00
#1657 put the ~ mark on estimated rows, but the headline total stayed bare on the desktop hero, the mac hero, the Windows hero and tray text, and GNOME. Each now applies the same 1% rule to the provider split's estimated portion (the desktop helper moves from the VS Code summary into lib/format). Combined device totals carry no estimated split and stay unmarked.
158 lines
7.4 KiB
TypeScript
158 lines
7.4 KiB
TypeScript
import { localeTag, t } from '../i18n'
|
||
import type { MenubarPayload } from './types'
|
||
|
||
export 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 formatUsdWithCurrency(n, activeCurrency)
|
||
}
|
||
|
||
/** Same rule as the CLI (src/format.ts isEstimatedCost): a row is marked `~`
|
||
* once its estimated portion is at least 1% of its cost, unless the amount
|
||
* reads as zero in the active currency. */
|
||
export function isEstimatedCost(cost: number, estimatedCost: number | undefined): boolean {
|
||
const estimated = estimatedCost ?? 0
|
||
return estimated > 0 && estimated >= cost * 0.01 && /[1-9]/.test(formatUsd(cost))
|
||
}
|
||
|
||
/** The estimated share of a payload's total: the provider split carries it on
|
||
* current CLIs; the model split is the fallback for a CLI that predates that. */
|
||
export function estimatedPortion(current: MenubarPayload['current']): number {
|
||
const providers = current.providerDetails?.filter(row => !row.excludedFromTotal)
|
||
if (providers?.some(row => row.estimatedCostUSD !== undefined)) {
|
||
return providers.reduce((sum, row) => sum + (row.estimatedCostUSD ?? 0), 0)
|
||
}
|
||
return (current.topModels ?? []).reduce((sum, row) => sum + (row.estimatedCostUSD ?? 0), 0)
|
||
}
|
||
|
||
/** Raw-USD input formatted against an explicit payload currency. This keeps a
|
||
* persisted exact snapshot correct on its very first paint, before App's
|
||
* global active-currency effect has had a chance to run. */
|
||
export function formatUsdWithCurrency(n: number, currency: ActiveCurrency): string {
|
||
return `${currency.symbol}${(n * currency.rate).toLocaleString(localeTag(), { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
|
||
}
|
||
|
||
/** a − b as displayed: the difference of the two rounded figures, so shown numbers add up. */
|
||
export function formatUsdDifference(a: number, b: number): string {
|
||
const shown = (n: number) => Number((n * activeCurrency.rate).toFixed(2))
|
||
return formatConverted(shown(a) - shown(b))
|
||
}
|
||
|
||
/**
|
||
* 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(localeTag(), { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
|
||
}
|
||
|
||
/** Shorten filesystem and CLI-mangled project paths to their useful trailing segments. */
|
||
export function shortenProjectPath(value: string, maxSegments = 3): string {
|
||
const trimmed = value.trim()
|
||
const pathDelimited = /[\\/]/.test(trimmed)
|
||
const parts = trimmed.split(pathDelimited ? /[\\/]+/ : /-+/).filter(Boolean)
|
||
let displayParts = parts.slice(-Math.max(1, maxSegments))
|
||
|
||
// A tail rooted directly under a home directory starts with the user name,
|
||
// which adds noise rather than useful project context.
|
||
const precedingPart = parts.at(-(displayParts.length + 1))
|
||
if (displayParts.length > 1 && /^(users?|home)$/i.test(precedingPart ?? '')) {
|
||
displayParts = displayParts.slice(1)
|
||
}
|
||
|
||
if (/^(projects|src|config)$/i.test(displayParts[0] ?? '')) {
|
||
displayParts[0] = displayParts[0].toLowerCase()
|
||
}
|
||
|
||
return displayParts.join('/') || trimmed
|
||
}
|
||
|
||
/** "1 session" / "2,048 sessions" — one count label for every count site, so the
|
||
* separator and the noun form never drift between screens. */
|
||
export function formatCount(n: number, singular: string, plural = `${singular}s`): string {
|
||
const count = n.toLocaleString(localeTag())
|
||
const stem = singular.replace(/\s+/g, '_')
|
||
const template = t(`common.count.${stem}.${n === 1 ? 'one' : 'other'}`)
|
||
// Fall back to English when a noun has no catalog entry (templates carry {count}).
|
||
return template.includes('{count}') ? template.replace('{count}', count) : `${count} ${n === 1 ? singular : plural}`
|
||
}
|
||
|
||
/** Compact token/count formatting: 1_842 → "1.8K", 184_000 → "184K", 1_200_000 → "1.2M". */
|
||
export function formatCompact(n: number): string {
|
||
if (!Number.isFinite(n)) return '—'
|
||
if (n === 0) return '0'
|
||
const abs = Math.abs(n)
|
||
if (abs < 1_000) return String(Math.round(n))
|
||
if (abs < 1_000_000) return `${trim(n / 1_000)}K`
|
||
if (abs < 1_000_000_000) return `${trim(n / 1_000_000)}M`
|
||
return `${trim(n / 1_000_000_000)}B`
|
||
}
|
||
|
||
// One decimal, but drop a trailing ".0" (184.0K → "184K", 1.2K stays "1.2K").
|
||
function trim(v: number): string {
|
||
const s = v.toFixed(1)
|
||
return s.endsWith('.0') ? s.slice(0, -2) : s
|
||
}
|
||
|
||
/** "Jul 10" — short month + day, no year. */
|
||
export function formatDayShort(iso: string): string {
|
||
const d = new Date(iso)
|
||
return Number.isNaN(d.getTime()) ? '—' : d.toLocaleDateString(localeTag(), { month: 'short', day: 'numeric' })
|
||
}
|
||
|
||
/** "Jul 10, 2026" — full date. */
|
||
export function formatDayLong(iso: string): string {
|
||
const d = new Date(iso)
|
||
return Number.isNaN(d.getTime()) ? '—' : d.toLocaleDateString(localeTag(), { month: 'short', day: 'numeric', year: 'numeric' })
|
||
}
|
||
|
||
/** "12 days" / "2h 14m" / "47m" / "38s" from a duration in ms. */
|
||
export function formatDuration(ms: number): string {
|
||
if (!Number.isFinite(ms) || ms <= 0) return '—'
|
||
const totalMin = Math.floor(ms / 60_000)
|
||
if (totalMin < 1) return `${Math.floor(ms / 1000)}s`
|
||
if (totalMin < 60) return `${totalMin}m`
|
||
if (totalMin >= 2_880) return t('common.duration.days', { count: Math.round(totalMin / 1_440) })
|
||
return `${Math.floor(totalMin / 60)}h ${totalMin % 60}m`
|
||
}
|
||
|
||
/**
|
||
* "as of HH:MM" for a figure that is NOT live — a stored or slowly-polled
|
||
* value. Never returns a bare time that could read as now: a value from another
|
||
* day carries its date. Null in, null out (nothing to date yet, so no label).
|
||
*/
|
||
export function asOfLabel(at: number | string | null | undefined): string | null {
|
||
if (at === null || at === undefined) return null
|
||
const when = new Date(at)
|
||
if (Number.isNaN(when.getTime())) return null
|
||
const time = when.toLocaleTimeString(localeTag(), { hour: 'numeric', minute: '2-digit' })
|
||
const sameDay = when.toDateString() === new Date().toDateString()
|
||
return sameDay
|
||
? t('common.asOf', { time })
|
||
: t('common.asOfDate', { date: when.toLocaleDateString(localeTag(), { month: 'short', day: 'numeric' }), time })
|
||
}
|
||
|
||
/** Time left until a quota window resets, or null when it is unknown. */
|
||
export function formatResetTime(resetsAt: string | null): string | null {
|
||
if (!resetsAt) return null
|
||
const reset = Date.parse(resetsAt)
|
||
if (!Number.isFinite(reset)) return null
|
||
const remainingMinutes = Math.floor((reset - Date.now()) / 60_000)
|
||
if (remainingMinutes <= 0) return t('plans.reset.now')
|
||
const days = Math.floor(remainingMinutes / (24 * 60))
|
||
const hours = Math.floor((remainingMinutes % (24 * 60)) / 60)
|
||
const minutes = remainingMinutes % 60
|
||
if (days > 0) return hours > 0 ? t('plans.reset.daysHours', { days, hours }) : t('plans.reset.days', { days })
|
||
if (hours > 0) return minutes > 0 ? t('plans.reset.hoursMinutes', { hours, minutes }) : t('plans.reset.hours', { hours })
|
||
return t('plans.reset.minutes', { minutes })
|
||
}
|