From 0f55a446dad954f2c535bd8e78c4e4a720b03283 Mon Sep 17 00:00:00 2001 From: Resham Joshi <65915470+iamtoruk@users.noreply.github.com> Date: Wed, 20 May 2026 04:16:48 -0700 Subject: [PATCH] Fix per-provider data loss, history regression, and decode fragility (#362) * Fix per-provider data loss, division-by-zero, and decode fragility - Per-provider multi-day queries only merged cost/calls from cache, dropping categories/models/sessions/tokens. Remove broken cache shortcut and always do full parse for per-provider periods. - Remove per-provider daily history double-counting from overlapping cache + live data. - Guard maxCost against zero in ActivitySection and ModelsSection to prevent NaN in bar width calculations. - Use offset-based ForEach ID in BarTooltipCard to avoid duplicate model name collisions. - Make cacheHitPercent, topActivities, topModels, providers use decodeIfPresent for backward compat with older CLI versions. - Skip currency switch when FX rate fetch fails with no cache, preventing rate/symbol desync. - Use readSessionFile in Gemini parser for 128MB size cap. - Truncate Codex userMessage to 500 chars like other providers. * Restore cache-backed trend history for provider-filtered views The previous commit removed the broken per-provider cache shortcut but also dropped cache-backed daily history, causing provider-filtered views to lose trend data outside the selected period range. Use allCacheDays for historical days (cost/calls per provider is accurate in cache) and today's entry from the full parse. No overlap since cache ends at yesterday. --- .../CodeBurnMenubar/Data/MenubarPayload.swift | 8 +- .../Views/ActivitySection.swift | 2 +- .../Views/HeatmapSection.swift | 2 +- .../Views/MenuBarContent.swift | 6 +- .../CodeBurnMenubar/Views/ModelsSection.swift | 2 +- src/main.ts | 95 +++++-------------- src/providers/codex.ts | 2 +- src/providers/gemini.ts | 11 +-- 8 files changed, 39 insertions(+), 89 deletions(-) diff --git a/mac/Sources/CodeBurnMenubar/Data/MenubarPayload.swift b/mac/Sources/CodeBurnMenubar/Data/MenubarPayload.swift index 8ca9fa1..4a374c8 100644 --- a/mac/Sources/CodeBurnMenubar/Data/MenubarPayload.swift +++ b/mac/Sources/CodeBurnMenubar/Data/MenubarPayload.swift @@ -122,10 +122,10 @@ extension CurrentBlock { oneShotRate = try c.decodeIfPresent(Double.self, forKey: .oneShotRate) inputTokens = try c.decode(Int.self, forKey: .inputTokens) outputTokens = try c.decode(Int.self, forKey: .outputTokens) - cacheHitPercent = try c.decode(Double.self, forKey: .cacheHitPercent) - topActivities = try c.decode([ActivityEntry].self, forKey: .topActivities) - topModels = try c.decode([ModelEntry].self, forKey: .topModels) - providers = try c.decode([String: Double].self, forKey: .providers) + cacheHitPercent = try c.decodeIfPresent(Double.self, forKey: .cacheHitPercent) ?? 0 + topActivities = try c.decodeIfPresent([ActivityEntry].self, forKey: .topActivities) ?? [] + topModels = try c.decodeIfPresent([ModelEntry].self, forKey: .topModels) ?? [] + providers = try c.decodeIfPresent([String: Double].self, forKey: .providers) ?? [:] topProjects = try c.decodeIfPresent([ProjectEntry].self, forKey: .topProjects) ?? [] modelEfficiency = try c.decodeIfPresent([ModelEfficiencyEntry].self, forKey: .modelEfficiency) ?? [] topSessions = try c.decodeIfPresent([TopSessionEntry].self, forKey: .topSessions) ?? [] diff --git a/mac/Sources/CodeBurnMenubar/Views/ActivitySection.swift b/mac/Sources/CodeBurnMenubar/Views/ActivitySection.swift index 9803387..3189717 100644 --- a/mac/Sources/CodeBurnMenubar/Views/ActivitySection.swift +++ b/mac/Sources/CodeBurnMenubar/Views/ActivitySection.swift @@ -20,7 +20,7 @@ struct ActivitySection: View { } ) { VStack(alignment: .leading, spacing: 7) { - let maxCost = store.payload.current.topActivities.map(\.cost).max() ?? 1 + let maxCost = max(store.payload.current.topActivities.map(\.cost).max() ?? 1, 0.01) ForEach(store.payload.current.topActivities, id: \.name) { activity in ActivityRow(activity: activity, maxCost: maxCost) } diff --git a/mac/Sources/CodeBurnMenubar/Views/HeatmapSection.swift b/mac/Sources/CodeBurnMenubar/Views/HeatmapSection.swift index b782be3..f5d1270 100644 --- a/mac/Sources/CodeBurnMenubar/Views/HeatmapSection.swift +++ b/mac/Sources/CodeBurnMenubar/Views/HeatmapSection.swift @@ -373,7 +373,7 @@ private struct BarTooltipCard: View { if !bar.topModels.isEmpty { VStack(alignment: .leading, spacing: 3) { - ForEach(Array(bar.topModels.prefix(4).enumerated()), id: \.element.name) { idx, m in + ForEach(Array(bar.topModels.prefix(4).enumerated()), id: \.offset) { idx, m in HStack(spacing: 6) { RoundedRectangle(cornerRadius: 1) .fill(Theme.brandAccent.opacity(0.75 - Double(idx) * 0.12)) diff --git a/mac/Sources/CodeBurnMenubar/Views/MenuBarContent.swift b/mac/Sources/CodeBurnMenubar/Views/MenuBarContent.swift index 7bad14b..9194d00 100644 --- a/mac/Sources/CodeBurnMenubar/Views/MenuBarContent.swift +++ b/mac/Sources/CodeBurnMenubar/Views/MenuBarContent.swift @@ -653,8 +653,10 @@ struct FooterBar: View { } let fresh = await FXRateCache.shared.rate(for: code) - store.currency = code - CurrencyState.shared.apply(code: code, rate: fresh ?? cached, symbol: symbol) + if let rate = fresh ?? cached { + store.currency = code + CurrencyState.shared.apply(code: code, rate: rate, symbol: symbol) + } } CLICurrencyConfig.persist(code: code) diff --git a/mac/Sources/CodeBurnMenubar/Views/ModelsSection.swift b/mac/Sources/CodeBurnMenubar/Views/ModelsSection.swift index cac5457..ce4cf80 100644 --- a/mac/Sources/CodeBurnMenubar/Views/ModelsSection.swift +++ b/mac/Sources/CodeBurnMenubar/Views/ModelsSection.swift @@ -19,7 +19,7 @@ struct ModelsSection: View { } ) { VStack(alignment: .leading, spacing: 7) { - let maxCost = store.payload.current.topModels.map(\.cost).max() ?? 1 + let maxCost = max(store.payload.current.topModels.map(\.cost).max() ?? 1, 0.01) ForEach(store.payload.current.topModels, id: \.name) { model in ModelRow(model: model, maxCost: maxCost) } diff --git a/src/main.ts b/src/main.ts index a6b509e..ec0d900 100644 --- a/src/main.ts +++ b/src/main.ts @@ -467,7 +467,6 @@ program let scanRange: DateRange let cache: DailyCache let todayProviderData: PeriodData | null = null - let usedPerProviderCachePath = false if (isAllProviders) { cache = await hydrateCache() @@ -487,32 +486,11 @@ program } } else { cache = await loadDailyCache() - const cacheIsCurrent = cache.lastComputedDate !== null - && cache.lastComputedDate >= yesterdayStr - if (cacheIsCurrent && rangeStartStr < todayStr) { - const todayProviderProjects = fp(await parseAllSessions(todayRange, pf)) - todayProviderData = buildPeriodData(periodInfo.label, todayProviderProjects) - const historicalDays = getDaysInRange(cache, rangeStartStr, yesterdayStr) - let histCost = 0, histCalls = 0 - for (const d of historicalDays) { - const prov = d.providers[pf] - if (prov) { histCost += prov.cost; histCalls += prov.calls } - } - currentData = { - ...todayProviderData, - cost: todayProviderData.cost + histCost, - calls: todayProviderData.calls + histCalls, - } - scanProjects = todayProviderProjects - scanRange = todayRange - usedPerProviderCachePath = true - } else { - const fullProjects = fp(await parseAllSessions(periodInfo.range, pf)) - todayProviderData = buildPeriodData(periodInfo.label, fullProjects) - currentData = todayProviderData - scanProjects = fullProjects - scanRange = periodInfo.range - } + const fullProjects = fp(await parseAllSessions(periodInfo.range, pf)) + todayProviderData = buildPeriodData(periodInfo.label, fullProjects) + currentData = todayProviderData + scanProjects = fullProjects + scanRange = periodInfo.range } // PROVIDERS @@ -579,7 +557,8 @@ program topModels, } }) - } else if (usedPerProviderCachePath) { + } else { + const emptyModels = [] as { name: string; cost: number; calls: number; inputTokens: number; outputTokens: number }[] const historyFromCache = allCacheDays.map(d => { const prov = d.providers[pf] ?? { calls: 0, cost: 0 } return { @@ -590,53 +569,25 @@ program outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, - topModels: [] as { name: string; cost: number; calls: number; inputTokens: number; outputTokens: number }[], + topModels: emptyModels, } }) - const todayCost = todayProviderData!.cost - const todayCalls = todayProviderData!.calls - if (todayCost > 0 || todayCalls > 0) { - historyFromCache.push({ - date: todayStr, - cost: todayCost, - calls: todayCalls, - inputTokens: 0, - outputTokens: 0, - cacheReadTokens: 0, - cacheWriteTokens: 0, - topModels: [], + const todayFromParse = aggregateProjectsIntoDays(scanProjects) + .filter(d => d.date === todayStr) + .map(d => { + const prov = d.providers[pf] ?? { calls: 0, cost: 0 } + return { + date: d.date, + cost: prov.cost, + calls: prov.calls, + inputTokens: 0, + outputTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0, + topModels: emptyModels, + } }) - } - dailyHistory = historyFromCache - } else { - const histFromCache = allCacheDays.map(d => { - const prov = d.providers[pf] ?? { calls: 0, cost: 0 } - return { - date: d.date, - cost: prov.cost, - calls: prov.calls, - inputTokens: 0, - outputTokens: 0, - cacheReadTokens: 0, - cacheWriteTokens: 0, - topModels: [] as { name: string; cost: number; calls: number; inputTokens: number; outputTokens: number }[], - } - }) - const fallbackDays = aggregateProjectsIntoDays(scanProjects) - const liveDays = fallbackDays.map(d => { - const prov = d.providers[pf] ?? { calls: 0, cost: 0 } - return { - date: d.date, - cost: prov.cost, - calls: prov.calls, - inputTokens: 0, - outputTokens: 0, - cacheReadTokens: 0, - cacheWriteTokens: 0, - topModels: [] as { name: string; cost: number; calls: number; inputTokens: number; outputTokens: number }[], - } - }) - dailyHistory = [...histFromCache, ...liveDays] + dailyHistory = [...historyFromCache, ...todayFromParse] } const home = homedir() diff --git a/src/providers/codex.ts b/src/providers/codex.ts index ca20deb..3d26f95 100644 --- a/src/providers/codex.ts +++ b/src/providers/codex.ts @@ -370,7 +370,7 @@ function createParser(source: SessionSource, seenKeys: Set): SessionPars .filter(c => c.type === 'input_text') .map(c => c.text ?? '') .filter(Boolean) - if (texts.length > 0) pendingUserMessage = texts.join(' ') + if (texts.length > 0) pendingUserMessage = texts.join(' ').slice(0, 500) continue } diff --git a/src/providers/gemini.ts b/src/providers/gemini.ts index 2ea71d4..b8e1ce0 100644 --- a/src/providers/gemini.ts +++ b/src/providers/gemini.ts @@ -1,7 +1,8 @@ -import { readdir, readFile, stat } from 'fs/promises' +import { readdir, stat } from 'fs/promises' import { join } from 'path' import { homedir } from 'os' +import { readSessionFile } from '../fs-utils.js' import { calculateCost } from '../models.js' import { extractBashCommands } from '../bash-utils.js' import type { Provider, SessionSource, SessionParser, ParsedProviderCall } from './types.js' @@ -185,12 +186,8 @@ function parseJsonl(raw: string): GeminiSession | null { function createParser(source: SessionSource, seenKeys: Set): SessionParser { return { async *parse(): AsyncGenerator { - let raw: string - try { - raw = await readFile(source.path, 'utf-8') - } catch { - return - } + const raw = await readSessionFile(source.path) + if (raw === null) return let data: GeminiSession | null = null