fix(menubar): apply --project/--exclude to the provider list on carried days

The provider list rebuilt its own day set straight from the daily cache,
unioning unfiltered historical days with an already-filtered today. Per-provider
costs therefore counted every carried day whole while today honoured the name
filters, so the list could not be reconciled with the headline or the By Project
panel. #864 fixed the headline and left this deliberately untouched.

Reuse durable.days, which is the same union the headline is built from, already
narrowed by range, day selection and project filter. That is what the comment
above the buildDurablePeriod call already promised this section would do.

Providers whose entire spend is excluded do not vanish from the list: the
installed-but-zero backfill below still adds them at cost 0.
This commit is contained in:
ozymandiashh 2026-08-02 19:59:18 +03:00
parent 44a94f52cf
commit f2b59f0eb8
2 changed files with 71 additions and 6 deletions

View file

@ -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<string, number> = {}
for (const d of allDaysForProviders) {
for (const [name, p] of Object.entries(d.providers)) {

View file

@ -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<string, ProjectDayStats>,
): 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<void> {
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()