mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-07-29 02:45:13 +00:00
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:
parent
44d2449d30
commit
cbddd3a9d7
20 changed files with 463 additions and 90 deletions
|
|
@ -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]} />
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
14
app/renderer/components/StaleBanner.test.tsx
Normal file
14
app/renderer/components/StaleBanner.test.tsx
Normal 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')
|
||||
})
|
||||
})
|
||||
24
app/renderer/components/StaleBanner.tsx
Normal file
24
app/renderer/components/StaleBanner.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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' })
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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". */
|
||||
|
|
|
|||
|
|
@ -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" />)
|
||||
|
|
|
|||
|
|
@ -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)}%` : '—'
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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')}>
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue