fix(app): accuracy batch A — provider scoping, honest chart axes, range threading, stale-error banner

- Provider filter: Models-this-period sources current.topModels (history
  days carry empty topModels per provider); StackedBars falls back to a
  cost-proportional single segment like the Swift menubar
- Chart windows align to the CLI (week -7, 30days -30, all = 6 months)
  and zero-fill a contiguous calendar window; history.daily is sparse and
  both sides key local YYYY-MM-DD, so lookups are safe (verified on real
  CLI output)
- Custom range threads into Overview panels (chart, models table) and
  suppresses MTD/projected and vs-last-week comparisons; Compare states
  that custom dates fall back to the period
- usePolled clears errors per attempt and sections show a StaleBanner
  instead of silently rendering last-good data after a failed refresh
- Cache-hit denominator matches the CLI; hero savings relabeled 'Saved by
  applied fixes' + new 'Saved via local models' line; formatCompact
  everywhere; Optimize 'Fixes' count matches the rendered list

App suite 163/163 (was 147), root 1613/1613.
This commit is contained in:
iamtoruk 2026-07-16 01:14:41 -07:00
parent 44d2449d30
commit cbddd3a9d7
20 changed files with 463 additions and 90 deletions

View file

@ -173,7 +173,7 @@ export function App() {
/>
<div className="body">
{section === 'overview' ? (
<OverviewContent period={period} overview={overview} onNavigate={navigate} />
<OverviewContent period={period} provider={provider} range={customRange} overview={overview} onNavigate={navigate} />
) : section === 'sessions' ? (
<Sessions period={period} provider={provider} range={customRange} refreshToken={refreshToken} />
) : section === 'spend' ? (
@ -183,7 +183,7 @@ export function App() {
) : section === 'models' ? (
<Models period={period} provider={provider} range={customRange} refreshToken={refreshToken} onNavigate={navigate} />
) : section === 'compare' ? (
<Compare period={period} provider={provider} refreshToken={refreshToken} />
<Compare period={period} provider={provider} range={customRange} refreshToken={refreshToken} />
) : (
<SectionPlaceholder title={SECTION_TITLES[section]} />
)}

View file

@ -28,4 +28,23 @@ describe('StackedBars', () => {
const ticks = container.querySelectorAll('.sbars-wrap > .ov-xax span')
expect([...ticks].map(tick => tick.textContent)).toEqual(['Jul 1', 'Jul 5', 'Jul 9', 'Jul 13', 'Jul 16'])
})
it('draws a single cost-only fallback bar and a provider legend when a day has cost but no model breakdown', () => {
// Provider-filtered days: cost present, topModels empty (the Swift menubar
// draws these from day.cost). A zero-cost day stays empty.
const daily = [
{ ...entry(9), cost: 0 },
{ ...entry(10), cost: 12 },
]
const { container } = render(<StackedBars daily={daily} fallbackLabel="Claude" />)
const columns = container.querySelectorAll('.sbars .c')
expect(columns[0].querySelectorAll('.s')).toHaveLength(0)
expect(columns[1].querySelectorAll('.s')).toHaveLength(1)
expect(columns[1].querySelector('.s-other')).toBeInTheDocument()
const legend = container.querySelector('.legend')!
expect(legend.querySelectorAll('span')).toHaveLength(1)
expect(legend).toHaveTextContent('Claude')
})
})

View file

