diff --git a/src/usage-aggregator.ts b/src/usage-aggregator.ts index 3f70d34..17dd2af 100644 --- a/src/usage-aggregator.ts +++ b/src/usage-aggregator.ts @@ -634,11 +634,18 @@ export async function buildMenubarPayloadForRange(periodInfo: PeriodInfo, opts: providers.push({ name: 'claude', displayName: displayNameByName.get('claude') ?? 'Claude', cost: 0 }) } } else if (isAllProviders) { - const unfilteredProviderDays = [ - ...(rangeStartStr <= historicalRangeEndStr ? getDaysInRange(cache, rangeStartStr, historicalRangeEndStr) : []), - ...(await getTodayAllDays()).filter(d => d.date >= rangeStartStr && d.date <= rangeEndStr), - ] - const allDaysForProviders = daysSelection ? unfilteredProviderDays.filter(d => daysSelection.days.has(d.date)) : unfilteredProviderDays + // Reuse the day set the headline was built from instead of rebuilding one + // straight out of the cache. The rebuilt version unioned unfiltered historical + // days with an already-filtered today, so per-provider costs counted every + // carried day whole while today honoured --project/--exclude, and the provider + // list could not be reconciled with the By Project panel (#865). + // + // durable.days is that same union, already narrowed by range, day selection and + // the project filter, which is what the comment above the buildDurablePeriod + // call already promised this section would use. Non-null here: this branch + // implies !isClaudeConfigScoped, which forces the !effectivelyScoped path that + // assigns it. + const allDaysForProviders = cacheDaysForPeriod ?? [] const providerTotals: Record = {} for (const d of allDaysForProviders) { for (const [name, p] of Object.entries(d.providers)) { diff --git a/tests/project-filter-durable-totals.test.ts b/tests/project-filter-durable-totals.test.ts index ff030aa..a81793a 100644 --- a/tests/project-filter-durable-totals.test.ts +++ b/tests/project-filter-durable-totals.test.ts @@ -4,7 +4,7 @@ import { existsSync } from 'fs' import { tmpdir } from 'os' import { join } from 'path' -import { DAILY_CACHE_VERSION, currentTzKey, type DailyCache, type DailyEntry } from '../src/daily-cache.js' +import { DAILY_CACHE_VERSION, currentTzKey, type DailyCache, type DailyEntry, type ProjectDayStats, type ProviderDaySlice } from '../src/daily-cache.js' import { loadPricing } from '../src/models.js' import { buildDurablePeriod, buildMenubarPayloadForRange, buildPeriodData, getDailyCacheConfigHash } from '../src/usage-aggregator.js' import { parseAllSessions, filterProjectsByName, clearSessionCache } from '../src/parser.js' @@ -83,6 +83,34 @@ function carriedDayWithoutProjects(date: string): DailyEntry { return day } +/** + * A carried day whose two providers own disjoint projects: claude spent only on + * `keep-me`, codex only on `drop-me`. Filtering `drop-me` out must therefore take + * codex's whole contribution with it, which is what makes the provider list's + * project slicing observable. + */ +function carriedDayTwoProviders(date: string): DailyEntry { + const day = carriedDayWithProjects(date) + const keepProjects = { 'keep-me': { cost: KEEP.cost, calls: KEEP.calls, savingsUSD: 0, sessions: KEEP.sessions, path: '/Users/gone/keep-me' } } + const dropProjects = { 'drop-me': { cost: DROP.cost, calls: DROP.calls, savingsUSD: 0, sessions: DROP.sessions, path: '/Users/gone/drop-me' } } + const slice = ( + stats: { cost: number; calls: number; sessions: number }, + projects: Record, + ): ProviderDaySlice => ({ + calls: stats.calls, cost: stats.cost, savingsUSD: 0, sessions: stats.sessions, + inputTokens: 2500, outputTokens: 1000, cacheReadTokens: 0, cacheWriteTokens: 0, + editTurns: 2, oneShotTurns: 1, + models: { 'Opus 4.8': { calls: stats.calls, cost: stats.cost, savingsUSD: 0, inputTokens: 2500, outputTokens: 1000, cacheReadTokens: 0, cacheWriteTokens: 0 } }, + categories: { coding: { turns: 5, cost: stats.cost, savingsUSD: 0, editTurns: 2, oneShotTurns: 1 } }, + projects, + }) + day.providers = { + claude: slice(KEEP, keepProjects), + codex: slice(DROP, dropProjects), + } + return day +} + async function seedCache(...days: DailyEntry[]): Promise { const cache: DailyCache = { version: DAILY_CACHE_VERSION, @@ -277,6 +305,36 @@ describe('durable headline honours --project / --exclude on carried days', () => expect(menubar.current.inputTokens).toBe(durable.data.inputTokens) }) + it('slices the provider list by the project filter so it reconciles with the headline', async () => { + await seedCache(carriedDayTwoProviders(daysAgoStr(10))) + const range = coveringRange() + + clearSessionCache() + const menubar = await buildMenubarPayloadForRange({ range, label: 'p' }, { provider: 'all', exclude: ['drop-me'], optimize: false, timeline: false }) + + const providers = menubar.current.providers + const providerSum = Object.values(providers).reduce((a, b) => a + b, 0) + + // The headline already honours the filter; the provider list used to be built + // from unfiltered cache days, so it kept reporting codex's excluded spend. + expect(menubar.current.cost).toBeCloseTo(KEEP.cost, 6) + expect(providerSum).toBeCloseTo(menubar.current.cost, 6) + expect(providers['claude']).toBeCloseTo(KEEP.cost, 6) + expect(providers['codex'] ?? 0).toBeCloseTo(0, 6) + }) + + it('leaves the provider list untouched when no project filter is given', async () => { + await seedCache(carriedDayTwoProviders(daysAgoStr(10))) + const range = coveringRange() + + clearSessionCache() + const menubar = await buildMenubarPayloadForRange({ range, label: 'p' }, { provider: 'all', optimize: false, timeline: false }) + + expect(menubar.current.providers['claude']).toBeCloseTo(KEEP.cost, 6) + expect(menubar.current.providers['codex']).toBeCloseTo(DROP.cost, 6) + expect(menubar.current.cost).toBeCloseTo(DAY_COST, 6) + }) + it('leaves the unfiltered headline exactly as it was', async () => { await seedCache(carriedDayWithProjects(daysAgoStr(10))) await seedLiveTodaySession()