diff --git a/app/renderer/sections/Overview.test.tsx b/app/renderer/sections/Overview.test.tsx
index 8ec61af80..48540d3d7 100644
--- a/app/renderer/sections/Overview.test.tsx
+++ b/app/renderer/sections/Overview.test.tsx
@@ -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(
+ ,
+ )
+ expect(container.querySelector('.ov-hero-num')).toHaveTextContent(/^\$312\.40$/)
+
+ const period = change === 'period switch' ? 'week' : '30days'
+ rerender()
+ 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()
+ 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))
@@ -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(
+ ,
+ )
+ expect(container.querySelector('.ov-hero-num')).toHaveTextContent(/^\$312\.40$/)
+
+ setActiveCurrency({ code: 'EUR', symbol: '€', rate: 0.9 })
+ rerender()
+ 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()
diff --git a/app/renderer/sections/Overview.tsx b/app/renderer/sections/Overview.tsx
index 2a9374438..de3bddc9b 100644
--- a/app/renderer/sections/Overview.tsx
+++ b/app/renderer/sections/Overview.tsx
@@ -601,7 +601,11 @@ function streakDays(daily: DailyHistoryEntry[], now: Date): number {
function CountUp({ value, tokens, animateKey, animate = true, estimated = false }: { value: number; tokens?: ReturnType; animateKey: string; animate?: boolean; estimated?: boolean }) {
const pop = useUsdPop(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(null)
const keyRef = useRef(null)
useEffect(() => {
@@ -610,7 +614,7 @@ 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 }
@@ -618,14 +622,14 @@ function CountUp({ value, tokens, animateKey, animate = true, estimated = false
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 (
<>
- {mark}{formatUsd(value)}
+ {mark}
{pop.pop}
>
)
diff --git a/src/main.ts b/src/main.ts
index a8ec40fd8..2c37edc5f 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -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),
diff --git a/src/status-snapshot-semantic.ts b/src/status-snapshot-semantic.ts
index d3bb7ca33..a32daf58a 100644
--- a/src/status-snapshot-semantic.ts
+++ b/src/status-snapshot-semantic.ts
@@ -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)
diff --git a/src/usage-aggregator.ts b/src/usage-aggregator.ts
index 9ac5f7816..fae776a00 100644
--- a/src/usage-aggregator.ts
+++ b/src/usage-aggregator.ts
@@ -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) {
@@ -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[]
@@ -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
@@ -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[number]
@@ -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
@@ -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,
@@ -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
@@ -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>()
const pathlessBySlug = new Map()
- 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
}
@@ -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)
@@ -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)
}
}
@@ -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)
@@ -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
@@ -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
@@ -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
@@ -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]) => ({
diff --git a/tests/cli-status-menubar.test.ts b/tests/cli-status-menubar.test.ts
index 7c28f69b3..679c55ef5 100644
--- a/tests/cli-status-menubar.test.ts
+++ b/tests/cli-status-menubar.test.ts
@@ -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 {
@@ -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)
@@ -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')
diff --git a/tests/live-session-count.test.ts b/tests/live-session-count.test.ts
new file mode 100644
index 000000000..d1420a454
--- /dev/null
+++ b/tests/live-session-count.test.ts
@@ -0,0 +1,120 @@
+import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { afterEach, beforeEach, describe, expect, it } from 'vitest'
+
+import { getDateRange } from '../src/cli-date.js'
+import { currentTzKey, DAILY_CACHE_VERSION, emptyCache, toDateString, type DailyCache, type DailyEntry } from '../src/daily-cache.js'
+import { loadPricing } from '../src/models.js'
+import { clearSessionCache, parseAllSessions } from '../src/parser.js'
+import { buildDurableOverviewFromNormalizedIndex, buildMenubarPayloadForRange, getDailyCacheConfigHash } from '../src/usage-aggregator.js'
+
+let root: string
+
+beforeEach(async () => {
+ root = await mkdtemp(join(tmpdir(), 'codeburn-live-count-'))
+ process.env['CLAUDE_CONFIG_DIR'] = join(root, 'claude')
+ process.env['CODEBURN_CACHE_DIR'] = join(root, 'cache')
+ process.env['CODEBURN_DESKTOP_SESSIONS_DIR'] = join(root, 'desktop')
+ clearSessionCache()
+ await loadPricing()
+ const now = new Date()
+ const timestamp = new Date(now.getFullYear(), now.getMonth(), now.getDate()).toISOString()
+ for (const name of ['alpha', 'beta']) {
+ const dir = join(root, 'claude', 'projects', name)
+ await mkdir(dir, { recursive: true })
+ await writeFile(join(dir, `${name}.jsonl`), JSON.stringify({
+ type: 'assistant', sessionId: name, timestamp, cwd: `/projects/${name}`,
+ message: {
+ id: name, role: 'assistant', type: 'message', model: 'claude-sonnet-4-6', content: [],
+ usage: { input_tokens: 1000, output_tokens: 100 },
+ },
+ }) + '\n')
+ }
+})
+
+afterEach(async () => {
+ clearSessionCache()
+ await rm(root, { recursive: true, force: true })
+})
+
+async function seedHistory(): Promise {
+ const now = new Date()
+ const date = toDateString(new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1))
+ const projects = { alpha: { path: '/projects/alpha', cost: 9, calls: 9, sessions: 3, savingsUSD: 0 } }
+ const day: DailyEntry = {
+ date, cost: 9, calls: 9, sessions: 3, savingsUSD: 0,
+ inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0,
+ editTurns: 0, oneShotTurns: 0, models: {}, categories: {}, projects,
+ providers: { claude: { cost: 9, calls: 9, sessions: 3, savingsUSD: 0, projects } },
+ carried: true,
+ }
+ const cache: DailyCache = {
+ ...emptyCache(), version: DAILY_CACHE_VERSION, days: [day], lastComputedDate: date,
+ savingsConfigHash: getDailyCacheConfigHash(), tzKey: currentTzKey(), complete: true, watermarkTrusted: true,
+ }
+ await mkdir(join(root, 'cache'), { recursive: true })
+ await writeFile(join(root, 'cache', `daily-cache.v${DAILY_CACHE_VERSION}.json`), JSON.stringify(cache))
+ return day
+}
+
+const options = { optimize: false, timeline: false }
+
+describe('live Today session counts (#1785)', () => {
+ it.each([
+ ['all', undefined, 2],
+ ['claude', undefined, 2],
+ ['all', ['alpha'], 1],
+ ['claude', ['alpha'], 1],
+ ] as const)('keeps Today exact for %s with project filter %j', async (provider, project, count) => {
+ // Retained history exists but is outside the selected Today range.
+ await seedHistory()
+ const payload = await buildMenubarPayloadForRange(getDateRange('today'), {
+ ...options, provider, project: project ? [...project] : undefined,
+ })
+ expect(payload.current).toMatchObject({ sessions: count, sessionCountBasis: 'identity', calls: count })
+ expect(payload.current.cost).toBeGreaterThan(0)
+ expect(payload.current.providerDetails.find(p => p.id === 'claude'))
+ .toMatchObject({ sessions: count, sessionCountBasis: 'identity' })
+ expect(payload.current.topProjects).toHaveLength(count)
+ for (const row of payload.current.topProjects) {
+ expect(row).toMatchObject({ sessions: 1, sessionCountBasis: 'identity' })
+ expect(row.avgCostPerSession).toBeCloseTo(row.cost)
+ }
+ expect(payload.current.topProjects.reduce((sum, p) => sum + p.cost, 0)).toBeCloseTo(payload.current.cost)
+ })
+
+ it.each(['all', 'claude'])('preserves historical lower bounds and money for %s', async provider => {
+ await seedHistory()
+ const today = await buildMenubarPayloadForRange(getDateRange('today'), { ...options, provider })
+ const week = await buildMenubarPayloadForRange(getDateRange('week'), { ...options, provider })
+ expect(week.current).toMatchObject({ sessions: 3, sessionCountBasis: 'partial', calls: 11 })
+ expect(week.current.cost).toBeCloseTo(9 + today.current.cost)
+ expect(week.current.providerDetails.find(p => p.id === 'claude'))
+ .toMatchObject({ sessions: 3, sessionCountBasis: 'partial' })
+ const historical = week.current.topProjects.find(p => p.name === 'alpha')!
+ expect(historical).toMatchObject({ sessions: 3, sessionCountBasis: 'partial' })
+ // Kept numeric for older menubar decoders; current clients gate on basis.
+ expect(historical.avgCostPerSession).toBeCloseTo(historical.cost / 3)
+ // A different project backed only by today's live row stays exact.
+ expect(week.current.topProjects.find(p => p.name === 'beta'))
+ .toMatchObject({ sessions: 1, sessionCountBasis: 'identity' })
+ expect(week.current.topProjects.reduce((sum, p) => sum + p.cost, 0)).toBeCloseTo(week.current.cost)
+ })
+
+ it.each(['all', 'claude'])('keeps the normalized-index Today headline exact for %s', async provider => {
+ const period = getDateRange('today')
+ const projects = await parseAllSessions(period.range, provider)
+ const result = buildDurableOverviewFromNormalizedIndex(period, projects, emptyCache(), { provider })
+ expect(result).toMatchObject({ sessions: 2, sessionCountBasis: 'identity', calls: 2 })
+ expect(result.cost).toBeGreaterThan(0)
+ })
+
+ it('keeps an empty Today exact', async () => {
+ await rm(join(root, 'claude'), { recursive: true, force: true })
+ await seedHistory()
+ const payload = await buildMenubarPayloadForRange(getDateRange('today'), { ...options, provider: 'claude' })
+ expect(payload.current).toMatchObject({ sessions: 0, sessionCountBasis: 'identity', cost: 0 })
+ expect(payload.current.topProjects).toEqual([])
+ })
+})
diff --git a/tests/menubar-json.test.ts b/tests/menubar-json.test.ts
index 2ad2862a2..5eab8e632 100644
--- a/tests/menubar-json.test.ts
+++ b/tests/menubar-json.test.ts
@@ -310,6 +310,19 @@ describe('buildMenubarPayload', () => {
expect(payload.current.topActivities).toHaveLength(20)
})
+ it('preserves exact live session counts across the headline, provider and project (#1785)', () => {
+ const period: PeriodData = {
+ ...emptyPeriod('Today'), cost: 4, calls: 2, sessions: 2, sessionCountBasis: 'identity',
+ projects: [{ name: 'project', cost: 4, savingsUSD: 0, sessions: 2, sessionCountBasis: 'identity' }],
+ }
+ const payload = buildMenubarPayload(period, [{
+ name: 'claude', displayName: 'Claude', cost: 4, calls: 2, sessions: 2, sessionCountBasis: 'identity',
+ }], null)
+ expect(payload.current).toMatchObject({ sessions: 2, sessionCountBasis: 'identity' })
+ expect(payload.current.providerDetails[0]).toMatchObject({ sessions: 2, sessionCountBasis: 'identity' })
+ expect(payload.current.topProjects[0]).toMatchObject({ sessions: 2, sessionCountBasis: 'identity', avgCostPerSession: 2 })
+ })
+
it('computes cacheHitPercent from cache reads over input plus cache reads', () => {
const period: PeriodData = {
label: 'Today',