@ -5,17 +5,26 @@ import type { DailyHistoryEntry } from '../lib/types'
const SERIES_ORDER: readonly SeriesKey[] = ['opus', 'fable', 'haiku', 'gpt', 'sonnet', 'other']
export function StackedBars({ daily }: { daily: DailyHistoryEntry[] }) {
function modelSpend(day: DailyHistoryEntry): number {
return day.topModels.reduce((sum, model) => sum + Math.max(0, model.cost), 0)
}
export function StackedBars({ daily, fallbackLabel = 'All models' }: { daily: DailyHistoryEntry[]; fallbackLabel?: string }) {
const presentSeries = new Set<SeriesKey>()
const maxTotal = Math.max(
1,
...daily.map(day => day.topModels.reduce((sum, model) => sum + Math.max(0, model.cost), 0)),
)
let usesFallback = false
for (const day of daily) {
for (const model of day.topModels) {
if (model.cost > 0) presentSeries.add(seriesKeyForModel(model.name))
if (modelSpend(day) > 0) {
for (const model of day.topModels) {
if (model.cost > 0) presentSeries.add(seriesKeyForModel(model.name))
}
} else if (day.cost > 0) {
// Provider-filtered days carry day.cost but no per-model breakdown; the
// bar must still reflect spend (the Swift menubar draws from day.cost).
usesFallback = true
}
}
// Fallback days contribute day.cost to the scale so their single segment is proportional.
const maxTotal = Math.max(1, ...daily.map(day => (modelSpend(day) > 0 ? modelSpend(day) : Math.max(0, day.cost))))
const legendSeries = SERIES_ORDER.filter(series => presentSeries.has(series))
const ticks = daily.filter((_, index) => index % 4 === 0)
const lastDay = daily.at(-1)
@ -26,19 +35,27 @@ export function StackedBars({ daily }: { daily: DailyHistoryEntry[] }) {
<div className="sbars" aria-label="Daily spend by model">
{daily.map(day => (
<div className="c" key={day.date} data-date={day.date} title={`${day.date} · ${formatUsd(day.cost)}`}>
{[...day.topModels].sort(
(a, b) => SERIES_ORDER.indexOf(seriesKeyForModel(a.name)) - SERIES_ORDER.indexOf(seriesKeyForModel(b.name)),
).map(model => {
const pct = Math.max(1, (Math.max(0, model.cost) / maxTotal) * 100)
return (
<span
key={`${day.date}-${model.name}`}
className={`s ${seriesClassForModel(model.name)}`}
style={{ height: `${pct}%` }}
title={`${model.name} · ${formatUsd(model.cost)}`}
/>
)
})}
{modelSpend(day) > 0 ? (
[...day.topModels].sort(
(a, b) => SERIES_ORDER.indexOf(seriesKeyForModel(a.name)) - SERIES_ORDER.indexOf(seriesKeyForModel(b.name)),
).map(model => {
const pct = Math.max(1, (Math.max(0, model.cost) / maxTotal) * 100)
return (
<span
key={`${day.date}-${model.name}`}
className={`s ${seriesClassForModel(model.name)}`}
style={{ height: `${pct}%` }}
title={`${model.name} · ${formatUsd(model.cost)}`}
/>
)
})
) : day.cost > 0 ? (
<span
className={`s ${seriesClassForKey('other')}`}
style={{ height: `${Math.max(1, (day.cost / maxTotal) * 100)}%` }}
title={`${fallbackLabel} · ${formatUsd(day.cost)}`}
/>
) : null}
</div>
))}
</div>
@ -59,6 +76,12 @@ export function StackedBars({ daily }: { daily: DailyHistoryEntry[] }) {
{SERIES_LABELS[series]}
</span>
))}
{usesFallback && !presentSeries.has('other') && (
<span key="fallback">
<i className={seriesClassForKey('other')} />
{fallbackLabel}
</span>
)}
</div>
</div>
)

View file

@ -0,0 +1,14 @@
// @vitest-environment jsdom
import { render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import { StaleBanner } from './StaleBanner'
describe('StaleBanner', () => {
it('shows the last-good notice with the error summary', () => {
render(<StaleBanner error={{ kind: 'nonzero', message: 'codeburn exited 1' }} />)
const banner = screen.getByRole('status')
expect(banner).toHaveTextContent('Refresh failed, showing last good data · codeburn exited 1')
})
})

View file

@ -0,0 +1,24 @@
import type { CliError } from '../lib/types'
/**
* One-line notice shown when a section still has last-good data but the latest
* background poll failed. Muted so it never competes with the data below it.
*/
export function StaleBanner({ error }: { error: CliError }) {
return (
<div
role="status"
className="stale-banner"
style={{
fontSize: 11.5,
color: 'var(--mut)',
borderLeft: '2px solid var(--warn)',
padding: '3px 8px',
margin: '0 0 8px',
lineHeight: 1.4,
}}
>
Refresh failed, showing last good data · {error.message}
</div>
)
}

View file

@ -36,4 +36,23 @@ describe('usePolled', () => {
await act(async () => { resolvers[1]!('A-stale') })
expect(result.current.data).toBe('B-fresh')
})
it('keeps last-good data and exposes the error when a background reload fails', async () => {
const calls: Array<{ resolve: (v: string) => void; reject: (e: unknown) => void }> = []
const fetcher = vi.fn(() => new Promise<string>((resolve, reject) => { calls.push({ resolve, reject }) }))
const { result } = renderHook(() => usePolled(fetcher, []))
// Establish last-good data.
await act(async () => { calls[0]!.resolve('good') })
expect(result.current.data).toBe('good')
expect(result.current.error).toBeNull()
// A reload clears the error up front; if it fails, data is retained and the
// error is surfaced alongside it (the StaleBanner condition).
act(() => { result.current.refresh() })
expect(result.current.error).toBeNull()
await act(async () => { calls[1]!.reject({ kind: 'nonzero', message: 'boom' }) })
expect(result.current.data).toBe('good')
expect(result.current.error).toMatchObject({ kind: 'nonzero', message: 'boom' })
})
})

View file

