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
575 changes: 478 additions & 97 deletions src/daily-cache.ts

Large diffs are not rendered by default.

99 changes: 92 additions & 7 deletions src/day-aggregator.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { DailyEntry } from './daily-cache.js'
import type { DailyEntry, ProjectDayStats, ProviderDaySlice } from './daily-cache.js'
import type { PeriodData } from './menubar-json.js'
import { CATEGORY_LABELS, type ProjectSummary, type TaskCategory } from './types.js'

Expand Down Expand Up @@ -26,18 +26,52 @@ export function dateKey(iso: string): string {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
}

function emptySlice(): ProviderDaySlice {
return {
calls: 0, cost: 0, savingsUSD: 0,
sessions: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0,
editTurns: 0, oneShotTurns: 0, models: {}, categories: {},
}
}

export function aggregateProjectsIntoDays(projects: ProjectSummary[]): DailyEntry[] {
const byDate = new Map<string, DailyEntry>()
const ensure = (date: string): DailyEntry => {
let d = byDate.get(date)
if (!d) { d = emptyEntry(date); byDate.set(date, d) }
return d
}
const ensureSlice = (day: DailyEntry, provider: string): ProviderDaySlice => {
let s = day.providers[provider]
if (!s) { s = emptySlice(); day.providers[provider] = s }
return s
}
const ensureProject = (holder: { projects?: Record<string, ProjectDayStats> }, project: string, path?: string): ProjectDayStats => {
const projects = (holder.projects ??= {})
// defineProperty so a project directory named "__proto__" becomes an own
// key instead of mutating the prototype link.
let p = Object.hasOwn(projects, project) ? projects[project] : undefined
if (!p) {
p = { cost: 0, calls: 0, savingsUSD: 0, sessions: 0 }
Object.defineProperty(projects, project, { value: p, enumerable: true, writable: true, configurable: true })
}
if (!p.path && path) p.path = path
return p
}

for (const project of projects) {
for (const session of project.sessions) {
const sessionDate = dateKey(session.firstTimestamp)
ensure(sessionDate).sessions += 1
const sessionDay = ensure(sessionDate)
sessionDay.sessions += 1
ensureProject(sessionDay, session.project, project.projectPath).sessions += 1
// A session belongs to exactly one provider; its calls all carry it.
const sessionProvider = session.turns.flatMap(t => t.assistantCalls)[0]?.provider
if (sessionProvider) {
const slice = ensureSlice(sessionDay, sessionProvider)
slice.sessions! += 1
ensureProject(slice, session.project, project.projectPath).sessions += 1
}

for (const turn of session.turns) {
if (turn.assistantCalls.length === 0) continue
Expand Down Expand Up @@ -68,6 +102,30 @@ export function aggregateProjectsIntoDays(projects: ProjectSummary[]): DailyEntr
cat.oneShotTurns += oneShotTurns
turnDay.categories[turn.category] = cat

// Slice-level turn stats are attributed per provider actually present
// in the turn, each with only ITS calls' cost — a slice's category
// totals must never contain another provider's spend, or a later
// carry-forward of that slice would overstate the day.
const providersInTurn = new Map<string, { cost: number; savingsUSD: number }>()
for (const call of turn.assistantCalls) {
const acc = providersInTurn.get(call.provider) ?? { cost: 0, savingsUSD: 0 }
acc.cost += call.costUSD
acc.savingsUSD += call.savingsUSD ?? 0
providersInTurn.set(call.provider, acc)
}
for (const [prov, totals] of providersInTurn) {
const turnSlice = ensureSlice(turnDay, prov)
turnSlice.editTurns! += editTurns
turnSlice.oneShotTurns! += oneShotTurns
const sliceCat = turnSlice.categories![turn.category] ?? { turns: 0, cost: 0, savingsUSD: 0, editTurns: 0, oneShotTurns: 0 }
sliceCat.turns += 1
sliceCat.cost += totals.cost
sliceCat.savingsUSD += totals.savingsUSD
sliceCat.editTurns += editTurns
sliceCat.oneShotTurns += oneShotTurns
turnSlice.categories![turn.category] = sliceCat
}

for (const call of turn.assistantCalls) {
const callSavings = call.savingsUSD ?? 0

Expand All @@ -79,6 +137,11 @@ export function aggregateProjectsIntoDays(projects: ProjectSummary[]): DailyEntr
turnDay.cacheReadTokens += call.usage.cacheReadInputTokens
turnDay.cacheWriteTokens += call.usage.cacheCreationInputTokens

const dayProject = ensureProject(turnDay, session.project, project.projectPath)
dayProject.cost += call.costUSD
dayProject.calls += 1
dayProject.savingsUSD += callSavings

const model = turnDay.models[call.model] ?? {
calls: 0, cost: 0, savingsUSD: 0,
inputTokens: 0, outputTokens: 0,
Expand All @@ -93,11 +156,33 @@ export function aggregateProjectsIntoDays(projects: ProjectSummary[]): DailyEntr
model.cacheWriteTokens += call.usage.cacheCreationInputTokens
turnDay.models[call.model] = model

const provider = turnDay.providers[call.provider] ?? { calls: 0, cost: 0, savingsUSD: 0 }
provider.calls += 1
provider.cost += call.costUSD
provider.savingsUSD += callSavings
turnDay.providers[call.provider] = provider
const slice = ensureSlice(turnDay, call.provider)
slice.calls += 1
slice.cost += call.costUSD
slice.savingsUSD += callSavings
slice.inputTokens! += call.usage.inputTokens
slice.outputTokens! += call.usage.outputTokens
slice.cacheReadTokens! += call.usage.cacheReadInputTokens
slice.cacheWriteTokens! += call.usage.cacheCreationInputTokens

const sliceProject = ensureProject(slice, session.project, project.projectPath)
sliceProject.cost += call.costUSD
sliceProject.calls += 1
sliceProject.savingsUSD += callSavings

const sliceModel = slice.models![call.model] ?? {
calls: 0, cost: 0, savingsUSD: 0,
inputTokens: 0, outputTokens: 0,
cacheReadTokens: 0, cacheWriteTokens: 0,
}
sliceModel.calls += 1
sliceModel.cost += call.costUSD
sliceModel.savingsUSD += callSavings
sliceModel.inputTokens += call.usage.inputTokens
sliceModel.outputTokens += call.usage.outputTokens
sliceModel.cacheReadTokens += call.usage.cacheReadInputTokens
sliceModel.cacheWriteTokens += call.usage.cacheCreationInputTokens
slice.models![call.model] = sliceModel
}
}
}
Expand Down
133 changes: 102 additions & 31 deletions src/usage-aggregator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { aggregateProjectsIntoDays, buildPeriodDataFromDays } from './day-aggreg
import { aggregateModelEfficiency } from './model-efficiency.js'
import { aggregateModels } from './models-report.js'
import { scanAndDetect } from './optimize.js'
import { getDaysInRange, ensureCacheHydrated, loadDailyCache, emptyCache, BACKFILL_DAYS, toDateString, type DailyCache } from './daily-cache.js'
import { getDaysInRange, ensureCacheHydrated, loadDailyCache, emptyCache, BACKFILL_DAYS, toDateString, type DailyCache, type DailyEntry } from './daily-cache.js'
import { buildGranularHistory } from './granular-history.js'

export function buildPeriodData(label: string, projects: ProjectSummary[]): PeriodData {
Expand Down Expand Up @@ -212,6 +212,11 @@ export async function buildMenubarPayloadForRange(periodInfo: PeriodInfo, opts:
let scanProjects!: ProjectSummary[]
let scanRange!: DateRange
let cache: DailyCache = emptyCache()
/// The exact day set behind the all-provider headline (cache-backed
/// historical days + today's live days, day-filtered). Non-null only on the
/// 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 todayProviderData: PeriodData | null = null
let claudeConfigs: ClaudeConfigSelector | undefined
const requestedClaudeConfigSourceId = opts.claudeConfigSourceId?.trim() || null
Expand Down Expand Up @@ -272,6 +277,7 @@ export async function buildMenubarPayloadForRange(periodInfo: PeriodInfo, opts:
const todayInRange = todayDays.filter(d => d.date >= rangeStartStr && d.date <= rangeEndStr)
const unfilteredDays = [...historicalDays, ...todayInRange].sort((a, b) => a.date.localeCompare(b.date))
const allDays = daysSelection ? unfilteredDays.filter(d => daysSelection.days.has(d.date)) : unfilteredDays
cacheDaysForPeriod = allDays
currentData = buildPeriodDataFromDays(allDays, periodInfo.label)
} else {
cache = await loadDailyCache()
Expand All @@ -284,11 +290,38 @@ export async function buildMenubarPayloadForRange(periodInfo: PeriodInfo, opts:
}
}
if (isAllProviders) {
// Load-bearing overwrite: the daily-cache path above never carries
// estimatedCostUSD (DailyEntry has no such field), so this fresh-parse
// rebuild is what keeps the estimated marker alive on cached periods.
// Removing it as redundant silently drops the flag.
currentData = buildPeriodData(periodInfo.label, scanProjects)
const scanData = buildPeriodData(periodInfo.label, scanProjects)
if (cacheDaysForPeriod === null) {
// Scoped all-provider path (claude-config source selected): the scan IS
// the authority, as before.
currentData = scanData
} else {
// The daily-cache path is the authority for headline totals: it includes
// carried days whose session files no longer exist, which the fresh
// parse cannot see. Replacing it wholesale with the scan (the previous
// behavior) silently truncated cost/calls/models/categories to the
// source-retention window. The scan contributes ONLY what DailyEntry
// genuinely lacks: the estimated-cost markers and unpriced-model
// detection, both derivable solely from surviving sessions.
currentData.estimatedCostUSD = scanData.estimatedCostUSD
currentData.unpricedModels = scanData.unpricedModels
// Sessions: the cache buckets a session on its START day, the scan
// counts it on any day it was ACTIVE. Each undercounts differently
// (day-filtered views miss midnight-spanners; the scan misses expired
// sessions). Both count distinct real sessions, so max is the tightest
// safe bound and never double counts.
currentData.sessions = Math.max(currentData.sessions, scanData.sessions)
const estimatedByModel = new Map(
scanData.models
.filter(m => m.estimatedCostUSD != null)
.map(m => [m.name, m.estimatedCostUSD!]),
)
if (estimatedByModel.size > 0) {
currentData.models = currentData.models.map(m =>
estimatedByModel.has(m.name) ? { ...m, estimatedCostUSD: estimatedByModel.get(m.name) } : m,
)
}
}
}
claudeConfigs = claudeConfigs ?? await claudeConfigSelector(scanProjects, null)

Expand Down Expand Up @@ -402,33 +435,71 @@ export async function buildMenubarPayloadForRange(periodInfo: PeriodInfo, opts:
}

const home = homedir()
const friendlyProject = (p: ProjectSummary) => {
const resolved = p.projectPath || p.project
if (resolved === home || resolved === home + '/') return 'Home'
return resolved.split('/').filter(Boolean).pop() || p.project
const friendlyFromPath = (path: string | undefined, fallback: string): string => {
if (!path) return fallback
if (path === home || path === home + '/') return 'Home'
return path.split('/').filter(Boolean).pop() || fallback
}
const friendlyProject = (p: ProjectSummary) => friendlyFromPath(p.projectPath || p.project, p.project)
const sessionDetailsOf = (p: ProjectSummary) => [...p.sessions]
.sort((a, b) => b.totalCostUSD - a.totalCostUSD)
.slice(0, 10)
.map(s => ({
cost: s.totalCostUSD,
savingsUSD: s.totalSavingsUSD,
calls: s.apiCalls,
inputTokens: s.totalInputTokens,
outputTokens: s.totalOutputTokens,
date: s.firstTimestamp?.split('T')[0] ?? '',
models: Object.entries(s.modelBreakdown)
.map(([name, m]) => ({ name, cost: m.costUSD, savingsUSD: m.savingsUSD }))
.sort((a, b) => b.cost - a.cost)
.slice(0, 3),
}))

currentData.projects = scanProjects.map(p => ({
name: friendlyProject(p),
cost: p.totalCostUSD,
savingsUSD: p.totalSavingsUSD,
sessions: p.sessions.length,
sessionDetails: [...p.sessions]
.sort((a, b) => b.totalCostUSD - a.totalCostUSD)
.slice(0, 10)
.map(s => ({
cost: s.totalCostUSD,
savingsUSD: s.totalSavingsUSD,
calls: s.apiCalls,
inputTokens: s.totalInputTokens,
outputTokens: s.totalOutputTokens,
date: s.firstTimestamp?.split('T')[0] ?? '',
models: Object.entries(s.modelBreakdown)
.map(([name, m]) => ({ name, cost: m.costUSD, savingsUSD: m.savingsUSD }))
.sort((a, b) => b.cost - a.cost)
.slice(0, 3),
})),
}))
if (cacheDaysForPeriod !== null) {
// Project totals come from the SAME day set as the headline, so carried
// days count here too. The surviving-session parse contributes only what
// day entries cannot: the per-session drill-down and a fresher project
// path. 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, not a bug.
type CachedProjectTotal = { cost: number; savingsUSD: number; sessions: number; path?: string }
const cachedTotals = new Map<string, CachedProjectTotal>()
for (const d of cacheDaysForPeriod) {
for (const [name, p] of Object.entries(d.projects ?? {})) {
const acc = cachedTotals.get(name) ?? { cost: 0, savingsUSD: 0, sessions: 0 }
acc.cost += p.cost
acc.savingsUSD += p.savingsUSD
acc.sessions += p.sessions
if (!acc.path && p.path) acc.path = p.path
cachedTotals.set(name, acc)
}
}
const liveByName = new Map(scanProjects.map(p => [p.project, p]))
const names = new Set([...cachedTotals.keys(), ...liveByName.keys()])
currentData.projects = [...names].map(name => {
const cached = cachedTotals.get(name)
const live = liveByName.get(name)
return {
name: live ? friendlyProject(live) : friendlyFromPath(cached?.path, name),
cost: cached?.cost ?? live!.totalCostUSD,
savingsUSD: cached?.savingsUSD ?? live!.totalSavingsUSD,
// max for the same reason as the headline: start-day bucketing vs
// active-day counting, both lower bounds of distinct sessions.
sessions: Math.max(cached?.sessions ?? 0, live?.sessions.length ?? 0),
...(live ? { sessionDetails: sessionDetailsOf(live) } : {}),
}
}).sort((a, b) => b.cost - a.cost)
} else {
currentData.projects = scanProjects.map(p => ({
name: friendlyProject(p),
cost: p.totalCostUSD,
savingsUSD: p.totalSavingsUSD,
sessions: p.sessions.length,
sessionDetails: sessionDetailsOf(p),
}))
}

const effMap = aggregateModelEfficiency(scanProjects)
currentData.modelEfficiency = [...effMap.entries()].map(([name, eff]) => ({
Expand Down
Loading