Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions app/renderer/sections/Overview.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -339,6 +339,34 @@ describe('Overview', () => {
await waitFor(() => expect(container.querySelector('.ov-hero-num')).toHaveTextContent(new RegExp(`^${shown.replace(/[$.]/g, '\\$&')}$`)))
})

it.each(['poll', 'period switch'] as const)('survives estimate-marker changes during a %s (#1760)', (change) => {
const payload = makePayload(new Date())
const estimated = {
...payload,
current: {
...payload.current,
cost: 320,
providerDetails: [{ id: 'claude', label: 'Claude', cost: 320, estimatedCostUSD: 20 }],
},
}
const { container, rerender } = render(
<OverviewContent period="30days" provider="all" overview={polled(payload)} />,
)
expect(container.querySelector('.ov-hero-num')).toHaveTextContent(/^\$312\.40$/)

const period = change === 'period switch' ? 'week' : '30days'
rerender(<OverviewContent period={period} provider="all" overview={polled(estimated)} />)
const hero = container.querySelector('.ov-hero-num')!
expect(hero).toHaveTextContent(/^~\$320\.00$/)
// The original outer trigger still anchors the token/estimate popover.
fireEvent.focus(hero)
expect(screen.getByRole('tooltip')).toHaveTextContent(/estimated/i)
fireEvent.blur(hero)

rerender(<OverviewContent period="30days" provider="all" overview={polled(payload)} />)
expect(container.querySelector('.ov-hero-num')).toHaveTextContent(/^\$312\.40$/)
})

