mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-10-21 05:23:19 +00:00
fix(usage-aggregator): slice the 365-day history by the project filter
The headline is sliced to the requested projects (buildDurablePeriod runs every historical cache day through sliceDayToProject) and today's days come from the name-filtered parse, but `history.daily` was read straight out of the day cache. Every surface built on it showed every project: the heatmap, the streak, "N active days", month-to-date, the projected month, the daily spend chart, the Spend punchcard and the daily-budget check. On this machine, `status --format menubar-json --project=<one project>` returned a $7.43 headline over a $2,993.61 month-to-date, and 137 of the 138 days in history.daily were byte-identical to the unfiltered run. Same slice the headline already uses, so a cache day with no project split contributes nothing here rather than everything, exactly as it does there.
This commit is contained in:
parent
435a5c8d95
commit
caae3df5cf
2 changed files with 130 additions and 1 deletions
|
|
@ -1477,7 +1477,16 @@ export async function buildMenubarPayloadForRange(periodInfo: PeriodInfo, opts:
|
|||
// a provider-filtered history without re-parsing. Tokens aren't broken down per provider
|
||||
// in the cache, so the filtered view shows zero tokens (heatmap/trend still works on cost).
|
||||
const historyStartStr = toDateString(new Date(now.getFullYear(), now.getMonth(), now.getDate() - BACKFILL_DAYS))
|
||||
const allCacheDays = getDaysInRange(cache, historyStartStr, yesterdayStr)
|
||||
// Under a project filter the headline is sliced by buildDurablePeriod while
|
||||
// today's days come from the name-filtered parse, so without this the heatmap,
|
||||
// streak and month-to-date beside that headline were every project's spend for
|
||||
// all 364 days before today. Same slice the headline uses, same cost: a cache
|
||||
// day with no project split contributes nothing rather than everything.
|
||||
const rawCacheDays = getDaysInRange(cache, historyStartStr, yesterdayStr)
|
||||
const historyFilter = makeProjectFilter(opts.project, opts.exclude)
|
||||
const allCacheDays = (opts.project?.length ?? 0) > 0 || (opts.exclude?.length ?? 0) > 0
|
||||
? rawCacheDays.map(day => sliceDayToProject(day, historyFilter))
|
||||
: rawCacheDays
|
||||
|
||||
let dailyHistory
|
||||
if (isClaudeConfigScoped && claudeConfigs?.selectedId) {
|
||||
|
|
|
|||
120
tests/menubar-history-project-filter.test.ts
Normal file
120
tests/menubar-history-project-filter.test.ts
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
import { afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { mkdir, rm, writeFile } from 'fs/promises'
|
||||
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 { getDateRange } from '../src/cli-date.js'
|
||||
import { loadPricing } from '../src/models.js'
|
||||
import { buildMenubarPayloadForRange, getDailyCacheConfigHash } from '../src/usage-aggregator.js'
|
||||
import { setHome } from './setup/home.js'
|
||||
|
||||
// The headline is sliced to the requested projects and today's days come from a
|
||||
// name-filtered parse, but the 365-day `history.daily` came straight out of the
|
||||
// day cache. Every surface built on it — the heatmap, the streak, month-to-date,
|
||||
// the daily-spend chart — therefore showed EVERY project's spend underneath a
|
||||
// filtered headline.
|
||||
|
||||
const ROOT = join(tmpdir(), `codeburn-history-filter-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`)
|
||||
const ENV_KEYS = ['HOME', 'CODEBURN_CACHE_DIR', 'CLAUDE_CONFIG_DIR', 'CLAUDE_CONFIG_DIRS', 'CODEX_HOME'] as const
|
||||
let savedEnv: Record<string, string | undefined>
|
||||
|
||||
function daysAgoStr(n: number): string {
|
||||
const d = new Date(Date.now() - n * 24 * 60 * 60 * 1000)
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
/** One historical day shared by two projects, with the per-project split a
|
||||
* post-v15 cache carries. */
|
||||
function sharedDay(date: string): DailyEntry {
|
||||
const projects = {
|
||||
'proj-keep': { cost: 100, calls: 40, savingsUSD: 0, sessions: 3, path: '/Users/gone/proj-keep' },
|
||||
'proj-hide': { cost: 50, calls: 20, savingsUSD: 0, sessions: 2, path: '/Users/gone/proj-hide' },
|
||||
}
|
||||
return {
|
||||
date,
|
||||
cost: 150,
|
||||
savingsUSD: 0,
|
||||
calls: 60,
|
||||
sessions: 5,
|
||||
inputTokens: 7000,
|
||||
outputTokens: 3000,
|
||||
cacheReadTokens: 0,
|
||||
cacheWriteTokens: 0,
|
||||
editTurns: 6,
|
||||
oneShotTurns: 3,
|
||||
models: { 'Opus 4.8': { calls: 60, cost: 150, savingsUSD: 0, inputTokens: 7000, outputTokens: 3000, cacheReadTokens: 0, cacheWriteTokens: 0 } },
|
||||
categories: { coding: { turns: 15, cost: 150, savingsUSD: 0, editTurns: 6, oneShotTurns: 3 } },
|
||||
providers: {
|
||||
claude: {
|
||||
calls: 60, cost: 150, savingsUSD: 0, sessions: 5,
|
||||
inputTokens: 7000, outputTokens: 3000, cacheReadTokens: 0, cacheWriteTokens: 0,
|
||||
projects,
|
||||
},
|
||||
},
|
||||
projects,
|
||||
carried: true,
|
||||
}
|
||||
}
|
||||
|
||||
async function seedCache(day: string): Promise<void> {
|
||||
const cache: DailyCache = {
|
||||
version: DAILY_CACHE_VERSION,
|
||||
savingsConfigHash: getDailyCacheConfigHash(),
|
||||
tzKey: currentTzKey(),
|
||||
lastComputedDate: daysAgoStr(1),
|
||||
days: [sharedDay(day)],
|
||||
complete: true,
|
||||
}
|
||||
await writeFile(join(ROOT, 'cache', `daily-cache.v${DAILY_CACHE_VERSION}.json`), JSON.stringify(cache), 'utf-8')
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
await loadPricing()
|
||||
})
|
||||
|
||||
beforeEach(async () => {
|
||||
savedEnv = Object.fromEntries(ENV_KEYS.map(k => [k, process.env[k]]))
|
||||
await mkdir(join(ROOT, 'home'), { recursive: true })
|
||||
await mkdir(join(ROOT, 'cache'), { recursive: true })
|
||||
setHome(join(ROOT, 'home'))
|
||||
process.env['CODEBURN_CACHE_DIR'] = join(ROOT, 'cache')
|
||||
delete process.env['CLAUDE_CONFIG_DIR']
|
||||
delete process.env['CLAUDE_CONFIG_DIRS']
|
||||
delete process.env['CODEX_HOME']
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
for (const k of ENV_KEYS) {
|
||||
if (savedEnv[k] === undefined) delete process.env[k]
|
||||
else process.env[k] = savedEnv[k]
|
||||
}
|
||||
if (existsSync(ROOT)) await rm(ROOT, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe('history.daily under a project filter', () => {
|
||||
it('slices the daily history the same way the headline is sliced', async () => {
|
||||
const day = daysAgoStr(10)
|
||||
await seedCache(day)
|
||||
|
||||
const payload = await buildMenubarPayloadForRange(getDateRange('all'), {
|
||||
provider: 'all', optimize: false, timeline: false, exclude: ['/Users/gone/proj-hide'],
|
||||
})
|
||||
|
||||
expect(payload.current.cost).toBe(100)
|
||||
const entry = payload.history.daily.find(d => d.date === day)
|
||||
expect(entry?.cost).toBe(100)
|
||||
expect(entry?.calls).toBe(40)
|
||||
})
|
||||
|
||||
it('leaves the daily history whole when nothing is filtered', async () => {
|
||||
const day = daysAgoStr(10)
|
||||
await seedCache(day)
|
||||
|
||||
const payload = await buildMenubarPayloadForRange(getDateRange('all'), { provider: 'all', optimize: false, timeline: false })
|
||||
|
||||
expect(payload.current.cost).toBe(150)
|
||||
expect(payload.history.daily.find(d => d.date === day)?.cost).toBe(150)
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue