mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-08-03 05:15:24 +00:00
* 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.
241 lines
8.6 KiB
TypeScript
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>
|
|
)
|
|
}
|