it('keeps weekday labels, and month labels on one row at least three columns apart', async () => {
const now = new Date()
getOverview.mockResolvedValue(makePayload(now))
Expand Down Expand Up @@ -1106,6 +1134,18 @@ describe('Overview', () => {
expect(screen.queryByLabelText('Coaching signals')).not.toBeInTheDocument()
})

it('updates the hero currency even when its raw cost and filter stay the same', () => {
const payload = makePayload(new Date())
const { container, rerender } = render(
<OverviewContent period="30days" provider="all" overview={polled(payload)} />,
)
expect(container.querySelector('.ov-hero-num')).toHaveTextContent(/^\$312\.40$/)

setActiveCurrency({ code: 'EUR', symbol: '€', rate: 0.9 })
rerender(<OverviewContent period="30days" provider="all" overview={polled(payload)} />)
expect(container.querySelector('.ov-hero-num')).toHaveTextContent(/^€281\.16$/)
})

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()
Expand Down
14 changes: 9 additions & 5 deletions app/renderer/sections/Overview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -601,7 +601,11 @@ function streakDays(daily: DailyHistoryEntry[], now: Date): number {
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 formattedValue = formatUsd(value)
// React owns the marker and wrapper; GSAP owns only the empty number span.
// Replacing React's text children made React insert the estimate marker
// before a detached node (#1760).
const ref = useRef<HTMLSpanElement>(null)
const keyRef = useRef<string | null>(null)

useEffect(() => {
Expand All @@ -610,22 +614,22 @@ function CountUp({ value, tokens, animateKey, animate = true, estimated = false
const keyChanged = keyRef.current !== animateKey
keyRef.current = animateKey
if (!animate || !keyChanged || !motionEnabled()) {
element.textContent = mark + formatUsd(value)
element.textContent = formattedValue
return
}
const counter = { n: 0 }
const tween = gsap.to(counter, {
n: value,
duration: 0.7,
ease: 'power2.out',
onUpdate: () => { element.textContent = mark + formatUsd(counter.n) },
onUpdate: () => { element.textContent = formatUsd(counter.n) },
})
return () => { tween.kill() }
}, [value, animateKey, animate, mark])
}, [value, animateKey, animate, formattedValue])

return (
<>
<div ref={ref} className="ov-hero-num" data-countup={value} data-countup-animation={animate ? 'enabled' : 'suppressed'} {...pop.props}>{mark}{formatUsd(value)}</div>
<div ref={pop.ref} className="ov-hero-num" data-countup={value} data-countup-animation={animate ? 'enabled' : 'suppressed'} {...pop.props}>{mark}<span ref={ref} /></div>
{pop.pop}
</>
)
Expand Down
2 changes: 1 addition & 1 deletion src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -645,7 +645,7 @@ function buildJsonReport(projects: ProjectSummary[], period: string, periodKey:
const sessionCountBasis = durable.data.sessionCountBasis
// Same durable day set as the headline, so a project's row is what selecting
// it reports (expired transcripts included), one row per repository.
const projectList = buildPayloadProjects(projects, durable.days, homedir()).map(p => ({
const projectList = buildPayloadProjects(projects, durable.days, homedir(), durable.sessionCountCacheDays).map(p => ({
name: p.name,
path: p.path ?? p.id ?? p.name,
cost: convertCost(p.cost),
Expand Down
3 changes: 2 additions & 1 deletion src/status-snapshot-semantic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,8 @@ import { DAILY_CACHE_VERSION } from './daily-cache.js'
// turns and cost for otherwise identical period queries.
// v12: pullRequests rows carry carriedUSD, runList, state and followUpAt, and
// push evidence moves spend between rows; an older snapshot shows the old card.
export const STATUS_SNAPSHOT_RENDER_VERSION = 12
// v13: today's live session identities are exact, including project/provider counts.
export const STATUS_SNAPSHOT_RENDER_VERSION = 13

/// The semantic key recorded on every status snapshot. A snapshot whose stored
/// key differs (an older render revision, or a different daily-cache version)
Expand Down
56 changes: 32 additions & 24 deletions src/usage-aggregator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -824,7 +824,8 @@ export function buildDurableOverviewFromNormalizedIndex(
// Fields whose durable day rows cannot project under a project filter come
// from the same normalized period slice that feeds the visible detail panels.
const scan = buildPeriodData(periodInfo.label, scanProjects)
const sessionCount = uniquePeriodSessionCount(scanProjects, days)
// Today is always sourced from the normalized index, with session IDs.
const sessionCount = uniquePeriodSessionCount(scanProjects, days.filter(day => day.date !== todayStr))
data.sessions = sessionCount.sessions
data.sessionCountBasis = sessionCount.basis
if (hasProjectFilter) {
Expand Down Expand Up @@ -882,6 +883,9 @@ export type DurablePeriod = {
/// `includeGatewayInTotals` on, so a surface renders the labelled row
/// whenever `costUSD` is non-zero.
excludedGateway: ExcludedGatewayTotals
/// Days whose accounting may contain sessions without live identities.
/// Today is entirely live; historical reconciliations remain conservative.
sessionCountCacheDays: DailyEntry[]
/// Fresh per-period parse (provider + name filtered) for detail views that
/// still need surviving session files.
liveProjects: ProjectSummary[]
Expand Down Expand Up @@ -1032,7 +1036,10 @@ export async function buildDurablePeriod(periodInfo: PeriodInfo, opts: Aggregate
// Daily occupancy ticks are not unique across a period (one session file
// active on two days is two ticks). Prefer canonical provider+sessionId
// from the live parse; occupancy remains only for source-dead days.
const sessionCount = uniquePeriodSessionCount(liveProjects, days)
// The union always takes today from this parse, never from the cache.
// Keep every day for money, but only historical rows can lack identities.
const sessionCountCacheDays = days.filter(day => day.date !== todayStr)
const sessionCount = uniquePeriodSessionCount(liveProjects, sessionCountCacheDays)
data.sessions = sessionCount.sessions
data.sessionCountBasis = sessionCount.basis
// Tokens/models/categories have no per-project split in the day cache, so
Expand Down Expand Up @@ -1089,7 +1096,7 @@ export async function buildDurablePeriod(periodInfo: PeriodInfo, opts: Aggregate
}]
})) as PeriodTotals
: undefined
return { data, days, carriedCostUSD, unattributedCostUSD, excludedGateway: excludedGatewayTotals(days, pf), liveProjects, knownProjects, cache, todayAllDays, scanRange, periodTotals }
return { data, days, sessionCountCacheDays, carriedCostUSD, unattributedCostUSD, excludedGateway: excludedGatewayTotals(days, pf), liveProjects, knownProjects, cache, todayAllDays, scanRange, periodTotals }
}

type PayloadProject = NonNullable<PeriodData['projects']>[number]
Expand Down Expand Up @@ -1310,15 +1317,19 @@ export function buildPayloadProjects(
scanProjects: ProjectSummary[],
cacheDays: DailyEntry[] | null,
home: string,
sessionCountCacheDays: DailyEntry[] | null = cacheDays,
): PayloadProject[] {
// Money still comes from the complete day set. Only rows actually eligible
// for cache-only accounting can make the corresponding project count partial.
const sessionCountCacheDaySet = new Set(sessionCountCacheDays ?? [])
type Contrib = {
cacheCost: number
cacheSavings: number
cacheSessions: number
cacheSessionDays: number
cacheMaxDaySessions: number
cacheCalls: number
hasCache: boolean
unknownSessions: boolean
liveCost: number
liveSavings: number
liveSessions: number
Expand Down Expand Up @@ -1360,11 +1371,11 @@ export function buildPayloadProjects(
c = {
cacheCost: 0,
cacheSavings: 0,
cacheSessions: 0,
cacheSessionDays: 0,
cacheMaxDaySessions: 0,
cacheCalls: 0,
hasCache: false,
unknownSessions: false,
liveCost: 0,
liveSavings: 0,
liveSessions: 0,
Expand All @@ -1384,16 +1395,16 @@ export function buildPayloadProjects(
path: string | undefined,
cost: number,
savingsUSD: number,
occupancy: { sessions: number, sessionDays: number, maxDaySessions: number, calls: number, originKey?: string },
occupancy: { sessions: number, sessionDays: number, maxDaySessions: number, calls: number, unknownSessions: boolean, originKey?: string },
): Acc => {
const acc = take(slug, path, slug)
acc.originKey ??= occupancy.originKey
const c = contribOf(acc, slug)
c.hasCache = true
c.unknownSessions ||= occupancy.unknownSessions
c.cacheCost += cost
c.cacheSavings += savingsUSD
c.cacheCalls += occupancy.calls
c.cacheSessions += occupancy.sessions
c.cacheSessionDays += occupancy.sessionDays
if (occupancy.maxDaySessions > c.cacheMaxDaySessions) c.cacheMaxDaySessions = occupancy.maxDaySessions
return acc
Expand All @@ -1415,16 +1426,17 @@ export function buildPayloadProjects(
for (const s of p.sessions) rememberLiveSlug(s.project, p.projectPath)
}

type Totals = { cost: number; savingsUSD: number; sessions: number; sessionDays: number; maxDaySessions: number; calls: number; originKey?: string }
type Totals = { cost: number; savingsUSD: number; sessions: number; sessionDays: number; maxDaySessions: number; calls: number; unknownSessions: boolean; originKey?: string }
const knownBySlug = new Map<string, Map<string, Totals>>()
const pathlessBySlug = new Map<string, Totals>()
const emptyTotals = (): Totals => ({ cost: 0, savingsUSD: 0, sessions: 0, sessionDays: 0, maxDaySessions: 0, calls: 0 })
const addTotals = (into: Totals, cost: number, savingsUSD: number, sessions: number, calls: number): void => {
const emptyTotals = (): Totals => ({ cost: 0, savingsUSD: 0, sessions: 0, sessionDays: 0, maxDaySessions: 0, calls: 0, unknownSessions: false })
const addTotals = (into: Totals, cost: number, savingsUSD: number, sessions: number, calls: number, unknownSessions: boolean): void => {
into.unknownSessions ||= unknownSessions
into.cost += cost
into.savingsUSD += savingsUSD
into.calls += calls
into.sessions += sessions
if (sessions > 0) {
if (unknownSessions && sessions > 0) {
into.sessionDays += 1
if (sessions > into.maxDaySessions) into.maxDaySessions = sessions
}
Expand All @@ -1433,6 +1445,8 @@ export function buildPayloadProjects(
for (const d of cacheDays) {
for (const [key, p] of Object.entries(d.projects ?? {})) {
const name = projectDayIdentity(key, p).project
const unknownSessions = sessionCountCacheDaySet.has(d)
&& (d.carried === true || hasUnknownCacheAccounting(p))
if (p.path) {
const id = spendProjectIdentity({ project: name, projectPath: p.path }).id
let byId = knownBySlug.get(name)
Expand All @@ -1441,12 +1455,12 @@ export function buildPayloadProjects(
knownBySlug.set(name, byId)
}
const acc = byId.get(id) ?? emptyTotals()
addTotals(acc, p.cost, p.savingsUSD, p.sessions, p.calls)
addTotals(acc, p.cost, p.savingsUSD, p.sessions, p.calls, unknownSessions)
acc.originKey ??= p.originKey
byId.set(id, acc)
} else {
const acc = pathlessBySlug.get(name) ?? emptyTotals()
addTotals(acc, p.cost, p.savingsUSD, p.sessions, p.calls)
addTotals(acc, p.cost, p.savingsUSD, p.sessions, p.calls, unknownSessions)
pathlessBySlug.set(name, acc)
}
}
Expand Down Expand Up @@ -1540,15 +1554,7 @@ export function buildPayloadProjects(
let unknownCache = false
for (const c of acc.contribs.values()) {
if (!c.hasCache) continue
if (
c.cacheCost > 0
|| c.cacheSavings > 0
|| c.cacheCalls > 0
|| c.cacheSessions > 0
|| c.cacheMaxDaySessions > 0
) {
unknownCache = true
}
unknownCache ||= c.unknownSessions
if (c.cacheMaxDaySessions > cacheDayBound) cacheDayBound = c.cacheMaxDaySessions
}
const counted = boundSessionCount(liveUnique, cacheDayBound, unknownCache)
Expand Down Expand Up @@ -1675,6 +1681,7 @@ export async function buildMenubarPayloadForRange(periodInfo: PeriodInfo, opts:
/// unscoped all-provider path; it is the authority the projects view merges
/// from, so carried days count even after their session files are gone.
let cacheDaysForPeriod: DailyEntry[] | null = null
let sessionCountCacheDays: DailyEntry[] | null = null
let claudeConfigs: ClaudeConfigSelector | undefined
const requestedClaudeConfigSourceId = opts.claudeConfigSourceId?.trim() || null
const isClaudeConfigScoped = requestedClaudeConfigSourceId !== null
Expand Down Expand Up @@ -1721,6 +1728,7 @@ export async function buildMenubarPayloadForRange(periodInfo: PeriodInfo, opts:
scanProjects = durable.liveProjects
scanRange = durable.scanRange
cacheDaysForPeriod = durable.days
sessionCountCacheDays = durable.sessionCountCacheDays
cache = durable.cache
todayAllDays = durable.todayAllDays
durablePeriodTotals = durable.periodTotals
Expand Down Expand Up @@ -1818,7 +1826,7 @@ export async function buildMenubarPayloadForRange(periodInfo: PeriodInfo, opts:
providers,
scanProjects,
!(isClaudeConfigScoped && effectivelyScoped)
&& (cacheDaysForPeriod ?? []).some(dayLacksSessionIdentities),
&& (sessionCountCacheDays ?? []).some(dayLacksSessionIdentities),
)
}
// Day slices carry no estimated split, so it comes from the live parse, the
Expand Down Expand Up @@ -1885,7 +1893,7 @@ export async function buildMenubarPayloadForRange(periodInfo: PeriodInfo, opts:
// (cache ?? live) otherwise. Days recorded before the projects rollup existed
// have totals but no project split, so this list can sum to less than the
// headline — an honest gap.
currentData.projects = buildPayloadProjects(scanProjects, cacheDaysForPeriod, home)
currentData.projects = buildPayloadProjects(scanProjects, cacheDaysForPeriod, home, sessionCountCacheDays)

const effMap = aggregateModelEfficiency(scanProjects)
currentData.modelEfficiency = [...effMap.entries()].map(([name, eff]) => ({
Expand Down
21 changes: 17 additions & 4 deletions tests/cli-status-menubar.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1073,7 +1073,7 @@ describe('codeburn status --format menubar-json', () => {
}
})

it('rejects a pre-count-basis render-5 snapshot and preserves nonempty costs', async () => {
it.each([5, 12])('rejects a render-%i snapshot and preserves nonempty Today identities and costs', async renderVersion => {
const home = await mkdtemp(join(tmpdir(), 'codeburn-menubar-legacy-basis-live-'))

try {
Expand Down Expand Up @@ -1101,12 +1101,24 @@ describe('codeburn status --format menubar-json', () => {
}
expect(original.current.calls).toBe(1)
expect(original.current.sessions).toBe(1)
expect(original.current.sessionCountBasis).toBe('partial')
expect(original.current.sessionCountBasis).toBe('identity')
expect(original.current.cost).toBeGreaterThan(0)

const snapshotFiles = findSnapshotFiles(join(home, '.cache', 'codeburn'))
expect(snapshotFiles).toHaveLength(1)
await plantRender5MissingBasis(snapshotFiles[0]!)
if (renderVersion === 5) {
await plantRender5MissingBasis(snapshotFiles[0]!)
} else {
// v12 already knows the field but wrongly qualifies live Today as partial.
// A v13 rendering bump must reject this otherwise-valid warm snapshot.
const record = JSON.parse(await readFile(snapshotFiles[0]!, 'utf-8')) as {
semanticKey: string
payload: { current: { sessionCountBasis: string } }
}
record.semanticKey = record.semanticKey.replace(/:render-\d+:/, ':render-12:')
record.payload.current.sessionCountBasis = 'partial'
await writeFile(snapshotFiles[0]!, JSON.stringify(record))
}

const second = runCli(args, home)
expect(second.status, `stderr: ${second.stderr}`).toBe(0)
Expand Down Expand Up @@ -1156,7 +1168,8 @@ describe('codeburn status --format menubar-json', () => {
const first = runCli(args, home)
expect(first.status, `stderr: ${first.stderr}`).toBe(0)
const original = JSON.parse(first.stdout) as { current: { cost: number, calls: number, sessions: number, sessionCountBasis?: string } }
expect(original.current.sessionCountBasis).toBe('partial')
// Before adding retained history, this period contains only today's live session.
expect(original.current.sessionCountBasis).toBe('identity')
expect(original.current.sessions).toBe(1)

const cacheDir = join(home, '.cache', 'codeburn')
Expand Down
Loading
Loading