@ -32,6 +32,9 @@ export function usePolled<T>(fetcher: () => Promise<T>, deps: unknown[], interva
const load = useCallback(() => {
const epoch = ++epochRef.current
setLoading(true)
// Clear any prior error at the start of each attempt so a fresh poll never
// shows a stale banner while it is still in flight; last-good `data` stays.
setError(null)
fetcher()
.then(result => {
if (epochRef.current !== epoch) return

View file

@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest'
import type { DailyHistoryEntry, Period } from './types'
import { formatChartDate, sliceDailyToPeriod } from './period'
import { contiguousDailyWindow, formatChartDate, periodWindowStart, sliceDailyToPeriod } from './period'
function entry(date: string): DailyHistoryEntry {
return {
@ -34,8 +34,9 @@ const DAILY = [
describe('sliceDailyToPeriod', () => {
it.each<[Period, string[]]>([
['today', ['2026-07-10']],
['week', ['2026-07-04', '2026-07-09', '2026-07-10']],
['30days', ['2026-06-11', '2026-07-01', '2026-07-03', '2026-07-04', '2026-07-09', '2026-07-10']],
// Window boundaries mirror src/cli-date.ts: week = now-7, 30days = now-30.
['week', ['2026-07-03', '2026-07-04', '2026-07-09', '2026-07-10']],
['30days', ['2026-06-10', '2026-06-11', '2026-07-01', '2026-07-03', '2026-07-04', '2026-07-09', '2026-07-10']],
['month', ['2026-07-01', '2026-07-03', '2026-07-04', '2026-07-09', '2026-07-10']],
[
'all',
@ -56,6 +57,29 @@ describe('sliceDailyToPeriod', () => {
})
})
describe('periodWindowStart', () => {
it.each<[Period, string]>([
['today', '2026-07-10'],
['week', '2026-07-03'],
['30days', '2026-06-10'],
['month', '2026-07-01'],
['all', '2026-01-01'],
])('aligns %s to the CLI window start', (period, expected) => {
expect(periodWindowStart(period, NOW)).toBe(expected)
})
})
describe('contiguousDailyWindow', () => {
it('zero-fills inactive calendar days between sparse real entries', () => {
const sparse = [entry('2026-07-08'), entry('2026-07-10')]
const window = contiguousDailyWindow(sparse, '2026-07-07', '2026-07-10')
expect(window.map(day => day.date)).toEqual(['2026-07-07', '2026-07-08', '2026-07-09', '2026-07-10'])
// The real entries keep their cost; the two gaps are zero-filled.
expect(window.map(day => day.cost)).toEqual([0, 1, 0, 1])
})
})
describe('formatChartDate', () => {
it('formats date keys without shifting the local calendar day', () => {
expect(formatChartDate('2026-07-01')).toBe('Jul 1')

View file

@ -1,26 +1,30 @@
import type { DailyHistoryEntry, Period } from './types'
// The CLI emits `history.daily` as a SPARSE list of active days only (not a
// backfilled calendar). Charts must zero-fill inactive days client-side to keep
// the time axis honest; helpers here own that windowing.
/** Local calendar date key "YYYY-MM-DD", matching the CLI's `dateKey` (src/day-aggregator.ts). */
export function localDateKey(d: Date): string {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
}
/**
* Shared period-window helper for backfilled `history.daily` arrays. T8 should
* migrate Overview.tsx to this helper so both sections use one source of truth.
*/
export function periodWindowStart(period: Period, now = new Date()): string | null {
// Aligned to src/cli-date.ts:getDateRange so client windows match CLI totals.
const ALL_TIME_MONTHS = 6
/** Inclusive lower bound (date key) of the selected period's window, matching the CLI. */
export function periodWindowStart(period: Period, now = new Date()): string {
switch (period) {
case 'today':
return localDateKey(now)
case 'week':
return localDateKey(new Date(now.getFullYear(), now.getMonth(), now.getDate() - 6))
return localDateKey(new Date(now.getFullYear(), now.getMonth(), now.getDate() - 7))
case '30days':
return localDateKey(new Date(now.getFullYear(), now.getMonth(), now.getDate() - 29))
return localDateKey(new Date(now.getFullYear(), now.getMonth(), now.getDate() - 30))
case 'month':
return localDateKey(new Date(now.getFullYear(), now.getMonth(), 1))
case 'all':
return null
return localDateKey(new Date(now.getFullYear(), now.getMonth() - ALL_TIME_MONTHS, 1))
}
}
@ -28,7 +32,48 @@ export function periodWindowStart(period: Period, now = new Date()): string | nu
export function sliceDailyToPeriod(daily: DailyHistoryEntry[], period: Period, now = new Date()): DailyHistoryEntry[] {
const start = periodWindowStart(period, now)
const todayKey = localDateKey(now)
return daily.filter(d => (start === null || d.date >= start) && d.date <= todayKey)
return daily.filter(d => d.date >= start && d.date <= todayKey)
}
/** `history.daily` entries within an explicit [from..to] date-key range (custom range). */
export function sliceDailyToRange(daily: DailyHistoryEntry[], from: string, to: string): DailyHistoryEntry[] {
return daily.filter(d => d.date >= from && d.date <= to)
}
function zeroEntry(date: string): DailyHistoryEntry {
return {
date,
cost: 0,
savingsUSD: 0,
calls: 0,
inputTokens: 0,
outputTokens: 0,
cacheReadTokens: 0,
cacheWriteTokens: 0,
topModels: [],
}
}
/**
* Contiguous calendar window [fromKey..toKey] with exactly one entry per day.
* Real (sparse) `history.daily` entries fill their day; inactive days are
* zero-filled so the chart axis reflects true calendar spacing. Keys are local
* YYYY-MM-DD (localDateKey / the CLI's dateKey), so lookups always match.
*/
export function contiguousDailyWindow(daily: DailyHistoryEntry[], fromKey: string, toKey: string): DailyHistoryEntry[] {
if (fromKey > toKey) return []
const byDate = new Map(daily.map(d => [d.date, d]))
const [fy, fm, fd] = fromKey.split('-').map(Number)
const [ty, tm, td] = toKey.split('-').map(Number)
const cursor = new Date(fy, fm - 1, fd)
const end = new Date(ty, tm - 1, td)
const out: DailyHistoryEntry[] = []
while (cursor <= end) {
const key = localDateKey(cursor)
out.push(byDate.get(key) ?? zeroEntry(key))
cursor.setDate(cursor.getDate() + 1)
}
return out
}
/** Format a local date key for compact chart-axis labels such as "Jul 1". */

View file

@ -82,6 +82,27 @@ describe('Compare', () => {
expect(mocks.getCompare).toHaveBeenCalledWith('30days', 'all', 'Sonnet 5', 'Opus 4.8')
})
it('computes cache hit rate over input + cache reads (excludes cache writes)', async () => {
mocks.getCompareModels.mockResolvedValue([modelA, modelB])
mocks.getCompare.mockResolvedValue(report)
render(<Compare period="30days" provider="all" />)
const context = (await screen.findByText('Context')).closest<HTMLElement>('.cmp-card')!
const row = within(context).getByText('Cache hit rate').closest('.cmp-metric')!
// 119.4M / (152.6M + 119.4M) = 44%, not 119.4 / (152.6 + 119.4 + 16) = 41%.
expect(row).toHaveTextContent('44%')
expect(row).not.toHaveTextContent('41%')
})
it('notes that custom ranges are unsupported and still compares by period', async () => {
mocks.getCompareModels.mockResolvedValue([modelA, modelB])
mocks.getCompare.mockResolvedValue(report)
render(<Compare period="30days" provider="all" range={{ from: '2026-07-01', to: '2026-07-11' }} />)
expect(await screen.findByText('Compare uses the selected period, custom dates are not supported yet.')).toBeInTheDocument()
expect(mocks.getCompareModels).toHaveBeenCalledWith('30days', 'all')
})
it('renders the need-two-models note without requesting a report', async () => {
mocks.getCompareModels.mockResolvedValue([modelA])
render(<Compare period="week" provider="all" />)

View file

@ -6,7 +6,7 @@ import { Panel } from '../components/Panel'
import { usePolled } from '../hooks/usePolled'
import { formatCompact, formatUsd } from '../lib/format'
import { codeburn } from '../lib/ipc'
import type { CompareJsonReport, ComparisonRow, ModelStats, Period, WorkingStyleRow } from '../lib/types'
import type { CompareJsonReport, ComparisonRow, DateRange, ModelStats, Period, WorkingStyleRow } from '../lib/types'
function fmtMetric(v: number | null, fn: 'cost' | 'number' | 'percent' | 'decimal'): string {
if (v === null) return '—'
@ -20,13 +20,25 @@ function EmptyNote({ children }: { children: React.ReactNode }) {
return <p style={{ color: 'var(--t3)', margin: 0, fontSize: 12 }}>{children}</p>
}
// The CLI `compare` command has no --from/--to, so a custom range falls back to
// the selected period. Say so instead of silently ignoring the dates.
function RangeNote() {
return (
<p className="cmp-range-note" role="status" style={{ color: 'var(--mut)', margin: '0 0 6px', fontSize: 11.5 }}>
Compare uses the selected period, custom dates are not supported yet.
</p>
)
}
export function Compare({
period,
provider,
range = null,
refreshToken = 0,
}: {
period: Period
provider: string
range?: DateRange | null
refreshToken?: number
}) {
const models = usePolled<ModelStats[]>(
@ -71,6 +83,7 @@ export function Compare({
return (
<>
{range && <RangeNote />}
<div className="cmp-picker" aria-label="Models being compared">
<Dropdown
id="compare-first-model"
@ -231,7 +244,8 @@ function CategoryCard({ report }: { report: CompareJsonReport }) {
}
function cacheHitRate(model: ModelStats): string {
const total = model.inputTokens + model.cacheReadTokens + model.cacheWriteTokens
// reads over reads + fresh input (matches menubar-json + compare-stats).
const total = model.inputTokens + model.cacheReadTokens
return total > 0 ? `${Math.round(model.cacheReadTokens / total * 100)}%` : '—'
}

View file

@ -4,6 +4,7 @@ import { CliErrorPanel } from '../components/CliErrorPanel'
import { seriesColorForModel } from '../components/ListRow'
import { Panel } from '../components/Panel'
import { SegTabs } from '../components/SegTabs'
import { StaleBanner } from '../components/StaleBanner'
import type { Section } from '../components/Sidebar'
import { usePolled } from '../hooks/usePolled'
import { formatCompact, formatUsd } from '../lib/format'
@ -56,6 +57,7 @@ export function Models({
return (
<>
{report.error && <StaleBanner error={report.error} />}
<div style={{ display: 'flex', alignItems: 'center', gap: 10, alignSelf: 'flex-start' }}>
<SegTabs options={LENSES} value={lens} onChange={value => setLens(value as ModelsLens)} />
<button type="button" className="btn btn-s" onClick={() => onNavigate?.('compare')}>

View file

@ -208,6 +208,25 @@ describe('Optimize', () => {
expect(screen.getByText('No abandoned sessions in this range yet.')).toBeInTheDocument()
})
it('labels the Fixes tab with the rendered list length, not the menubar-wide findingCount', async () => {
const payload = makePayload()
// The menubar counts 25 findings, but the Fixes tab only renders topFindings.
payload.optimize = {
findingCount: 25,
savingsUSD: 94.4,
topFindings: [
{ title: 'A', impact: 'high', savingsUSD: 1 },
{ title: 'B', impact: 'low', savingsUSD: 1 },
],
}
getOverview.mockResolvedValue(payload)
render(<Optimize period="30days" provider="all" />)
expect(await screen.findByRole('button', { name: 'Fixes 2' })).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Fixes 25' })).not.toBeInTheDocument()
})
it('passes provider and custom range to the optimize report bridge', async () => {
render(<Optimize period="30days" provider="claude" range={{ from: '2026-07-01', to: '2026-07-11' }} />)
await screen.findByText('Opus is doing your small talk')

View file

@ -3,6 +3,7 @@ import { Fragment, useState } from 'react'
import { CliErrorPanel } from '../components/CliErrorPanel'
import { Panel } from '../components/Panel'
import { SegTabs } from '../components/SegTabs'
import { StaleBanner } from '../components/StaleBanner'
import { type Polled, usePolled } from '../hooks/usePolled'
import { formatCompact, formatUsd } from '../lib/format'
import { codeburn } from '../lib/ipc'
@ -61,11 +62,13 @@ export function OptimizeContent({
{ value: 'waste', label: `Waste ${formatUsd(overview.data.optimize.savingsUSD)}` },
{ value: 'reverts', label: `Reverts ${revertedTotal}` },
{ value: 'abandoned', label: `Abandoned ${abandonedTotal}` },
{ value: 'fixes', label: `Fixes ${overview.data.optimize.findingCount.toLocaleString('en-US')}` },
// The Fixes tab renders topFindings (capped list), so label the count that shows.
{ value: 'fixes', label: `Fixes ${overview.data.optimize.topFindings.length.toLocaleString('en-US')}` },
]
return (
<>
{overview.error && <StaleBanner error={overview.error} />}
<SegTabs
options={options}
value={tab}

View file

@ -2,8 +2,13 @@
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { Polled } from '../hooks/usePolled'
import type { ActReportJson, MenubarPayload, YieldJsonReport } from '../lib/types'
import { Overview, localDateKey } from './Overview'
import { Overview, OverviewContent, localDateKey } from './Overview'
function polled(data: MenubarPayload): Polled<MenubarPayload> {
return { data, error: null, loading: false, lastSuccessAt: Date.now(), refresh: vi.fn() }
}
// Mock the typed bridge so the section fetches our payload instead of spawning
// the CLI. `normalizeCliError` (used by usePolled) is kept from the real module.
@ -209,7 +214,8 @@ describe('Overview', () => {
const modelRows = within(modelsTable).getAllByRole('row')
expect(modelRows[1]).toHaveTextContent('claude-opus-4')
expect(modelRows[1]).toHaveTextContent('1.2B')
expect(modelRows[1]).toHaveTextContent('60.0M')
// Tokens now use the shared formatCompact helper (drops a trailing .0).
expect(modelRows[1]).toHaveTextContent('60M')
expect(modelRows[1]).toHaveTextContent('$171.20')
expect(modelRows[1]).toHaveTextContent('900')
expect(modelRows[2]).toHaveTextContent('claude-haiku-4')
@ -237,7 +243,7 @@ describe('Overview', () => {
// equal the saved figure on some dates, so an unscoped getByText('$84.20')
// would match two cards.
expect(within(kpis).getByText('$84.20')).toBeInTheDocument()
expect(within(kpis).getByText('from 11 applied fixes')).toBeInTheDocument()
expect(within(kpis).getByText('across 11 fixes')).toBeInTheDocument()
const statsCard = screen.getByText('Month to date').closest('.ov-stats3')
expect(statsCard).toHaveClass('ov-card')
expect(statsCard?.children).toHaveLength(2)
@ -271,26 +277,28 @@ describe('Overview', () => {
render(<Overview period="30days" provider="all" />)
expect(await screen.findByText('from 0 applied fixes')).toBeInTheDocument()
expect(await screen.findByText('across 0 fixes')).toBeInTheDocument()
await waitFor(() => expect(getActReport).toHaveBeenCalled())
expect(screen.getByText('$0.00')).toBeInTheDocument()
})
it('shows the available real history entries without zero-filling a 30-day window', async () => {
it('zero-fills a contiguous 30-day window from sparse history', async () => {
const now = new Date()
const payload = makePayload(now)
payload.history.daily = payload.history.daily.slice(-5)
getOverview.mockResolvedValue(payload)
// The daily chart is a trend, not scoped to the period selector: even for a
// short period like "week" it uses the real history entries and does not
// synthesize zero-height bars to reach 30 days.
// history.daily is sparse (active days only). The daily chart zero-fills a
// contiguous calendar window (at least 30 days) so gaps read as real
// calendar time instead of compressed bars — the date keys match localDateKey.
const { container } = render(<Overview period="week" provider="all" />)
expect(await screen.findByText('parser-service')).toBeInTheDocument()
const bars = container.querySelectorAll('.chart .col')
expect(bars).toHaveLength(5)
expect([...bars].map(bar => bar.getAttribute('data-cost'))).toEqual(['5', '5', '5', '5', '6.2'])
expect(bars).toHaveLength(30)
// The five real days keep their cost at the end; the leading 25 days are zeros.
expect([...bars].slice(-5).map(bar => bar.getAttribute('data-cost'))).toEqual(['5', '5', '5', '5', '6.2'])
expect([...bars].slice(0, 25).every(bar => bar.getAttribute('data-cost') === '0')).toBe(true)
})
it('computes month-to-date, projection, and previous-month pace', async () => {
@ -402,4 +410,75 @@ describe('Overview', () => {
expect(await screen.findByText(/Locate the codeburn CLI/i)).toBeInTheDocument()
})
it('sources Models this period from current.topModels when a provider filter is active', async () => {
const now = new Date()
const payload = makePayload(now)
// Provider-filtered CLI output: history.daily loses its per-model breakdown,
// but current is already provider-scoped.
payload.history.daily = payload.history.daily.map(day => ({ ...day, topModels: [] }))
payload.current.topModels = [
{ name: 'gpt-5.5-codex', cost: 120, savingsUSD: 0, savingsBaselineModel: '', calls: 240 },
{ name: 'claude-opus-4', cost: 60, savingsUSD: 0, savingsBaselineModel: '', calls: 90 },
]
render(<OverviewContent period="30days" provider="codex" overview={polled(payload)} />)
const modelsTable = await screen.findByRole('table', { name: 'Models this period' })
const rows = within(modelsTable).getAllByRole('row')
// Sourced from current.topModels (cost-sorted), not the now-empty daily aggregation.
expect(rows[1]).toHaveTextContent('gpt-5.5-codex')
expect(rows[1]).toHaveTextContent('$120.00')
expect(rows[1]).toHaveTextContent('240')
expect(rows[2]).toHaveTextContent('claude-opus-4')
// current.topModels carries no per-model tokens → both token cells show a dash.
expect(within(rows[1] as HTMLElement).getAllByText('—')).toHaveLength(2)
})
it('suppresses the week-over-week anomaly and MTD card for a custom range', async () => {
const now = new Date()
const overview = polled(makePayload(now))
const { rerender } = render(<OverviewContent period="30days" provider="all" overview={overview} />)
// Baseline (no range): the MTD card and a week-over-week signal are present.
expect(await screen.findByText('Month to date')).toBeInTheDocument()
expect(screen.getAllByText(/than last week/).length).toBeGreaterThan(0)
const from = localDateKey(new Date(now.getFullYear(), now.getMonth(), now.getDate() - 2))
const to = localDateKey(now)
rerender(<OverviewContent period="30days" provider="all" range={{ from, to }} overview={overview} />)
expect(screen.queryByText('Month to date')).not.toBeInTheDocument()
expect(screen.queryByText('Projected month')).not.toBeInTheDocument()
expect(screen.queryByText(/than last week/)).toBeNull()
expect(screen.getByText(/is the biggest driver in this range/)).toBeInTheDocument()
})
it('renders local-model savings in the hero only when present', async () => {
const now = new Date()
const payload = makePayload(now)
payload.current.localModelSavings = { totalUSD: 42.5, calls: 10, byModel: [], byProvider: [] }
render(<OverviewContent period="30days" provider="all" overview={polled(payload)} />)
expect(await screen.findByText('Saved by applied fixes')).toBeInTheDocument()
expect(screen.getByText('Saved via local models')).toBeInTheDocument()
expect(screen.getByText('$42.50')).toBeInTheDocument()
expect(screen.queryByText('Saved to date')).not.toBeInTheDocument()
})
it('shows a stale banner when last-good data is present but the latest poll failed', async () => {
const now = new Date()
const overview: Polled<MenubarPayload> = {
data: makePayload(now),
error: { kind: 'nonzero', message: 'codeburn exited 1' },
loading: false,
lastSuccessAt: Date.now(),
refresh: vi.fn(),
}
render(<OverviewContent period="30days" provider="all" overview={overview} />)
expect(await screen.findByRole('status')).toHaveTextContent('Refresh failed, showing last good data · codeburn exited 1')
})
})

View file

@ -5,13 +5,15 @@ import { CliErrorPanel } from '../components/CliErrorPanel'
import { ActivityHeatmap } from '../components/ActivityHeatmap'
import { ListRow } from '../components/ListRow'
import { Panel } from '../components/Panel'
import { StaleBanner } from '../components/StaleBanner'
import { type Polled, usePolled } from '../hooks/usePolled'
import { formatUsd } from '../lib/format'
import { formatCompact, formatUsd } from '../lib/format'
import { codeburn } from '../lib/ipc'
import { formatChartDate, localDateKey, sliceDailyToPeriod } from '../lib/period'
import { contiguousDailyWindow, formatChartDate, localDateKey, sliceDailyToPeriod, sliceDailyToRange } from '../lib/period'
import type {
ActReportJson,
DailyHistoryEntry,
DateRange,
MenubarPayload,
Period,
YieldJsonReport,
@ -126,7 +128,7 @@ function CostPerOutcome({ outcome }: { outcome: Polled<YieldJsonReport> }) {
type Anomaly = { lead: string; value: string; tail: string }
function deriveAnomalies(data: MenubarPayload, now: Date): Anomaly[] {
function deriveAnomalies(data: MenubarPayload, now: Date, includeWeekOverWeek = true): Anomaly[] {
const anomalies: Anomaly[] = []
const todayKey = localDateKey(now)
const today = data.history.daily.find(day => day.date === todayKey)
@ -144,7 +146,7 @@ function deriveAnomalies(data: MenubarPayload, now: Date): Anomaly[] {
anomalies.push({ lead: "Today's spend is ", value: `${ratio.toFixed(1).replace(/\.0$/, '')}×`, tail: ` your typical ${weekday}.` })
}
if (data.history.daily.length >= 14) {
if (includeWeekOverWeek && data.history.daily.length >= 14) {
const recent14 = data.history.daily.slice(-14)
const currentWeek = mean(recent14.slice(-7).map(day => day.cost))
const priorWeek = mean(recent14.slice(0, 7).map(day => day.cost))
@ -267,19 +269,21 @@ function formatShortDay(date: string): string {
return `${month}/${day}`
}
function formatTokens(value: number): string {
if (value >= 1e9) return `${(value / 1e9).toFixed(1)}B`
if (value >= 1e6) return `${(value / 1e6).toFixed(1)}M`
if (value >= 1e3) return `${(value / 1e3).toFixed(0)}K`
return String(Math.round(value))
}
type AggregatedModel = {
name: string
cost: number
calls: number
inputTokens: number
outputTokens: number
// Absent in provider-filtered mode: `current.topModels` carries no per-model
// token counts, so the table shows "—" rather than a misleading zero.
inputTokens?: number
outputTokens?: number
}
/** Provider-filtered source: `current.topModels` is already period/range/provider-scoped by the CLI. */
function topModelsToAggregated(models: MenubarPayload['current']['topModels']): AggregatedModel[] {
return models
.map(model => ({ name: model.name, cost: model.cost, calls: model.calls }))
.sort((a, b) => b.cost - a.cost)
}
function aggregateModels(daily: DailyHistoryEntry[]): AggregatedModel[] {
@ -295,8 +299,8 @@ function aggregateModels(daily: DailyHistoryEntry[]): AggregatedModel[] {
}
row.cost += model.cost
row.calls += model.calls
row.inputTokens += model.inputTokens
row.outputTokens += model.outputTokens
row.inputTokens = (row.inputTokens ?? 0) + model.inputTokens
row.outputTokens = (row.outputTokens ?? 0) + model.outputTokens
byName.set(model.name, row)
}
}
@ -322,8 +326,8 @@ function ModelsTable({ models }: { models: AggregatedModel[] }) {
{models.map(model => (
<tr key={model.name}>
<td className="ov-model-name">{model.name}</td>
<td className="num mono">{formatTokens(model.inputTokens)}</td>
<td className="num mono">{formatTokens(model.outputTokens)}</td>
<td className="num mono">{model.inputTokens === undefined ? '—' : formatCompact(model.inputTokens)}</td>
<td className="num mono">{model.outputTokens === undefined ? '—' : formatCompact(model.outputTokens)}</td>
<td className="num mono">{formatUsd(model.cost)}</td>
<td className="num">{model.calls.toLocaleString('en-US')}</td>
</tr>
@ -447,15 +451,19 @@ function TopActivities({ activities }: { activities: MenubarPayload['current']['
export function Overview({ period, provider }: { period: Period; provider: string }) {
const overview = usePolled<MenubarPayload>(() => codeburn.getOverview(period, provider), [period, provider])
return <OverviewContent period={period} overview={overview} />
return <OverviewContent period={period} provider={provider} overview={overview} />
}
export function OverviewContent({
period,
provider = 'all',
range = null,
overview,
onNavigate,
}: {
period: Period
provider?: string
range?: DateRange | null
overview: Polled<MenubarPayload>
onNavigate?: (section: 'optimize') => void
}) {
@ -470,10 +478,25 @@ export function OverviewContent({
}
const now = new Date()
const rangeActive = !!range
const stats = deriveStats(data, now)
const periodDaily = sliceDailyToPeriod(data.history.daily, period, now)
const chartDaily = data.history.daily.slice(-Math.max(30, periodDaily.length))
const models = aggregateModels(periodDaily)
// Daily chart: contiguous zero-filled calendar window. A custom range spans
// [from..to]; otherwise the trend covers at least the last 30 days, extended
// back to the earliest active day already in the period window.
const defaultChartStart = localDateKey(new Date(now.getFullYear(), now.getMonth(), now.getDate() - 29))
const chartDaily = rangeActive
? contiguousDailyWindow(data.history.daily, range.from, range.to)
: contiguousDailyWindow(
data.history.daily,
periodDaily[0] && periodDaily[0].date < defaultChartStart ? periodDaily[0].date : defaultChartStart,
localDateKey(now),
)
// Provider-filtered history.daily has empty topModels, so source the models
// table from current.topModels (already period/range/provider-scoped) instead.
const models = provider !== 'all'
? topModelsToAggregated(data.current.topModels)
: aggregateModels(rangeActive ? sliceDailyToRange(data.history.daily, range.from, range.to) : periodDaily)
const recent14 = data.history.daily.slice(-14)
const weekNow = mean(recent14.slice(-7).map(day => day.cost))
const weekPrior = mean(recent14.slice(-14, -7).map(day => day.cost))
@ -482,24 +505,32 @@ export function OverviewContent({
const topModel = data.current.topModels[0]
const saved = actReport.data?.totals.realizedCostUSD ?? 0
const applied = saved > 0 ? (actReport.data?.totals.measuredActions ?? 0) : 0
const anomalies = deriveAnomalies(data, now)
const localSaved = data.current.localModelSavings.totalUSD
// A custom range has no meaningful "vs last week" or month-to-date baseline.
const anomalies = deriveAnomalies(data, now, !rangeActive)
return (
<div className="ov-dashboard">
{error && <StaleBanner error={error} />}
<div className="ov-card ov-hero-split" aria-label="Key performance indicators">
<div className="ov-hero-main">
<div className="ov-hero-top"><span className="ov-label">{data.current.label}</span><span className="ov-streak"><b>{streakDays(data.history.daily, now)}</b>-day streak</span></div>
<CountUp value={data.current.cost} />
<div className="ov-hero-sub">{data.current.calls.toLocaleString('en-US')} calls · {data.current.sessions.toLocaleString('en-US')} sessions</div>
<div className="ov-saved-line"><span>Saved to date</span><strong>{formatUsd(saved)}</strong><small>from {applied} applied fixes</small></div>
<div className="ov-saved-line"><span>Saved by applied fixes</span><strong>{formatUsd(saved)}</strong><small>across {applied} {applied === 1 ? 'fix' : 'fixes'}</small></div>
{localSaved > 0 && (
<div className="ov-saved-line"><span>Saved via local models</span><strong>{formatUsd(localSaved)}</strong><small>local-model routing</small></div>
)}
</div>
<ActivityHeatmap daily={data.history.daily} bare />
<EfficiencyScorecard current={data.current} bare />
</div>
<div className="ov-card ov-stats3">
<div className="ov-stat"><div className="ov-label">Month to date</div><div className="v">{formatUsd(stats.mtd)}</div><div className="d">{stats.pacePct === null ? `No ${stats.prevMonthName} pace yet` : `${stats.pacePct >= 0 ? '+' : ''}${Math.round(stats.pacePct)}% vs ${stats.prevMonthName} pace`}</div></div>
<div className="ov-stat"><div className="ov-label">Projected month</div><div className="v">{formatUsd(stats.projected)} <small>est</small></div><div className="d warn">{formatUsd(Math.max(0, stats.projected - stats.mtd))} to go</div></div>
</div>
{!rangeActive && (
<div className="ov-card ov-stats3">
<div className="ov-stat"><div className="ov-label">Month to date</div><div className="v">{formatUsd(stats.mtd)}</div><div className="d">{stats.pacePct === null ? `No ${stats.prevMonthName} pace yet` : `${stats.pacePct >= 0 ? '+' : ''}${Math.round(stats.pacePct)}% vs ${stats.prevMonthName} pace`}</div></div>
<div className="ov-stat"><div className="ov-label">Projected month</div><div className="v">{formatUsd(stats.projected)} <small>est</small></div><div className="d warn">{formatUsd(Math.max(0, stats.projected - stats.mtd))} to go</div></div>
</div>
)}
<div className="ov-card ov-panel ov-chart-widget">
<div className="ov-panel-head"><h3>Daily spend</h3><span className="r">{topModel ? `Biggest driver: ${topModel.name}` : 'No model driver yet'}</span></div>
@ -510,7 +541,9 @@ export function OverviewContent({
<div className="ov-coach">
<svg viewBox="0 0 24 24" aria-hidden="true"><polyline points="3 17 9 11 13 15 21 7"/><polyline points="15 7 21 7 21 13"/></svg>
<div className="ov-coach-tx">
{weeklyPct === null ? <>No prior-week pacing baseline yet</> : <>You're pacing <span className="num">{weeklyPct}% {weeklyDirection}</span> than last week</>}{topModel ? <>; <span className="num">{topModel.name}</span> is the biggest driver</> : ''}. <span className="num">{formatUsd(data.optimize.savingsUSD)}</span> is recoverable.
{rangeActive
? <>{topModel ? <><span className="num">{topModel.name}</span> is the biggest driver in this range</> : 'No single model dominates this range'}. <span className="num">{formatUsd(data.optimize.savingsUSD)}</span> is recoverable.</>
: <>{weeklyPct === null ? <>No prior-week pacing baseline yet</> : <>You're pacing <span className="num">{weeklyPct}% {weeklyDirection}</span> than last week</>}{topModel ? <>; <span className="num">{topModel.name}</span> is the biggest driver</> : ''}. <span className="num">{formatUsd(data.optimize.savingsUSD)}</span> is recoverable.</>}
</div>
<button className="ov-coach-cta" type="button" onClick={() => onNavigate?.('optimize')}>Review </button>
</div>

View file

@ -1,6 +1,7 @@
import { CliErrorPanel } from '../components/CliErrorPanel'
import { Panel } from '../components/Panel'
import type { Section } from '../components/Sidebar'
import { StaleBanner } from '../components/StaleBanner'
import { usePolled } from '../hooks/usePolled'
import { formatUsd } from '../lib/format'
import { codeburn } from '../lib/ipc'
@ -71,6 +72,7 @@ export function Plans({ period, refreshToken = 0, onNavigate }: { period: Period
</button>
</div>
<div className="body">
{budgetReport.data && budgetReport.error && <StaleBanner error={budgetReport.error} />}
{renderQuota(quota.data, quota.error)}
{renderBudgetPlans(budgetReport.data, budgetReport.error, manualPlans)}
</div>

View file

@ -3,6 +3,7 @@ import { Fragment, useEffect, useMemo, useState } from 'react'
import { CliErrorPanel } from '../components/CliErrorPanel'
import { Panel } from '../components/Panel'
import { SegTabs } from '../components/SegTabs'
import { StaleBanner } from '../components/StaleBanner'
import { Stat } from '../components/Stat'
import { usePolled } from '../hooks/usePolled'
import { formatCompact, formatDayLong, formatDayShort, formatDuration, formatUsd, shortenProjectPath } from '../lib/format'
@ -159,6 +160,7 @@ export function Sessions({
return (
<div className="sessions-list-view">
{report.error && <StaleBanner error={report.error} />}
<div className="sessions-toolbar">
<input
className="sessions-search"

View file

@ -135,7 +135,7 @@ describe('Spend', () => {
vi.useRealTimers()
})
it('renders the real last-15 spend entries, date axis, projects, and Sankey ribbons', async () => {
it('zero-fills a contiguous 15-day calendar window with a real date axis, projects, and Sankey ribbons', async () => {
getOverview.mockResolvedValue(makePayload(new Date()))
getSpendFlow.mockResolvedValue(makeFlow())
@ -146,18 +146,17 @@ describe('Spend', () => {
expect(screen.getByText('agentseal-dash')).toBeInTheDocument()
expect(screen.getByText('top 2')).toBeInTheDocument()
// Sparse history is zero-filled into 15 contiguous days ending today (Jul 10),
// so the axis reflects real calendar spacing rather than compressing gaps.
const barColumns = container.querySelectorAll('.sbars .c')
expect(barColumns).toHaveLength(5)
expect(barColumns).toHaveLength(15)
expect([...barColumns].map(col => col.getAttribute('data-date'))).toEqual([
'2026-06-30',
'2026-07-01',
'2026-07-04',
'2026-07-06',
'2026-07-10',
'2026-06-26', '2026-06-27', '2026-06-28', '2026-06-29', '2026-06-30',
'2026-07-01', '2026-07-02', '2026-07-03', '2026-07-04', '2026-07-05',
'2026-07-06', '2026-07-07', '2026-07-08', '2026-07-09', '2026-07-10',
])
const ticks = container.querySelectorAll('.sbars-wrap > .ov-xax span')
expect(ticks).toHaveLength(2)
expect([...ticks].map(tick => tick.textContent)).toEqual(['Jun 30', 'Jul 10'])
expect([...ticks].map(tick => tick.textContent)).toEqual(['Jun 26', 'Jun 30', 'Jul 4', 'Jul 8', 'Jul 10'])
expect(container.querySelectorAll('[data-testid="sankey-ribbon"]')).toHaveLength(makeFlow().links.length)
})

View file

@ -3,10 +3,23 @@ import { ListRow } from '../components/ListRow'
import { Panel } from '../components/Panel'
import { Sankey } from '../components/Sankey'
import { StackedBars } from '../components/StackedBars'
import { StaleBanner } from '../components/StaleBanner'
import { type Polled, usePolled } from '../hooks/usePolled'
import { formatUsd } from '../lib/format'
import { codeburn } from '../lib/ipc'
import type { DateRange, MenubarPayload, Period, SpendFlow } from '../lib/types'
import { contiguousDailyWindow, localDateKey } from '../lib/period'
import type { CliError, DateRange, MenubarPayload, Period, SpendFlow } from '../lib/types'
const SPEND_CHART_DAYS = 15
function providerLabel(provider: string): string {
if (provider === 'all') return 'All models'
return provider
.split(/[-\s]+/)
.filter(Boolean)
.map(part => part.charAt(0).toUpperCase() + part.slice(1))
.join(' ')
}
function EmptyNote({ children }: { children: React.ReactNode }) {
return <p style={{ color: 'var(--t3)', margin: 0, fontSize: 12 }}>{children}</p>
@ -47,20 +60,34 @@ export function SpendContent({
)
}
return <SpendPage data={overview.data} flow={flow} />
return <SpendPage data={overview.data} flow={flow} provider={provider} range={range} staleError={overview.error} />
}
function SpendPage({
data,
flow,
provider,
range,
staleError,
}: {
data: MenubarPayload
flow: ReturnType<typeof usePolled<SpendFlow>>
provider: string
range: DateRange | null
staleError: CliError | null
}) {
// Use the real last-15 backfilled days directly. Rebuilding a calendar window
// client-side (contiguousDailyWindow) mismatched the CLI's date keys and
// zero-filled every day, leaving the chart empty.
const chartDaily = data.history.daily.slice(-15)
// `history.daily` is SPARSE (active days only), so zero-fill a contiguous
// calendar window client-side; date keys are localDateKey / the CLI dateKey,
// which match exactly, so real days always land in place.
const now = new Date()
const chartDaily = range
? contiguousDailyWindow(data.history.daily, range.from, range.to)
: contiguousDailyWindow(
data.history.daily,
localDateKey(new Date(now.getFullYear(), now.getMonth(), now.getDate() - (SPEND_CHART_DAYS - 1))),
localDateKey(now),
)
const chartHasSpend = chartDaily.some(day => day.cost > 0)
const projects = data.current.topProjects
const breakdowns = [
{
@ -111,9 +138,10 @@ function SpendPage({
return (
<>
{staleError && <StaleBanner error={staleError} />}
<div className="spend-top-row">
<Panel title="Daily spend by model" className="spend-chart-panel">
{chartDaily.length ? <StackedBars daily={chartDaily} /> : <EmptyNote>No model spend in this range yet.</EmptyNote>}
{chartHasSpend ? <StackedBars daily={chartDaily} fallbackLabel={providerLabel(provider)} /> : <EmptyNote>No model spend in this range yet.</EmptyNote>}
</Panel>
<Panel title="By project" right={projects.length ? `top ${projects.length}` : undefined} className="spend-scroll">
{projects.length ? (