codeburn/app/renderer/sections/Spend.tsx
iamtoruk 411d52f924 feat(app,menubar): surface parity — Spend punchcard in the app, Pull requests strip in the menubar
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.
2026-08-10 14:29:26 -07:00

260 lines
9.3 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>
)
}