codeburn/app/renderer/lib/format.ts
Resham Joshi 389c14ce5e
fix(ui): mark estimated headline totals with ~ on every hero (#1722)
#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.
2026-10-10 07:05:58 +02:00

158 lines
7.4 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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