codeburn/app/renderer/sections/Spend.tsx
Resham Joshi dbd93fee51
app,dash: render pre-history days as no data, not a currency zero (#765)
* fix(app,dash): render pre-history days as no data, not a currency zero

Days before the first recorded day in history.daily were painted as a real
0.00/0 calls in the daily activity heatmap and the daily spend charts. That
zero is unknown, not measured, so those cells and bars now read "No data
recorded" instead of a currency zero. Genuinely idle days within recorded
history stay as real zeros.

- app: heatmap cells, the daily spend bars, and the daily-by-model columns
  get a distinct no-data style plus a "No data recorded" hover for days before
  the first recorded day.
- dash: the granular line chart drops buckets before the first recorded day so
  no flat zero line is drawn before any history exists.
- data-start is derived in the UI layer from history.daily; payload shape is
  unchanged.

Verified: app vitest 418 pass (7 new for the no-data behavior); app, dash, and
root typecheck clean; dash and app renderer vite builds succeed.

* no-data: cap guard, timezone-free dash trim, StackedBars aria

Three hardening fixes from adversarial review of the no-data rendering:

dataStartKey returns null at the payload's 365-entry history cap, where
the oldest retained entry is no longer the true data start; classifying
past it would label real aged-out history as no data on long custom
ranges. Documented that the install-to-first-use gap reading as no data
is literally accurate: nothing was recorded then either.

The dash granular chart now trims leading zero-only buckets by value
instead of comparing bucket timestamps against date keys, so producer
and viewer timezone skew can never drop a real first-day bucket, and an
all-zero series lands in the established empty state.

StackedBars no-data columns expose their state via aria-label, not only
a title on a non-focusable div.
2026-07-20 08:03:24 -07:00

241 lines
8.6 KiB
TypeScript

import { Fragment, useState } from 'react'
import { CliErrorPanel, CliErrorText } from '../components/CliErrorPanel'
import { EmptyNote } from '../components/EmptyState'
import { ListRow } from '../components/ListRow'
import { Panel } from '../components/Panel'
import { Sankey } from '../components/Sankey'
import { SectionSkeleton } from '../components/Skeleton'
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 { contiguousDailyWindow, dataStartKey, localDateKey } from '../lib/period'
import type { CliError, DateRange, MenubarPayload, Period, SpendFlow } from '../lib/types'
type Project = MenubarPayload['current']['topProjects'][number]
/** Date-only CLI strings ("2026-07-11") formatted at local noon so the calendar day never rolls across time zones. */
function formatProjectDay(date: string): string {
const d = new Date(`${date}T12:00:00`)
return Number.isNaN(d.getTime()) ? '—' : d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
}
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(' ')
}
export function Spend({ period, provider, range = null }: { period: Period; provider: string; range?: DateRange | null }) {
const overview = usePolled<MenubarPayload>(
() => range ? codeburn.getOverview(period, provider, range) : codeburn.getOverview(period, provider),
[period, provider, range?.from, range?.to],
)
return <SpendContent period={period} provider={provider} range={range} overview={overview} />
}
export function SpendContent({
period,
provider,
range = null,
overview,
refreshToken = 0,
ready = true,
}: {
period: Period
provider: string
range?: DateRange | null
overview: Polled<MenubarPayload>
refreshToken?: number
ready?: boolean
}) {
// Gate on app-level readiness so boot hydrates the cache once (default true
// keeps standalone renders/tests polling normally).
const flow = usePolled<SpendFlow>(
() => range ? codeburn.getSpendFlow(period, provider, range) : codeburn.getSpendFlow(period, provider),
[period, provider, range?.from, range?.to, refreshToken],
{ enabled: ready, memoKey: `spendflow|${period}|${provider}|${range?.from ?? ''}-${range?.to ?? ''}` },
)
if (!overview.data) {
if (overview.error) return <CliErrorPanel error={overview.error} subject="spend" />
return <SectionSkeleton label="Scanning spend…" rows={3} chart />
}
const animateKey = `${period}|${provider}|${range?.from ?? ''}|${range?.to ?? ''}`
return <SpendPage data={overview.data} flow={flow} provider={provider} range={range} staleError={overview.error} animateKey={animateKey} />
}
function SpendPage({
data,
flow,
provider,
range,
staleError,
animateKey,
}: {
data: MenubarPayload
flow: ReturnType<typeof usePolled<SpendFlow>>
provider: string
range: DateRange | null
staleError: CliError | null
animateKey: string
}) {
// `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 dataStart = dataStartKey(data.history.daily)
const projects = data.current.topProjects
const breakdowns = [
{
title: 'Activity',
rows: [
...data.current.topActivities.map(row => ({
key: `activity-${row.name}`,
title: row.name,
sub: `${row.turns.toLocaleString('en-US')} turns`,
value: formatUsd(row.cost),
})),
...data.current.skills.map(row => ({
key: `skill-${row.name}`,
title: row.name,
sub: `${row.turns.toLocaleString('en-US')} turns · skill`,
value: formatUsd(row.cost),
})),
],
},
{
title: 'Tools',
rows: data.current.tools.map(row => ({
key: row.name,
title: row.name,
sub: `${row.calls.toLocaleString('en-US')} calls`,
value: undefined,
})),
},
{
title: 'MCP',
rows: data.current.mcpServers.map(row => ({
key: row.name,
title: row.name,
sub: `${row.calls.toLocaleString('en-US')} calls`,
value: undefined,
})),
},
{
title: 'Subagents',
rows: data.current.subagents.map(row => ({
key: row.name,
title: row.name,
sub: `${row.calls.toLocaleString('en-US')} calls`,
value: formatUsd(row.cost),
})),
},
].filter(section => section.rows.length)
return (
<>
{staleError && <StaleBanner error={staleError} />}
<div className="spend-top-row">
<Panel title="Daily spend by model" className="spend-chart-panel">
{chartHasSpend ? <StackedBars daily={chartDaily} fallbackLabel={providerLabel(provider)} animateKey={animateKey} dataStart={dataStart} /> : <EmptyNote>No model spend in this range yet.</EmptyNote>}
</Panel>
<ProjectBreakdown projects={projects} />
</div>
<Panel title="Cost flow · model → project" right="model → project flow for this range" className="scroll-x">
{flow.data && flow.data.links.length ? (
<Sankey flow={flow.data} />
) : flow.error ? (
<CliErrorText error={flow.error} />
) : (
<EmptyNote>{flow.loading ? 'Loading cost flow…' : 'No model-project flow in this range yet.'}</EmptyNote>
)}
</Panel>
<div className="spend-breakdowns">
{breakdowns.length ? (
breakdowns.map(section => <RowsPanel key={section.title} title={section.title} rows={section.rows} />)
) : (
<EmptyNote>No activity, tool, MCP, or subagent data in this range yet.</EmptyNote>
)}
</div>
</>
)
}
function ProjectBreakdown({ projects }: { projects: Project[] }) {
const [expanded, setExpanded] = useState<string | null>(null)
return (
<Panel title="By project" right={projects.length ? `top ${projects.length}` : undefined} className="spend-scroll">
{projects.length ? (
projects.map((project, i) => {
const open = expanded === project.name
return (
<Fragment key={project.name}>
<ListRow
no={String(i + 1).padStart(2, '0')}
title={project.name}
sub={`${project.sessions.toLocaleString('en-US')} ${project.sessions === 1 ? 'session' : 'sessions'}`}
value={formatUsd(project.cost)}
expanded={open}
onClick={() => setExpanded(current => current === project.name ? null : project.name)}
/>
{open && (
<div className="spend-proj-detail" role="region" aria-label={`${project.name} sessions`}>
{project.sessionDetails.length ? (
project.sessionDetails.map((session, j) => (
<div className="spend-proj-session" key={`${session.date}-${j}`}>
<span className="sps-date">{formatProjectDay(session.date)}</span>
<span className="sps-model">{session.models[0]?.name ?? '—'}</span>
<span className="sps-calls">{session.calls.toLocaleString('en-US')} calls</span>
<span className="sps-cost">{formatUsd(session.cost)}</span>
</div>
))
) : (
<div className="spend-proj-empty">No session detail for this project.</div>
)}
</div>
)}
</Fragment>
)
})
) : (
<EmptyNote>No project spend in this range yet.</EmptyNote>
)}
</Panel>
)
}
function RowsPanel({
title,
rows,
}: {
title: string
rows: Array<{ key: string; title: string; sub: string; value?: string }>
}) {
return (
<Panel title={title} className="spend-scroll">
{rows.map((row, i) => (
<ListRow key={row.key} no={String(i + 1).padStart(2, '0')} title={row.title} sub={row.sub} value={row.value} />
))}
</Panel>
)
}