mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-08-21 22:44:31 +00:00
The release's new capabilities were unevenly surfaced: the Spend punchcard existed only on the web dashboard, and the menubar decoded nothing of the PR-attribution block the payload already carries. - Desktop app: new Punchcard component (hour-of-day x weekday spend matrix, ported from the dash and restyled to the app's tokens) on the Spend page. Fed by a dedicated getTimeline bridge channel that fetches the payload WITH history.timeline; every other fetch keeps the lean --no-timeline path, and the serve child makes the extra fetch cheap. Hides gracefully when the payload has no timeline (older CLI). - Menubar: PullRequestsSection renders the top three PRs by attributed spend under the Workflow strip; MenubarPayload now decodes the pullRequests block (decodeIfPresent, so older payloads are unchanged). Hidden when absent or empty. Deliberately NOT ported: codex Tok/s (reads rollout files per session - too heavy for payload cadence; stays a TUI/report analytics view) and the punchcard in the menubar (a 7x24 matrix has no legible place in a compact popover). App suite 468 green (bridge channel pinned, mocks extended), swift build + 156 tests green, CLI suite green.
260 lines
9.3 KiB
TypeScript
260 lines
9.3 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 { Punchcard } from '../components/Punchcard'
|
||
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(' ')
|
||
}
|
||
|
||
function SpendPunchcard({ period, provider, range }: { period: Period; provider: string; range: DateRange | null }) {
|
||
const payload = usePolled<MenubarPayload>(
|
||
() => range ? codeburn.getTimeline(period, provider, range) : codeburn.getTimeline(period, provider),
|
||
[period, provider, range?.from, range?.to],
|
||
)
|
||
const timeline = payload.data?.history.timeline
|
||
if (!timeline) return null
|
||
return (
|
||
<Panel title="Spend punchcard" right="hour of day × weekday">
|
||
<Punchcard timeline={timeline} />
|
||
</Panel>
|
||
)
|
||
}
|
||
|
||
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} period={period} provider={provider} range={range} staleError={overview.error} animateKey={animateKey} />
|
||
}
|
||
|
||
function SpendPage({
|
||
data,
|
||
flow,
|
||
period,
|
||
provider,
|
||
range,
|
||
staleError,
|
||
animateKey,
|
||
}: {
|
||
data: MenubarPayload
|
||
flow: ReturnType<typeof usePolled<SpendFlow>>
|
||
period: Period
|
||
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>
|
||
|
||
<SpendPunchcard period={period} provider={provider} range={range} />
|
||
|
||
<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>
|
||
)
|
||
}
|