diff --git a/src/dashboard.tsx b/src/dashboard.tsx index 97ed839..3b31a43 100644 --- a/src/dashboard.tsx +++ b/src/dashboard.tsx @@ -7,6 +7,7 @@ import { formatCost, formatTokens, markEstimated } from './format.js' import { aggregateModelEfficiency } from './model-efficiency.js' import { parseAllSessions, filterProjectsByDateRange, filterProjectsByName, setInteractiveScanUI } from './parser.js' import { findUnpricedModels, loadPricing } from './models.js' +import { buildDurablePeriod } from './usage-aggregator.js' import { getAllProviders } from './providers/index.js' import { scanAndDetect, type WasteFinding, type WasteAction, type OptimizeResult } from './optimize.js' import { estimateContextBudget, type ContextBudget } from './context-budget.js' @@ -127,6 +128,46 @@ function getPeriodRange(period: Period): { start: Date; end: Date } { return getDateRange(period).range } +/// The durable headline totals the Overview panel renders. Sourced from the +/// carry-forward daily cache (via buildDurablePeriod) so the dashboard's top- +/// line cost/calls/tokens match the menubar and report exactly, including days +/// whose session files have expired. +export type DurableOverview = { + cost: number + savingsUSD: number + calls: number + sessions: number + inputTokens: number + outputTokens: number + cacheReadTokens: number + cacheWriteTokens: number +} + +async function computeDurableOverview( + period: Period, + provider: string, + projectFilter: string[] | undefined, + excludeFilter: string[] | undefined, + customRange: DateRange | null | undefined, + day: string | null, +): Promise { + const range = day ? getDayRange(day) : customRange ?? getPeriodRange(period) + const { data } = await buildDurablePeriod( + { range, label: PERIOD_LABELS[period] }, + { provider, project: projectFilter ?? [], exclude: excludeFilter ?? [] }, + ) + return { + cost: data.cost, + savingsUSD: data.savingsUSD, + calls: data.calls, + sessions: data.sessions, + inputTokens: data.inputTokens, + outputTokens: data.outputTokens, + cacheReadTokens: data.cacheReadTokens, + cacheWriteTokens: data.cacheWriteTokens, + } +} + function getDayRange(day: string): DateRange { return parseDayFlag(day)!.range } @@ -229,16 +270,19 @@ function planStatusText(planUsage: PlanUsage): string { return `${(planUsage.spentApiEquivalentUsd / Math.max(planUsage.budgetUsd, 1)).toFixed(1)}x your subscription value. Projected month: ${formatCost(planUsage.projectedMonthUsd)} (reset in ${planUsage.daysUntilReset} days).` } -function Overview({ projects, label, width, planUsages }: { projects: ProjectSummary[]; label: string; width: number; planUsages?: PlanUsage[] }) { - const totalCost = projects.reduce((s, p) => s + p.totalCostUSD, 0) - const totalSavings = projects.reduce((s, p) => s + p.totalSavingsUSD, 0) - const totalCalls = projects.reduce((s, p) => s + p.totalApiCalls, 0) - const totalSessions = projects.reduce((s, p) => s + p.sessions.length, 0) +function Overview({ projects, label, width, planUsages, durable }: { projects: ProjectSummary[]; label: string; width: number; planUsages?: PlanUsage[]; durable?: DurableOverview }) { + // Headline totals prefer the durable daily cache (carried, expired-source days + // included) so they match the menubar and report; the live parse is the + // fallback until the durable figures land / for panels below. + const totalCost = durable ? durable.cost : projects.reduce((s, p) => s + p.totalCostUSD, 0) + const totalSavings = durable ? durable.savingsUSD : projects.reduce((s, p) => s + p.totalSavingsUSD, 0) + const totalCalls = durable ? durable.calls : projects.reduce((s, p) => s + p.totalApiCalls, 0) + const totalSessions = durable ? durable.sessions : projects.reduce((s, p) => s + p.sessions.length, 0) const allSessions = projects.flatMap(p => p.sessions) - const totalInput = allSessions.reduce((s, sess) => s + sess.totalInputTokens, 0) - const totalOutput = allSessions.reduce((s, sess) => s + sess.totalOutputTokens, 0) - const totalCacheRead = allSessions.reduce((s, sess) => s + sess.totalCacheReadTokens, 0) - const totalCacheWrite = allSessions.reduce((s, sess) => s + sess.totalCacheWriteTokens, 0) + const totalInput = durable ? durable.inputTokens : allSessions.reduce((s, sess) => s + sess.totalInputTokens, 0) + const totalOutput = durable ? durable.outputTokens : allSessions.reduce((s, sess) => s + sess.totalOutputTokens, 0) + const totalCacheRead = durable ? durable.cacheReadTokens : allSessions.reduce((s, sess) => s + sess.totalCacheReadTokens, 0) + const totalCacheWrite = durable ? durable.cacheWriteTokens : allSessions.reduce((s, sess) => s + sess.totalCacheWriteTokens, 0) const allInputTokens = totalInput + totalCacheRead + totalCacheWrite const cacheHit = allInputTokens > 0 ? (totalCacheRead / allInputTokens) * 100 : 0 @@ -806,7 +850,7 @@ function Row({ wide, width, children }: { wide: boolean; width: number; children return <>{children} } -function DashboardContent({ projects, period, columns, activeProvider, budgets, planUsages, label, dayMode, dailyHistoryProjects, scrollableDailyHistory = false, dailyHistoryCursor = 0, dailyHistoryLoading = false }: { projects: ProjectSummary[]; period: Period; columns?: number; activeProvider?: string; budgets?: Map; planUsages?: PlanUsage[]; label?: string; dayMode?: boolean; dailyHistoryProjects?: ProjectSummary[]; scrollableDailyHistory?: boolean; dailyHistoryCursor?: number; dailyHistoryLoading?: boolean }) { +function DashboardContent({ projects, period, columns, activeProvider, budgets, planUsages, label, dayMode, dailyHistoryProjects, scrollableDailyHistory = false, dailyHistoryCursor = 0, dailyHistoryLoading = false, durable }: { projects: ProjectSummary[]; period: Period; columns?: number; activeProvider?: string; budgets?: Map; planUsages?: PlanUsage[]; label?: string; dayMode?: boolean; dailyHistoryProjects?: ProjectSummary[]; scrollableDailyHistory?: boolean; dailyHistoryCursor?: number; dailyHistoryLoading?: boolean; durable?: DurableOverview }) { const { dashWidth, wide, halfWidth, barWidth } = getLayout(columns) const isCursor = activeProvider === 'cursor' const activeLabel = label ?? PERIOD_LABELS[period] @@ -820,7 +864,7 @@ function DashboardContent({ projects, period, columns, activeProvider, budgets, const visiblePlanUsages = (planUsages ?? []).filter(p => p.plan.provider === (activeProvider ?? 'all')) return ( - + {isCursor ? ( @@ -832,12 +876,13 @@ function DashboardContent({ projects, period, columns, activeProvider, budgets, ) } -function InteractiveDashboard({ initialProjects, initialDailyHistoryProjects, initialPeriod, initialProvider, initialPlanUsages, refreshSeconds, projectFilter, excludeFilter, customRange, customRangeLabel, initialDay }: { +function InteractiveDashboard({ initialProjects, initialDailyHistoryProjects, initialPeriod, initialProvider, initialPlanUsages, initialDurable, refreshSeconds, projectFilter, excludeFilter, customRange, customRangeLabel, initialDay }: { initialProjects: ProjectSummary[] initialDailyHistoryProjects?: ProjectSummary[] initialPeriod: Period initialProvider: string initialPlanUsages?: PlanUsage[] + initialDurable?: DurableOverview refreshSeconds?: number projectFilter?: string[] excludeFilter?: string[] @@ -848,6 +893,7 @@ function InteractiveDashboard({ initialProjects, initialDailyHistoryProjects, in const { exit } = useApp() const [period, setPeriod] = useState(initialPeriod) const [projects, setProjects] = useState(initialProjects) + const [durable, setDurable] = useState(initialDurable) const [loading, setLoading] = useState(false) const [activeProvider, setActiveProvider] = useState(initialProvider) const [detectedProviders, setDetectedProviders] = useState([]) @@ -932,6 +978,9 @@ function InteractiveDashboard({ initialProjects, initialDailyHistoryProjects, in if (!day && isHeavyPeriod(p)) { setProjects([]) setProjectBudgets(new Map()) + // Drop the previous period's durable headline so it can't flash on the + // new tab before the fresh figure lands. + setDurable(undefined) await nextTick() if (reloadGenerationRef.current !== generation) return } @@ -944,6 +993,12 @@ function InteractiveDashboard({ initialProjects, initialDailyHistoryProjects, in if (shouldLoadHistory) setDailyHistoryProjects(filteredProjects) setProjects(selectDashboardPeriodProjects(filteredProjects, p, shouldLoadHistory)) + // Durable headline totals (carry-forward cache + today), matching the + // menubar/report. Computed after the live parse so the panel paints + // immediately; the durable figure replaces the live one when it resolves. + const durableTotals = await computeDurableOverview(p, prov, projectFilter, excludeFilter, customRange, day) + if (reloadGenerationRef.current !== generation) return + setDurable(durableTotals) const usage = await getPlanUsages() if (reloadGenerationRef.current !== generation) return setPlanUsages(usage) @@ -1142,7 +1197,7 @@ function InteractiveDashboard({ initialProjects, initialDailyHistoryProjects, in ? setView('dashboard')} /> : view === 'optimize' && optimizeResult ? - : } + : } {view !== 'compare' && } ) @@ -1165,13 +1220,13 @@ function CustomRangeBanner({ label, width }: { label: string; width: number }) { ) } -function StaticDashboard({ projects, period, activeProvider, planUsages, label, dayMode }: { projects: ProjectSummary[]; period: Period; activeProvider?: string; planUsages?: PlanUsage[]; label?: string; dayMode?: boolean }) { +function StaticDashboard({ projects, period, activeProvider, planUsages, label, dayMode, durable }: { projects: ProjectSummary[]; period: Period; activeProvider?: string; planUsages?: PlanUsage[]; label?: string; dayMode?: boolean; durable?: DurableOverview }) { const { columns } = useWindowSize() const { dashWidth } = getLayout(columns) return ( {dayMode ? : } - + ) } @@ -1190,15 +1245,19 @@ export async function renderDashboard(period: Period = 'week', provider: string const scannedProjects = filterProjectsByName(await parseAllSessions(range, provider), projectFilter, excludeFilter) const filteredProjects = selectDashboardPeriodProjects(scannedProjects, period, scrollableDailyHistory) const planUsages = await getPlanUsages() + // Durable headline totals for the initial paint (carry-forward cache + today), + // matching the menubar/report. The interactive tree recomputes this on every + // period/provider/refresh change; the static one-shot render uses just this. + const initialDurable = await computeDurableOverview(period, provider, projectFilter, excludeFilter, customRange, initialDay ?? null) const label = initialDay ? formatDayRangeLabel(initialDay) : customRangeLabel patchStdoutForWindows() if (isTTY) { const { waitUntilExit } = render( - + ) await waitUntilExit() } else { - const { unmount } = render(, { patchConsole: false }) + const { unmount } = render(, { patchConsole: false }) // Non-interactive one-shot output: ink schedules the frame through a // throttled render, so yield a tick to let it flush to stdout before // unmounting. Unmounting synchronously can race the flush and drop output. diff --git a/src/format.ts b/src/format.ts index b50d478..4ba2734 100644 --- a/src/format.ts +++ b/src/format.ts @@ -36,12 +36,28 @@ function localDateString(d: Date): string { return `${y}-${m}-${day}` } -export function renderStatusBar(projects: ProjectSummary[]): string { +/// Precomputed today/month totals from the durable daily cache. When supplied, +/// the status bar renders these instead of bucketing the live parse, so the +/// figures match the menubar exactly (carried, expired-source days included). +export type StatusBarTotals = { + today: { cost: number; calls: number } + month: { cost: number; calls: number } +} + +export function renderStatusBar(projects: ProjectSummary[], totals?: StatusBarTotals): string { const now = new Date() const today = localDateString(now) const monthStart = `${today.slice(0, 7)}-01` let todayCost = 0, todayCalls = 0, monthCost = 0, monthCalls = 0 + if (totals) { + todayCost = totals.today.cost; todayCalls = totals.today.calls + monthCost = totals.month.cost; monthCalls = totals.month.calls + const lines: string[] = [''] + lines.push(` ${chalk.bold('Today')} ${chalk.yellowBright(formatCost(todayCost))} ${chalk.dim(`${todayCalls} calls`)} ${chalk.bold('Month')} ${chalk.yellowBright(formatCost(monthCost))} ${chalk.dim(`${monthCalls} calls`)}`) + lines.push('') + return lines.join('\n') + } for (const project of projects) { for (const session of project.sessions) { diff --git a/src/main.ts b/src/main.ts index c30c5fa..e2c1a72 100644 --- a/src/main.ts +++ b/src/main.ts @@ -11,7 +11,7 @@ import { toDateString } from './daily-cache.js' import { dateKey } from './day-aggregator.js' import { CATEGORY_LABELS, type DateRange, type ProjectSummary, type TaskCategory } from './types.js' import { aggregateModelEfficiency } from './model-efficiency.js' -import { buildPeriodData, buildMenubarPayloadForRange } from './usage-aggregator.js' +import { buildPeriodData, buildMenubarPayloadForRange, buildDurablePeriod, type DurablePeriod } from './usage-aggregator.js' import { renderDashboard } from './dashboard.js' import { renderOverview } from './overview.js' import { runWebDashboard } from './web-dashboard.js' @@ -413,8 +413,8 @@ function assertScope(value: string, allowed: readonly string[], command: string) async function runJsonReport(period: Period, provider: string, project: string[], exclude: string[]): Promise { await loadPricing() const { range, label } = getDateRange(period) - const projects = filterProjectsByName(await parseAllSessions(range, provider), project, exclude) - const report: ReturnType & { plan?: JsonPlanSummary; plans?: JsonPlanSummaryMap } = await attachPlanSummaries(buildJsonReport(projects, label, period)) + const durable = await buildDurablePeriod({ range, label }, { provider, project, exclude }) + const report: ReturnType & { plan?: JsonPlanSummary; plans?: JsonPlanSummaryMap } = await attachPlanSummaries(buildJsonReport(durable.liveProjects, label, period, durable)) console.log(JSON.stringify(report, null, 2)) } @@ -447,23 +447,27 @@ program.hook('preAction', async (thisCommand) => { await loadCurrency() }) -function buildJsonReport(projects: ProjectSummary[], period: string, periodKey: string) { +function buildJsonReport(projects: ProjectSummary[], period: string, periodKey: string, durable?: DurablePeriod) { const sessions = projects.flatMap(p => p.sessions) const { code } = getCurrency() - const totalCostUSD = projects.reduce((s, p) => s + p.totalCostUSD, 0) - const totalSavingsUSD = projects.reduce((s, p) => s + p.totalSavingsUSD, 0) - const totalEstimatedUSD = projects.reduce((s, p) => s + (p.totalEstimatedCostUSD ?? 0), 0) + // Headline totals come from the durable daily cache (carry-forward days whose + // session files have expired still count), matching the menubar exactly. The + // proxied/net split is a surviving-session concept (subscription attribution + // isn't stored per day), so it stays live; net is taken off the durable total. + const totalCostUSD = durable ? durable.data.cost : projects.reduce((s, p) => s + p.totalCostUSD, 0) + const totalSavingsUSD = durable ? durable.data.savingsUSD : projects.reduce((s, p) => s + p.totalSavingsUSD, 0) + const totalEstimatedUSD = durable ? (durable.data.estimatedCostUSD ?? 0) : projects.reduce((s, p) => s + (p.totalEstimatedCostUSD ?? 0), 0) // Subscription-covered (proxied) portion of totalCostUSD, and the resulting // out-of-pocket figure. `cost` stays the full billable/would-be amount. const totalProxiedUSD = projects.reduce((s, p) => s + p.totalProxiedCostUSD, 0) const netCostUSD = totalCostUSD - totalProxiedUSD - const totalCalls = projects.reduce((s, p) => s + p.totalApiCalls, 0) - const totalSessions = projects.reduce((s, p) => s + p.sessions.length, 0) - const totalInput = sessions.reduce((s, sess) => s + sess.totalInputTokens, 0) - const totalOutput = sessions.reduce((s, sess) => s + sess.totalOutputTokens, 0) - const totalCacheRead = sessions.reduce((s, sess) => s + sess.totalCacheReadTokens, 0) - const totalCacheWrite = sessions.reduce((s, sess) => s + sess.totalCacheWriteTokens, 0) + const totalCalls = durable ? durable.data.calls : projects.reduce((s, p) => s + p.totalApiCalls, 0) + const totalSessions = durable ? durable.data.sessions : projects.reduce((s, p) => s + p.sessions.length, 0) + const totalInput = durable ? durable.data.inputTokens : sessions.reduce((s, sess) => s + sess.totalInputTokens, 0) + const totalOutput = durable ? durable.data.outputTokens : sessions.reduce((s, sess) => s + sess.totalOutputTokens, 0) + const totalCacheRead = durable ? durable.data.cacheReadTokens : sessions.reduce((s, sess) => s + sess.totalCacheReadTokens, 0) + const totalCacheWrite = durable ? durable.data.cacheWriteTokens : sessions.reduce((s, sess) => s + sess.totalCacheWriteTokens, 0) // Match src/menubar-json.ts:cacheHitPercent: reads over reads+fresh-input. cache_write // counts tokens being stored, not served, so it doesn't belong in the denominator. const cacheHitDenom = totalInput + totalCacheRead @@ -499,21 +503,40 @@ function buildJsonReport(projects: ProjectSummary[], period: string, periodKey: } } } - const daily = Object.entries(dailyMap).sort().map(([date, d]) => ({ - date, - cost: convertCost(d.cost), - savings: convertCost(d.savings), - calls: d.calls, - turns: d.turns, - editTurns: d.editTurns, - oneShotTurns: d.oneShotTurns, - // Pre-computed convenience for dashboards that don't want to do the math. - // null when there are no edit turns (the rate is undefined, not zero — - // a day where the user only had Q&A turns shouldn't read as 0% one-shot). - oneShotRate: d.editTurns > 0 - ? Math.round((d.oneShotTurns / d.editTurns) * 1000) / 10 - : null, - })) + // Daily rows come from the same durable day set as the headline so they sum + // to it, carried days included. The live per-turn rollup (dailyMap) is only + // the fallback for callers that pass no durable period. + const daily = durable + ? durable.days.map(d => { + const turns = Object.values(d.categories).reduce((s, c) => s + c.turns, 0) + return { + date: d.date, + cost: convertCost(d.cost), + savings: convertCost(d.savingsUSD), + calls: d.calls, + turns, + editTurns: d.editTurns, + oneShotTurns: d.oneShotTurns, + oneShotRate: d.editTurns > 0 + ? Math.round((d.oneShotTurns / d.editTurns) * 1000) / 10 + : null, + } + }) + : Object.entries(dailyMap).sort().map(([date, d]) => ({ + date, + cost: convertCost(d.cost), + savings: convertCost(d.savings), + calls: d.calls, + turns: d.turns, + editTurns: d.editTurns, + oneShotTurns: d.oneShotTurns, + // Pre-computed convenience for dashboards that don't want to do the math. + // null when there are no edit turns (the rate is undefined, not zero — + // a day where the user only had Q&A turns shouldn't read as 0% one-shot). + oneShotRate: d.editTurns > 0 + ? Math.round((d.oneShotTurns / d.editTurns) * 1000) / 10 + : null, + })) const projectList = projects.map(p => ({ name: p.project, @@ -748,12 +771,8 @@ program const range = daySelection?.range ?? customRange! const label = daySelection?.label ?? formatDateRangeLabel(opts.from, opts.to) const periodKey = daySelection ? 'day' : 'custom' - const projects = filterProjectsByName( - await parseAllSessions(range, opts.provider), - opts.project, - opts.exclude, - ) - console.log(JSON.stringify(await attachPlanSummaries(buildJsonReport(projects, label, periodKey)), null, 2)) + const durable = await buildDurablePeriod({ range, label }, { provider: opts.provider, project: opts.project, exclude: opts.exclude }) + console.log(JSON.stringify(await attachPlanSummaries(buildJsonReport(durable.liveProjects, label, periodKey, durable)), null, 2)) } else { await runJsonReport(period, opts.provider, opts.project, opts.exclude) } @@ -926,12 +945,29 @@ program const { range, label } = customRange ? { range: customRange, label: formatDateRangeLabel(opts.from, opts.to) } : getDateRange(period!) - const projects = filterProjectsByName(await parseAllSessions(range, opts.provider), opts.project, opts.exclude) + const durable = await buildDurablePeriod({ range, label }, { provider: opts.provider, project: opts.project, exclude: opts.exclude }) + const projects = durable.liveProjects const config = await readConfig() const budget = isOverviewBudgetFilterActive(opts) ? undefined : buildOverviewBudget(projects, config.budget, budgetTierForOverview(period, customRange), range) - process.stdout.write(renderOverview(projects, { label, color: opts.color, budget })) + process.stdout.write(renderOverview(projects, { + label, + color: opts.color, + budget, + durable: { + cost: durable.data.cost, + savingsUSD: durable.data.savingsUSD, + calls: durable.data.calls, + sessions: durable.data.sessions, + inputTokens: durable.data.inputTokens, + outputTokens: durable.data.outputTokens, + cacheReadTokens: durable.data.cacheReadTokens, + cacheWriteTokens: durable.data.cacheWriteTokens, + days: durable.days, + carriedCostUSD: durable.carriedCostUSD, + }, + })) }) program @@ -1091,12 +1127,13 @@ program } if (opts.format === 'json') { - const todayProjects = fp(await parseAllSessions(getDateRange('today').range, pf)) - const todayData = buildPeriodData('today', todayProjects) - clearSessionCache() - const monthProjects = fp(await parseAllSessions(getDateRange('month').range, pf)) - const monthData = buildPeriodData('month', monthProjects) - clearSessionCache() + // Durable totals so the compact status matches the menubar / report. + const todayDurable = await buildDurablePeriod(getDateRange('today'), { provider: pf, project: opts.project, exclude: opts.exclude }) + const todayData = todayDurable.data + const todayProjects = todayDurable.liveProjects + const monthDurable = await buildDurablePeriod(getDateRange('month'), { provider: pf, project: opts.project, exclude: opts.exclude }) + const monthData = monthDurable.data + const monthProjects = monthDurable.liveProjects const { code, rate } = getCurrency() const payload: { currency: string @@ -1124,9 +1161,12 @@ program return } - const monthProjects2 = fp(await parseAllSessions(getDateRange('month').range, pf)) - clearSessionCache() - console.log(renderStatusBar(monthProjects2)) + const todayDurable = await buildDurablePeriod(getDateRange('today'), { provider: pf, project: opts.project, exclude: opts.exclude }) + const monthDurable = await buildDurablePeriod(getDateRange('month'), { provider: pf, project: opts.project, exclude: opts.exclude }) + console.log(renderStatusBar([], { + today: { cost: todayDurable.data.cost, calls: todayDurable.data.calls }, + month: { cost: monthDurable.data.cost, calls: monthDurable.data.calls }, + })) }) program diff --git a/src/overview.ts b/src/overview.ts index 43986b8..4881ae9 100644 --- a/src/overview.ts +++ b/src/overview.ts @@ -7,6 +7,7 @@ import { formatCost as baseCost, getCurrency } from './currency.js' import { findUnpricedModels, getShortModelName } from './models.js' import { markEstimated } from './format.js' import { dateKey } from './day-aggregator.js' +import type { DailyEntry } from './daily-cache.js' import type { BudgetStatus, BudgetTier } from './budget.js' // Display-only helpers. The shared formatters omit thousands separators and @@ -83,18 +84,34 @@ function renderTable(c: ChalkInstance, cols: Col[], rows: string[][]): string { ].join('\n') } +/// The durable slice renderOverview needs: headline totals + the day set behind +/// them + how much of the total came from carried (expired-source) days. +export type OverviewDurable = { + cost: number + savingsUSD: number + calls: number + sessions: number + inputTokens: number + outputTokens: number + cacheReadTokens: number + cacheWriteTokens: number + days: DailyEntry[] + carriedCostUSD: number +} + export function renderOverview( projects: ProjectSummary[], - opts: { label: string; color: boolean; budget?: OverviewBudget }, + opts: { label: string; color: boolean; budget?: OverviewBudget; durable?: OverviewDurable }, ): string { const c = new Chalk(opts.color ? {} : { level: 0 }) const heading = (text: string): string => c.cyan.bold(text) const out: string[] = [] + const durable = opts.durable out.push(c.bold('CodeBurn') + c.dim(' ' + opts.label)) out.push('') - if (projects.length === 0) { + if (projects.length === 0 && !(durable && durable.cost > 0)) { out.push(c.dim(`No usage found for ${opts.label}.`)) return out.join('\n') + '\n' } @@ -160,6 +177,29 @@ export function renderOverview( } } + // Headline totals and the day-resolved views (Daily, Highest-value days) come + // from the durable daily cache so they match the menubar exactly, carried + // (expired-source) days included. The per-tool / per-model / per-project + // breakdowns above stay live: they need surviving session detail. + if (durable) { + cost = durable.cost + savings = durable.savingsUSD + calls = durable.calls + sessions = durable.sessions + inTok = durable.inputTokens + outTok = durable.outputTokens + cacheR = durable.cacheReadTokens + cacheW = durable.cacheWriteTokens + byDay.clear() + for (const d of durable.days) { + byDay.set(d.date, { + cost: d.cost, + tokens: d.inputTokens + d.outputTokens + d.cacheReadTokens + d.cacheWriteTokens, + providers: new Set(Object.keys(d.providers)), + }) + } + } + const totalTokens = inTok + outTok + cacheR + cacheW const cacheHitDenom = inTok + cacheR const cacheHit = cacheHitDenom > 0 ? (cacheR / cacheHitDenom) * 100 : 0 @@ -303,5 +343,12 @@ export function renderOverview( const mostly = topTool ? `, mostly ${topTool}${topModel ? ` / ${topModel}` : ''}` : '' out.push(c.dim('Bottom line: ') + `${opts.label} totals ${formatCost(cost)} across ${formatTokens(totalTokens)} tokens${mostly}.`) + // When some of the period's total came from days whose session logs have since + // expired, say so once. The figure is real (preserved in the durable daily + // cache); it just can't be re-derived from surviving files anymore. + if (durable && durable.carriedCostUSD > 0) { + out.push(c.dim(` includes ${formatCost(durable.carriedCostUSD)} preserved from expired session logs`)) + } + return out.join('\n') + '\n' } diff --git a/src/usage-aggregator.ts b/src/usage-aggregator.ts index 75fa6cc..126af72 100644 --- a/src/usage-aggregator.ts +++ b/src/usage-aggregator.ts @@ -11,7 +11,7 @@ import { aggregateModelEfficiency } from './model-efficiency.js' import { aggregateModels } from './models-report.js' import { scanUserCorrections, medianTimeToFirstEditMs, aggregateFileChurn, computePricingCoverage } from './workflow-insights.js' import { scanAndDetect } from './optimize.js' -import { getDaysInRange, ensureCacheHydrated, loadDailyCache, emptyCache, BACKFILL_DAYS, toDateString, type DailyCache, type DailyEntry } from './daily-cache.js' +import { getDaysInRange, ensureCacheHydrated, 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 { @@ -183,6 +183,170 @@ function dailyEntriesToHistory(days: ReturnType | null, +): DailyEntry[] { + const now = new Date() + const yesterdayStr = toDateString(new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1)) + const rangeStartStr = toDateString(periodInfo.range.start) + const rangeEndStr = toDateString(periodInfo.range.end) + const historicalRangeEndStr = rangeEndStr < yesterdayStr ? rangeEndStr : yesterdayStr + const historicalDays = rangeStartStr <= historicalRangeEndStr + ? getDaysInRange(cache, rangeStartStr, historicalRangeEndStr) + : [] + const todayInRange = todayAllDays.filter(d => d.date >= rangeStartStr && d.date <= rangeEndStr) + const unfiltered = [...historicalDays, ...todayInRange].sort((a, b) => a.date.localeCompare(b.date)) + return daysSelection ? unfiltered.filter(d => daysSelection.has(d.date)) : unfiltered +} + +/// The single durable-totals builder every CLI/TUI surface and the menubar share. +/// Headline totals (cost/calls/sessions/tokens/models/categories/savings) come +/// from the carry-forward daily cache unioned with today's live parse and sliced +/// to the requested provider, so a period that includes days whose session files +/// have expired still counts them — the invariant the menubar already relies on. +/// Detail-only fields that day entries can't carry (estimatedCost, unpriced +/// models, workflow intelligence, per-session drill-down) are enriched from a +/// fresh parse of the surviving sessions. +export type DurablePeriod = { + /// Durable headline totals for the period. + data: PeriodData + /// The exact provider-sliced, day-filtered day set behind `data`. Daily rows + /// rendered by report/overview come from here so they reconcile to `data`. + days: DailyEntry[] + /// Sum of `cost` on `carried` days included in the period (footnote source). + carriedCostUSD: number + /// Fresh per-period parse (provider + name filtered) for detail views that + /// still need surviving session files. + liveProjects: ProjectSummary[] + /// Hydrated all-provider cache (reused by the menubar's provider list + daily + /// history sections). + cache: DailyCache + /// Today-only slice, all providers, name-filtered (memo seed for the menubar). + todayAllDays: DailyEntry[] + /// The scan range the live parse covered (today-only when the period is today). + scanRange: DateRange +} + +export async function buildDurablePeriod(periodInfo: PeriodInfo, opts: AggregateOpts = {}): Promise { + const pf = opts.provider ?? 'all' + const daysSelection = opts.daysSelection ?? null + const fp = (p: ProjectSummary[]) => filterProjectsByName(p, opts.project ?? [], opts.exclude ?? []) + + const now = new Date() + const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()) + const todayRange: DateRange = { start: todayStart, end: now } + const todayStr = toDateString(todayStart) + const rangeStartStr = toDateString(periodInfo.range.start) + const rangeEndStr = toDateString(periodInfo.range.end) + const isTodayOnly = rangeStartStr === todayStr && rangeEndStr === todayStr + + const cache = await hydrateCache() + + // Today's live data always comes from an all-provider parse so the union (and + // any per-provider slice of it) sees every provider's today. `todayAllDays` is + // the today bucket only — the union filters the historical remainder out of the + // cache. + let liveProjects: ProjectSummary[] + let todayAllDays: DailyEntry[] + let scanRange: DateRange + if (pf === 'all') { + if (isTodayOnly) { + const raw = fp(await parseAllSessions(todayRange, 'all')) + liveProjects = raw + scanRange = todayRange + todayAllDays = aggregateProjectsIntoDays(raw).filter(d => d.date === todayStr) + } else { + const raw = fp(await parseAllSessions(periodInfo.range, 'all')) + liveProjects = daysSelection ? filterProjectsByDays(raw, daysSelection.days) : raw + scanRange = periodInfo.range + // A period that reaches today contains today's turns already, so derive the + // today slice from the same parse instead of scanning today again. + todayAllDays = rangeEndStr >= todayStr + ? aggregateProjectsIntoDays(raw).filter(d => d.date === todayStr) + : aggregateProjectsIntoDays(fp(await parseAllSessions(todayRange, 'all'))).filter(d => d.date === todayStr) + } + } else { + // Provider-filtered: today's all-provider parse feeds the union (sliced + // below); the provider-scoped parse feeds the detail/enrichment fields. + todayAllDays = aggregateProjectsIntoDays(fp(await parseAllSessions(todayRange, 'all'))).filter(d => d.date === todayStr) + const rawProv = fp(await parseAllSessions(isTodayOnly ? todayRange : periodInfo.range, pf)) + liveProjects = daysSelection && !isTodayOnly ? filterProjectsByDays(rawProv, daysSelection.days) : rawProv + scanRange = isTodayOnly ? todayRange : periodInfo.range + } + + const allDays = unionDaysForPeriod(cache, todayAllDays, periodInfo, daysSelection?.days ?? null) + const days = pf === 'all' ? allDays : allDays.map(d => sliceDayToProvider(d, pf)) + const data = buildPeriodDataFromDays(days, periodInfo.label) + + // Enrich the cache-authoritative headline with fields DailyEntry cannot carry. + // These are all derivable only from surviving sessions (estimated-cost markers, + // unpriced-model detection, per-turn workflow intelligence), so they describe + // the live population, a subset of the carried headline. + const scanData = buildPeriodData(periodInfo.label, liveProjects) + data.estimatedCostUSD = scanData.estimatedCostUSD + data.unpricedModels = scanData.unpricedModels + data.workflow = scanData.workflow + data.topReworkedFiles = scanData.topReworkedFiles + data.pricingCoverage = scanData.pricingCoverage + // Cache buckets a session on its START day, the scan on any ACTIVE day; both + // are lower bounds of distinct sessions, so max is the tightest safe bound. + data.sessions = Math.max(data.sessions, scanData.sessions) + const estimatedByModel = new Map( + scanData.models.filter(m => m.estimatedCostUSD != null).map(m => [m.name, m.estimatedCostUSD!]), + ) + if (estimatedByModel.size > 0) { + data.models = data.models.map(m => + estimatedByModel.has(m.name) ? { ...m, estimatedCostUSD: estimatedByModel.get(m.name) } : m, + ) + } + + const carriedCostUSD = days.reduce((s, d) => s + (d.carried ? d.cost : 0), 0) + return { data, days, carriedCostUSD, liveProjects, cache, todayAllDays, scanRange } +} + /** * Resolved-range aggregation shared by `status --format menubar-json` and the MCP server. * Pricing must already be loaded (callers run loadPricing first). When opts.optimize is @@ -231,7 +395,6 @@ export async function buildMenubarPayloadForRange(periodInfo: PeriodInfo, opts: /// 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 const isClaudeConfigScoped = requestedClaudeConfigSourceId !== null @@ -257,93 +420,23 @@ export async function buildMenubarPayloadForRange(periodInfo: PeriodInfo, opts: // still renders. } if (!effectivelyScoped) { - if (isAllProviders) { - cache = await hydrateCache() - const isTodayOnly = rangeStartStr === todayStr && rangeEndStr === todayStr - // Single all-provider scan for the requested window. Previously the overview - // parsed every provider TWICE — once over todayRange and once over the full - // period — even though the period scan already contains today's turns. Scan - // once here and derive the "today" slice from it below: aggregateProjects- - // IntoDays buckets by each turn's own timestamp, so a period scan's today - // slice is identical to a dedicated today scan's. A purely historical window - // (ends before today) never reaches today, so its separate today scan — the - // one the always-on daily-history strip needs — is left to run. - let rawScan: ProjectSummary[] - if (isTodayOnly) { - rawScan = fp(await parseAllSessions(todayRange, 'all')) - scanProjects = rawScan - scanRange = todayRange - } else { - rawScan = fp(await parseAllSessions(periodInfo.range, 'all')) - scanProjects = daysSelection ? filterProjectsByDays(rawScan, daysSelection.days) : rawScan - scanRange = periodInfo.range - } - // Seed the today memo from the un-daysSelection rawScan so the always-on - // daily-history strip still shows today even when the heatmap day filter - // excludes it (matching the old dedicated today scan, which ignored it). - if (rangeEndStr >= todayStr) { - todayAllDays = aggregateProjectsIntoDays(rawScan).filter(d => d.date === todayStr) - } - const todayDays = await getTodayAllDays() - const historicalDays = rangeStartStr <= historicalRangeEndStr - ? getDaysInRange(cache, rangeStartStr, historicalRangeEndStr) - : [] - 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() - const rawProviderProjects = fp(await parseAllSessions(periodInfo.range, pf)) - const fullProjects = daysSelection ? filterProjectsByDays(rawProviderProjects, daysSelection.days) : rawProviderProjects - todayProviderData = buildPeriodData(periodInfo.label, fullProjects) - currentData = todayProviderData - scanProjects = fullProjects - scanRange = periodInfo.range - } - } - if (isAllProviders) { - 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 - // Workflow-intelligence metrics are session-derived by nature (they need - // turn text, timestamps, and tool sequences that day entries don't - // carry), so like the fields above they come from the scan. Note - // pricingCoverage therefore describes surviving-session calls, a smaller - // population than the carried headline cost. - currentData.workflow = scanData.workflow - currentData.topReworkedFiles = scanData.topReworkedFiles - currentData.pricingCoverage = scanData.pricingCoverage - // 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, - ) - } - } + // Every non-scoped headline — all-provider AND provider-filtered — is built + // by the one shared durable-totals builder. It unions the carry-forward + // cache with today's live parse (slicing to the provider when filtered), so + // days whose session files have expired still count. The provider list and + // daily-history sections below reuse its cache + today slice. + const durable = await buildDurablePeriod(periodInfo, { + provider: pf, + project: opts.project, + exclude: opts.exclude, + daysSelection, + }) + currentData = durable.data + scanProjects = durable.liveProjects + scanRange = durable.scanRange + cacheDaysForPeriod = durable.days + cache = durable.cache + todayAllDays = durable.todayAllDays } claudeConfigs = claudeConfigs ?? await claudeConfigSelector(scanProjects, null) diff --git a/tests/cli-durable-totals.test.ts b/tests/cli-durable-totals.test.ts new file mode 100644 index 0000000..82f1e44 --- /dev/null +++ b/tests/cli-durable-totals.test.ts @@ -0,0 +1,261 @@ +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, + buildDurablePeriod, + buildPeriodData, + getDailyCacheConfigHash, +} from '../src/usage-aggregator.js' +import { parseAllSessions, filterProjectsByName, clearSessionCache } from '../src/parser.js' +import { renderOverview } from '../src/overview.js' +import type { DateRange } from '../src/types.js' + +// The point of #755: Claude deletes transcripts after ~30 days, so a day that +// can no longer be re-derived from session files exists ONLY in the durable +// daily cache. The bug this suite guards: the CLI report / overview / TUI +// live-parsed the surviving files and dropped those carried days, so their +// totals fell short of the menubar (which unions the cache). Every surface now +// routes totals through the ONE shared builder (buildDurablePeriod), so the CLI +// report totals must equal the menubar payload totals EXACTLY — carried day +// included — across all-provider, provider-filtered, custom-range, and lifetime +// queries, and in the plain live regime with no carried days at all. + +const ROOT = join(tmpdir(), `codeburn-durable-totals-${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 + +const CARRIED_COST = 100 + +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')}` +} + +/** A day the cache holds but no session file can reproduce (sources aged out). */ +function carriedDay(date: string): DailyEntry { + return { + date, + cost: CARRIED_COST, + savingsUSD: 0, + calls: 40, + sessions: 3, + inputTokens: 5000, + outputTokens: 2000, + cacheReadTokens: 0, + cacheWriteTokens: 0, + editTurns: 4, + oneShotTurns: 2, + models: { 'Opus 4.8': { calls: 40, cost: CARRIED_COST, savingsUSD: 0, inputTokens: 5000, outputTokens: 2000, cacheReadTokens: 0, cacheWriteTokens: 0 } }, + categories: { coding: { turns: 10, cost: CARRIED_COST, savingsUSD: 0, editTurns: 4, oneShotTurns: 2 } }, + providers: { + claude: { + calls: 40, cost: CARRIED_COST, savingsUSD: 0, sessions: 3, + inputTokens: 5000, outputTokens: 2000, cacheReadTokens: 0, cacheWriteTokens: 0, + editTurns: 4, oneShotTurns: 2, + models: { 'Opus 4.8': { calls: 40, cost: CARRIED_COST, savingsUSD: 0, inputTokens: 5000, outputTokens: 2000, cacheReadTokens: 0, cacheWriteTokens: 0 } }, + categories: { coding: { turns: 10, cost: CARRIED_COST, savingsUSD: 0, editTurns: 4, oneShotTurns: 2 } }, + projects: { 'proj-x': { cost: CARRIED_COST, calls: 40, savingsUSD: 0, sessions: 3, path: '/Users/gone/proj-x' } }, + }, + }, + projects: { 'proj-x': { cost: CARRIED_COST, calls: 40, savingsUSD: 0, sessions: 3, path: '/Users/gone/proj-x' } }, + carried: true, + } +} + +/** Write a cache whose only historical day is carried (no source files exist). */ +async function seedCarriedCache(): Promise { + const day = daysAgoStr(10) + const cache: DailyCache = { + version: DAILY_CACHE_VERSION, + savingsConfigHash: getDailyCacheConfigHash(), + tzKey: currentTzKey(), + lastComputedDate: daysAgoStr(1), + days: [carriedDay(day)], + complete: true, + } + await writeFile(join(ROOT, 'cache', `daily-cache.v${DAILY_CACHE_VERSION}.json`), JSON.stringify(cache), 'utf-8') + return day +} + +/** Seed a real, priced Claude session dated TODAY (the live, surviving half). */ +async function seedLiveTodaySession(): Promise { + const projectDir = join(ROOT, 'home', '.claude', 'projects', 'p') + await mkdir(projectDir, { recursive: true }) + const now = new Date() + const ts = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 12, 0, 0).toISOString() + const ts2 = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 12, 30, 0).toISOString() + const line = (id: string, t: string): string => JSON.stringify({ + type: 'assistant', + timestamp: t, + sessionId: 's-today', + message: { + type: 'message', role: 'assistant', model: 'claude-3-5-sonnet-20241022', id, + content: [], + usage: { input_tokens: 90000, output_tokens: 12000, cache_creation_input_tokens: 0, cache_read_input_tokens: 300000 }, + }, + }) + await writeFile(join(projectDir, 's-today.jsonl'), [line('m1', ts), line('m2', ts2)].join('\n') + '\n', 'utf-8') +} + +/** Live-only headline over the surviving files for the range (no cache union). */ +async function liveOnly(range: DateRange): Promise<{ cost: number; calls: number }> { + clearSessionCache() + const projects = filterProjectsByName(await parseAllSessions(range, 'all'), [], []) + const data = buildPeriodData('live', projects) + return { cost: data.cost, calls: data.calls } +} + +beforeAll(async () => { + await loadPricing() +}) + +beforeEach(async () => { + savedEnv = Object.fromEntries(ENV_KEYS.map(k => [k, process.env[k]])) + await mkdir(join(ROOT, 'home', '.claude'), { recursive: true }) + await mkdir(join(ROOT, 'cache'), { recursive: true }) + process.env['HOME'] = join(ROOT, 'home') + process.env['CODEBURN_CACHE_DIR'] = join(ROOT, 'cache') + process.env['CLAUDE_CONFIG_DIR'] = join(ROOT, 'home', '.claude') + delete process.env['CLAUDE_CONFIG_DIRS'] + delete process.env['CODEX_HOME'] + clearSessionCache() +}) + +afterEach(async () => { + clearSessionCache() + 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 }) +}) + +/** The full report-vs-menubar equality for one resolved range + provider. */ +async function assertParity(range: DateRange, provider: string): Promise<{ menubarCost: number; carried: number }> { + clearSessionCache() + const menubar = await buildMenubarPayloadForRange({ range, label: 'p' }, { provider, optimize: false, timeline: false }) + clearSessionCache() + const durable = await buildDurablePeriod({ range, label: 'p' }, { provider }) + // The report / overview / TUI headline IS durable.data; the menubar payload's + // current IS the same builder. They must agree bit-for-bit. + expect(menubar.current.cost).toBe(durable.data.cost) + expect(menubar.current.calls).toBe(durable.data.calls) + expect(menubar.current.sessions).toBe(durable.data.sessions) + expect(menubar.current.inputTokens).toBe(durable.data.inputTokens) + expect(menubar.current.outputTokens).toBe(durable.data.outputTokens) + return { menubarCost: menubar.current.cost, carried: durable.carriedCostUSD } +} + +describe('CLI totals ↔ menubar parity through the durable daily cache', () => { + it('counts the carried day equally on both paths for all-provider, custom-range, and lifetime', async () => { + await seedCarriedCache() + await seedLiveTodaySession() + + const custom: DateRange = { start: new Date(Date.now() - 20 * 24 * 60 * 60 * 1000), end: new Date() } + for (const range of [getDateRange('all').range, getDateRange('lifetime').range, custom]) { + const { menubarCost, carried } = await assertParity(range, 'all') + // The carried day is genuinely in the total: it equals the surviving-file + // parse PLUS the $100 carried day, and strictly exceeds the live-only view. + const live = await liveOnly(range) + expect(carried).toBeCloseTo(CARRIED_COST, 6) + expect(menubarCost).toBeGreaterThan(live.cost) + expect(menubarCost).toBeCloseTo(live.cost + CARRIED_COST, 6) + } + }) + + it('resolves provider filters identically on both paths, slicing the carried day per provider', async () => { + await seedCarriedCache() + await seedLiveTodaySession() + const range = getDateRange('all').range + + // The corpus is entirely Claude, so the claude slice equals the all total. + const claude = await assertParity(range, 'claude') + clearSessionCache() + const all = await buildDurablePeriod({ range, label: 'p' }, { provider: 'all' }) + expect(claude.menubarCost).toBeCloseTo(all.data.cost, 6) + expect(claude.carried).toBeCloseTo(CARRIED_COST, 6) + + // A provider with no data is zero on both paths (no carried leak). + clearSessionCache() + const codexMenubar = await buildMenubarPayloadForRange({ range, label: 'p' }, { provider: 'codex', optimize: false, timeline: false }) + clearSessionCache() + const codexDurable = await buildDurablePeriod({ range, label: 'p' }, { provider: 'codex' }) + expect(codexMenubar.current.cost).toBe(codexDurable.data.cost) + expect(codexDurable.data.cost).toBe(0) + expect(codexDurable.carriedCostUSD).toBe(0) + }) + + it('holds the equality in the plain live regime with no carried days', async () => { + // No cache seeded: the only data is today's surviving session. + await seedLiveTodaySession() + const range = getDateRange('all').range + + const { menubarCost, carried } = await assertParity(range, 'all') + const live = await liveOnly(range) + expect(carried).toBe(0) + expect(menubarCost).toBeGreaterThan(0) + expect(menubarCost).toBeCloseTo(live.cost, 6) + }) +}) + +describe('terminal overview carried-day footnote', () => { + it('appends the preserved-cost footnote exactly when carried > 0', async () => { + await seedCarriedCache() + await seedLiveTodaySession() + const range = getDateRange('all').range + + clearSessionCache() + const durable = await buildDurablePeriod({ range, label: 'Last 6 months' }, { provider: 'all' }) + expect(durable.carriedCostUSD).toBeGreaterThan(0) + const withCarried = renderOverview(durable.liveProjects, { + label: 'Last 6 months', + color: false, + durable: { + cost: durable.data.cost, + savingsUSD: durable.data.savingsUSD, + calls: durable.data.calls, + sessions: durable.data.sessions, + inputTokens: durable.data.inputTokens, + outputTokens: durable.data.outputTokens, + cacheReadTokens: durable.data.cacheReadTokens, + cacheWriteTokens: durable.data.cacheWriteTokens, + days: durable.days, + carriedCostUSD: durable.carriedCostUSD, + }, + }) + expect(withCarried).toContain('preserved from expired session logs') + }) + + it('omits the footnote when nothing was carried', async () => { + await seedLiveTodaySession() + const range = getDateRange('all').range + + clearSessionCache() + const durable = await buildDurablePeriod({ range, label: 'Last 6 months' }, { provider: 'all' }) + expect(durable.carriedCostUSD).toBe(0) + const noCarried = renderOverview(durable.liveProjects, { + label: 'Last 6 months', + color: false, + durable: { + cost: durable.data.cost, + savingsUSD: durable.data.savingsUSD, + calls: durable.data.calls, + sessions: durable.data.sessions, + inputTokens: durable.data.inputTokens, + outputTokens: durable.data.outputTokens, + cacheReadTokens: durable.data.cacheReadTokens, + cacheWriteTokens: durable.data.cacheWriteTokens, + days: durable.days, + carriedCostUSD: durable.carriedCostUSD, + }, + }) + expect(noCarried).not.toContain('preserved from expired session logs') + }) +})