mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-10-19 10:13:21 +00:00
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.
This commit is contained in:
parent
c2cfbd18d8
commit
389c14ce5e
8 changed files with 67 additions and 25 deletions
|
|
@ -1,4 +1,5 @@
|
|||
import { localeTag, t } from '../i18n'
|
||||
import type { MenubarPayload } from './types'
|
||||
|
||||
export type ActiveCurrency = { code: string; symbol: string; rate: number }
|
||||
|
||||
|
|
@ -24,6 +25,16 @@ export function isEstimatedCost(cost: number, estimatedCost: number | undefined)
|
|||
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. */
|
||||
|
|
|
|||
|
|
@ -322,6 +322,23 @@ describe('Overview', () => {
|
|||
expect(ticks.at(-1)).toHaveTextContent(now.toLocaleString('en-US', { month: 'short', day: 'numeric' }))
|
||||
})
|
||||
|
||||
it.each([
|
||||
['marks', 12.4, '~$312.40'],
|
||||
['does not mark', 2, '$312.40'],
|
||||
])('%s the hero total by the shared 1%% estimated rule', async (_label, estimatedCostUSD, shown) => {
|
||||
const payload = makePayload(new Date())
|
||||
payload.current.providerDetails = [
|
||||
{ id: 'claude', label: 'Claude', cost: 300 - estimatedCostUSD },
|
||||
{ id: 'cursor', label: 'Cursor', cost: 12.4 + estimatedCostUSD, estimatedCostUSD },
|
||||
{ id: 'gateway', label: 'Gateway', cost: 50, estimatedCostUSD: 50, excludedFromTotal: true },
|
||||
]
|
||||
getOverview.mockResolvedValue(payload)
|
||||
|
||||
const { container } = render(<Overview period="30days" provider="all" />)
|
||||
|
||||
await waitFor(() => expect(container.querySelector('.ov-hero-num')).toHaveTextContent(new RegExp(`^${shown.replace(/[$.]/g, '\\$&')}$`)))
|
||||
})
|
||||
|
||||
it('keeps weekday labels, and month labels on one row at least three columns apart', async () => {
|
||||
const now = new Date()
|
||||
getOverview.mockResolvedValue(makePayload(now))
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import { StaleBanner } from '../components/StaleBanner'
|
|||
import { DUR, motionEnabled, useBarGrowIn } from '../lib/motion'
|
||||
import { useOptimizeSnapshot } from '../hooks/useOptimizeSnapshot'
|
||||
import { type Polled, usePolled } from '../hooks/usePolled'
|
||||
import { asOfLabel, formatCompact, formatCount, formatUsd, formatUsdWithCurrency, isEstimatedCost } from '../lib/format'
|
||||
import { asOfLabel, estimatedPortion, formatCompact, formatCount, formatUsd, formatUsdWithCurrency, isEstimatedCost } from '../lib/format'
|
||||
import { Usd, sumTokens, tokensOf, useUsdPop } from '../components/Usd'
|
||||
import { codeburn } from '../lib/ipc'
|
||||
import {
|
||||
|
|
@ -598,8 +598,9 @@ function streakDays(daily: DailyHistoryEntry[], now: Date): number {
|
|||
* changes (a user action), but never on the 30s poll: a value that arrives
|
||||
* under the same `animateKey` snaps in place instead of re-animating.
|
||||
*/
|
||||
function CountUp({ value, tokens, animateKey, animate = true }: { value: number; tokens?: ReturnType<typeof tokensOf>; animateKey: string; animate?: boolean }) {
|
||||
const pop = useUsdPop<HTMLDivElement>(tokens)
|
||||
function CountUp({ value, tokens, animateKey, animate = true, estimated = false }: { value: number; tokens?: ReturnType<typeof tokensOf>; animateKey: string; animate?: boolean; estimated?: boolean }) {
|
||||
const pop = useUsdPop<HTMLDivElement>(tokens, false, estimated)
|
||||
const mark = estimated ? '~' : ''
|
||||
const ref = pop.ref
|
||||
const keyRef = useRef<string | null>(null)
|
||||
|
||||
|
|
@ -609,7 +610,7 @@ function CountUp({ value, tokens, animateKey, animate = true }: { value: number;
|
|||
const keyChanged = keyRef.current !== animateKey
|
||||
keyRef.current = animateKey
|
||||
if (!animate || !keyChanged || !motionEnabled()) {
|
||||
element.textContent = formatUsd(value)
|
||||
element.textContent = mark + formatUsd(value)
|
||||
return
|
||||
}
|
||||
const counter = { n: 0 }
|
||||
|
|
@ -617,14 +618,14 @@ function CountUp({ value, tokens, animateKey, animate = true }: { value: number;
|
|||
n: value,
|
||||
duration: 0.7,
|
||||
ease: 'power2.out',
|
||||
onUpdate: () => { element.textContent = formatUsd(counter.n) },
|
||||
onUpdate: () => { element.textContent = mark + formatUsd(counter.n) },
|
||||
})
|
||||
return () => { tween.kill() }
|
||||
}, [value, animateKey, animate])
|
||||
}, [value, animateKey, animate, mark])
|
||||
|
||||
return (
|
||||
<>
|
||||
<div ref={ref} className="ov-hero-num" data-countup={value} data-countup-animation={animate ? 'enabled' : 'suppressed'} {...pop.props}>{formatUsd(value)}</div>
|
||||
<div ref={ref} className="ov-hero-num" data-countup={value} data-countup-animation={animate ? 'enabled' : 'suppressed'} {...pop.props}>{mark}{formatUsd(value)}</div>
|
||||
{pop.pop}
|
||||
</>
|
||||
)
|
||||
|
|
@ -1098,6 +1099,10 @@ export function OverviewContent({
|
|||
const useGeneration = headline != null && genModels != null
|
||||
const heroCost = combined ? combined.combined.cost : useGeneration ? headline.cost : data.current.cost
|
||||
const heroCalls = combined ? combined.combined.calls : useGeneration ? headline.calls : data.current.calls
|
||||
// The combined aggregate carries no estimated split, so it is never marked.
|
||||
const heroEstimated = !combined && isEstimatedCost(heroCost, useGeneration
|
||||
? genModels.reduce((sum, model) => sum + (model.estimatedCostUSD ?? 0), 0)
|
||||
: estimatedPortion(data.current))
|
||||
const heroSessions = combined ? combined.combined.sessions : data.current.sessions
|
||||
const heroSessionLabel = combined
|
||||
? formatCombinedSessionCount()
|
||||
|
|
@ -1200,7 +1205,7 @@ export function OverviewContent({
|
|||
{/* A returning launch already showed a truthful persisted headline.
|
||||
Replaying the live hero from $0 on handoff makes that exact value
|
||||
appear to collapse and recover; snap to the revalidated total. */}
|
||||
<CountUp value={heroCost} tokens={heroTokens} animateKey={animateKey} animate={!suppressHeroReplay} />
|
||||
<CountUp value={heroCost} tokens={heroTokens} animateKey={animateKey} animate={!suppressHeroReplay} estimated={heroEstimated} />
|
||||
<div className="ov-hero-sub" title={heroSessionHelp}>{formatCount(heroCalls, 'call')} · {heroSessionLabel}</div>
|
||||
{(provider === 'cursor' || provider === 'cursor-agent') && <CursorSyncLine status={data.cursorSync} />}
|
||||
{combined
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { t } from '../i18n'
|
||||
import { formatCount, formatResetTime, formatUsd, isEstimatedCost, setActiveCurrency, type ActiveCurrency } from '../lib/format'
|
||||
import { estimatedPortion, formatCount, formatResetTime, formatUsd, isEstimatedCost, setActiveCurrency, type ActiveCurrency } from '../lib/format'
|
||||
import { PROVIDER_NAMES } from '../lib/providers'
|
||||
import type { MenubarPayload, ProviderName, QuotaProvider } from '../lib/types'
|
||||
|
||||
|
|
@ -24,16 +24,6 @@ export type Summary = {
|
|||
|
||||
const USD: ActiveCurrency = { code: 'USD', symbol: '$', rate: 1 }
|
||||
|
||||
/** 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. */
|
||||
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)
|
||||
}
|
||||
|
||||
/** The #1657 rule, as every other surface applies it. Needs the payload's currency active. */
|
||||
export function figureOf(payload: MenubarPayload): Figure {
|
||||
return { cost: payload.current.cost, estimated: isEstimatedCost(payload.current.cost, estimatedPortion(payload.current)) }
|
||||
|
|
|
|||
|
|
@ -99,6 +99,13 @@ function formatCost(value, currency, rate = 1, exact = false) {
|
|||
return `${symbol}${parts.join('.')}`;
|
||||
}
|
||||
|
||||
// Same rule as the CLI (src/format.ts isEstimatedCost): `~` once the estimated
|
||||
// portion is at least 1% of the figure, unless `shown` reads as zero.
|
||||
function isEstimatedCost(cost, estimatedCostUSD, shown) {
|
||||
const estimated = Number(estimatedCostUSD) || 0;
|
||||
return estimated > 0 && estimated >= cost * 0.01 && /[1-9]/.test(shown);
|
||||
}
|
||||
|
||||
function formatTokensCompact(n) {
|
||||
const v = Number(n) || 0;
|
||||
if (v >= 1_000_000_000) return `${(v / 1_000_000_000).toFixed(1)}B`;
|
||||
|
|
@ -795,7 +802,10 @@ class CodeBurnIndicator extends PanelMenu.Button {
|
|||
|
||||
this._panelLabel.set_text(this._fmt(cost));
|
||||
this._heroLabel.set_text(combined ? `Combined · ${current.label || ''}` : (current.label || ''));
|
||||
this._heroAmount.set_text(this._fmt(cost));
|
||||
// Combined totals carry no estimated split, so only a local figure is marked.
|
||||
const estimated = !combined && current === payload?.current
|
||||
&& isEstimatedCost(cost, (current.providerDetails ?? []).reduce((sum, d) => sum + numberValue(d?.estimatedCostUSD), 0), this._fmt(cost));
|
||||
this._heroAmount.set_text(`${estimated ? '~' : ''}${this._fmt(cost)}`);
|
||||
|
||||
const calls = Number(combined?.calls ?? current.calls ?? 0);
|
||||
const sessions = Number(combined?.sessions ?? current.sessions ?? 0);
|
||||
|
|
|
|||
|
|
@ -104,7 +104,11 @@ struct HeroSection: View {
|
|||
if total >= 1_000 { return String(format: "%.0fK tok", total / 1_000) }
|
||||
return String(format: "%.0f tok", total)
|
||||
}
|
||||
return totals.cost.asCurrency()
|
||||
let shown = totals.cost.asCurrency()
|
||||
// The combined aggregate carries no estimated split, so it is never marked.
|
||||
guard combinedUsage == nil else { return shown }
|
||||
let estimated = store.payload.current.providerDetails.reduce(0.0) { $0 + ($1.estimatedCostUSD ?? 0) }
|
||||
return (isEstimatedCost(totals.cost, estimated, shown: shown) ? "~" : "") + shown
|
||||
}
|
||||
|
||||
private var combinedUsage: CombinedUsage? {
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { listen } from '@tauri-apps/api/event'
|
|||
|
||||
import type { MenubarPayload } from './lib/payload'
|
||||
import type { CurrencyState } from './lib/currency'
|
||||
import { USD, formatCurrency, formatTokens, trayBadgeText } from './lib/currency'
|
||||
import { USD, formatCurrency, formatTokens, isEstimatedCost, trayBadgeText } from './lib/currency'
|
||||
import { PayloadCache, sameSelection, selectionKey, type Selection } from './lib/cache'
|
||||
import { relativePast } from './lib/dates'
|
||||
import { applyTheme, readSetting, writeSetting } from './lib/settings'
|
||||
|
|
@ -423,9 +423,11 @@ export function App() {
|
|||
const isTokenMetric = settings.metric === 'tokens' || settings.metric === 'totalTokens'
|
||||
// The 16 px badge bitmap has room for about four glyphs, so the mac's up/down token pair
|
||||
// cannot fit; both token metrics show the total there and the hero carries the split.
|
||||
const trayEstimated = trayCombined === null && trayCost !== null
|
||||
&& isEstimatedCost(trayCost, (trayCurrent?.providerDetails ?? []).reduce((sum, d) => sum + (d.estimatedCostUSD ?? 0), 0), formatCurrency(trayCost, currency))
|
||||
const trayFigure = isTokenMetric
|
||||
? (trayTokens === null ? null : `${formatTokens(trayTokens)} tok`)
|
||||
: (trayCost === null ? null : formatCurrency(trayCost, currency))
|
||||
: (trayCost === null ? null : `${trayEstimated ? '~' : ''}${formatCurrency(trayCost, currency)}`)
|
||||
// Combined scope with a paired device that did not report this cycle: the figure is short
|
||||
// of what these machines actually spent, and the mac says so beside its title with a dimmed
|
||||
// "reachable/total". A 16 px bitmap has no room for that, so the badge is drawn dimmed and
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import type { CombinedUsage, CursorSyncStatus, MenubarPayload } from '../lib/payload'
|
||||
import type { CurrencyState } from '../lib/currency'
|
||||
import { formatCurrency, formatTokens } from '../lib/currency'
|
||||
import { formatCurrency, formatTokens, isEstimatedCost } from '../lib/currency'
|
||||
import { prettyDate, relativePast, todayKey } from '../lib/dates'
|
||||
import { SectionCaption } from './CollapsibleSection'
|
||||
import { ArrowDownRight, ArrowUpRight, LeafIcon, MonitorIcon, WarningIcon } from './Icons'
|
||||
|
|
@ -50,9 +50,12 @@ export function HeroSection({ payload, currency, periodLabel, isToday, dailyBudg
|
|||
// Both token metrics put the total in the headline, as the mac's heroText does; only the
|
||||
// Tokens metric replaces calls and sessions with the up and down split.
|
||||
const isTokenMetric = metric === 'tokens' || metric === 'totalTokens'
|
||||
const shownCost = formatCurrency(cost, currency)
|
||||
// The combined aggregate carries no estimated split, so it is never marked.
|
||||
const estimated = !totals && isEstimatedCost(cost, (payload?.current.providerDetails ?? []).reduce((sum, d) => sum + (d.estimatedCostUSD ?? 0), 0), shownCost)
|
||||
const headline = isTokenMetric
|
||||
? `${formatTokens(inputTokens + outputTokens)} tok`
|
||||
: formatCurrency(cost, currency)
|
||||
: `${estimated ? '~' : ''}${shownCost}`
|
||||
|
||||
const label = payload?.current.label || periodLabel
|
||||
const caption = combined ? `Combined · ${label}` : isToday ? `Today · ${todayLabel}` : label
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue