feat(app): Spend section — daily-by-model stacked chart, by-project, model→project Sankey

Period-sliced StackedBars (history.daily[].topModels within the selected window via
a new shared lib/period.ts), the By-project list, and a Sankey rendered dynamically
from getSpendFlow (per-model hue ribbons). Lens tabs; series palette shared with the
legend. Adds a neutral .s-other class for the rollup segment.

Implemented by Codex gpt-5.5 (high); committed by Fable (git blocked in Codex sandbox).
This commit is contained in:
iamtoruk 2026-07-10 16:43:27 -07:00
parent 343156618f
commit d7f3ccd1f0
7 changed files with 619 additions and 0 deletions

View file

@ -8,6 +8,7 @@ import { Window } from './components/Window'
import { usePolled } from './hooks/usePolled'
import { codeburn } from './lib/ipc'
import { Overview } from './sections/Overview'
import { Spend } from './sections/Spend'
import type { MenubarPayload, Period } from './lib/types'
const SECTION_TITLES: Record<Section, string> = {
@ -68,6 +69,8 @@ export function App() {
<div className="body">
{section === 'overview' ? (
<Overview period={period} provider={provider} />
) : section === 'spend' ? (
<Spend period={period} provider={provider} />
) : (
<SectionPlaceholder title={SECTION_TITLES[section]} />
)}

View file

@ -0,0 +1,123 @@
import { isOtherNode, seriesHexForModel } from './StackedBars'
import type { SpendFlow, SpendFlowNode } from '../lib/types'
type LayoutNode = SpendFlowNode & {
x: number
y: number
h: number
fill: string
}
const VIEW_W = 640
const VIEW_H = 190
const TOP = 14
const BOTTOM = 18
const LEFT_X = 72
const RIGHT_X = 562
const NODE_W = 5
const GAP = 8
export function Sankey({ flow }: { flow: SpendFlow }) {
const models = layoutNodes(flow.models, LEFT_X, true)
const projects = layoutNodes(flow.projects, RIGHT_X, false)
const modelById = new Map(models.map(node => [node.id, node]))
const projectById = new Map(projects.map(node => [node.id, node]))
const sourceOffset = new Map<string, number>()
const targetOffset = new Map<string, number>()
const ribbons = flow.links.flatMap((link, i) => {
const source = modelById.get(link.model)
const target = projectById.get(link.project)
if (!source || !target || link.cost <= 0) return []
const sourceSegment = segmentSize(source, link.cost)
const targetSegment = segmentSize(target, link.cost)
const width = Math.max(2, Math.min(28, (sourceSegment + targetSegment) / 2))
const sy = source.y + (sourceOffset.get(source.id) ?? 0) + sourceSegment / 2
const ty = target.y + (targetOffset.get(target.id) ?? 0) + targetSegment / 2
sourceOffset.set(source.id, (sourceOffset.get(source.id) ?? 0) + sourceSegment)
targetOffset.set(target.id, (targetOffset.get(target.id) ?? 0) + targetSegment)
const gradId = gradientId(source.id)
return [
<path
key={`${link.model}-${link.project}-${i}`}
data-testid="sankey-ribbon"
d={`M ${LEFT_X + NODE_W + 1} ${round(sy)} C 300 ${round(sy)} 380 ${round(ty)} ${RIGHT_X - 1} ${round(ty)}`}
stroke={`url(#${gradId})`}
strokeWidth={round(width)}
fill="none"
strokeOpacity=".40"
/>,
]
})
return (
<svg viewBox={`0 0 ${VIEW_W} ${VIEW_H}`} width="100%" style={{ minWidth: 560, display: 'block' }}>
<defs>
{models.map(model => (
<linearGradient key={model.id} id={gradientId(model.id)} x1="0" y1="0" x2="1" y2="0">
<stop offset="0" stopColor={model.fill} />
<stop offset="1" stopColor={model.fill} stopOpacity=".25" />
</linearGradient>
))}
</defs>
{ribbons}
{models.map(node => (
<rect key={node.id} x={node.x} y={round(node.y)} width={NODE_W} height={round(node.h)} rx="2.5" fill={node.fill} />
))}
{projects.map(node => (
<rect key={node.id} x={node.x} y={round(node.y)} width={NODE_W} height={round(node.h)} rx="2.5" fill={node.fill} />
))}
{models.map(node => (
<text key={node.id} x="64" y={round(node.y + node.h / 2 + 3)} textAnchor="end" fontSize="10" fill="#9BA3B7">
{node.label} · {fmtUsd(node.cost)}
</text>
))}
{projects.map(node => (
<text key={node.id} x="575" y={round(node.y + node.h / 2 + 3)} fontSize="10" fill="#9BA3B7">
{node.label} · {fmtUsd(node.cost)}
</text>
))}
</svg>
)
}
function layoutNodes(nodes: SpendFlowNode[], x: number, modelSide: boolean): LayoutNode[] {
if (nodes.length === 0) return []
const usable = VIEW_H - TOP - BOTTOM - GAP * Math.max(0, nodes.length - 1)
const total = nodes.reduce((sum, node) => sum + Math.max(0, node.cost), 0)
const rawHeights = nodes.map(node => (total > 0 ? (Math.max(0, node.cost) / total) * usable : usable / nodes.length))
const minH = Math.min(10, usable / nodes.length)
const inflated = rawHeights.map(h => Math.max(minH, h))
const scale = inflated.reduce((sum, h) => sum + h, 0) > usable ? usable / inflated.reduce((sum, h) => sum + h, 0) : 1
let y = TOP
return nodes.map((node, i) => {
const h = Math.max(2, inflated[i] * scale)
const neutral = isOtherNode(node.id) || isOtherNode(node.label)
const fill = modelSide && !neutral ? seriesHexForModel(node.label || node.id) : neutral ? '#5F6780' : '#3A4258'
const laidOut = { ...node, x, y, h, fill }
y += h + GAP
return laidOut
})
}
function segmentSize(node: LayoutNode, cost: number): number {
return node.cost > 0 ? Math.max(1, (Math.max(0, cost) / node.cost) * node.h) : 1
}
function gradientId(id: string): string {
return `sankey-${id.replace(/[^a-zA-Z0-9_-]/g, '-')}`
}
function round(n: number): number {
return Math.round(n * 10) / 10
}
function fmtUsd(n: number): string {
return `$${n.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
}

View file

@ -0,0 +1,95 @@
import type { DailyHistoryEntry } from '../lib/types'
export const SERIES_HEX = {
opus: '#5B8CFF',
sonnet: '#8B7CF6',
haiku: '#B5A8FF',
gpt: '#4DD8E6',
other: '#5F6780',
} as const
export type SeriesKey = keyof typeof SERIES_HEX
export function seriesKeyForModel(model?: string): SeriesKey {
const m = (model ?? '').toLowerCase()
if (m.includes('opus')) return 'opus'
if (m.includes('sonnet')) return 'sonnet'
if (m.includes('haiku')) return 'haiku'
if (m.includes('gpt') || m.includes('codex')) return 'gpt'
return 'other'
}
export function seriesClassForModel(model?: string): string {
switch (seriesKeyForModel(model)) {
case 'opus':
return 's-opus'
case 'sonnet':
return 's-son'
case 'haiku':
return 's-hai'
case 'gpt':
return 's-gpt'
case 'other':
return 's-other'
}
}
export function seriesHexForModel(model?: string): string {
return SERIES_HEX[seriesKeyForModel(model)]
}
export function isOtherNode(idOrLabel?: string): boolean {
const value = (idOrLabel ?? '').trim().toLowerCase()
return value === '__other__' || value === 'other' || value === 'others'
}
export function StackedBars({ daily }: { daily: DailyHistoryEntry[] }) {
const maxTotal = Math.max(
1,
...daily.map(day => day.topModels.reduce((sum, model) => sum + Math.max(0, model.cost), 0)),
)
return (
<>
<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} · ${fmtUsd(day.cost)}`}>
{day.topModels.map(model => {
const pct = Math.max(2, (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} · ${fmtUsd(model.cost)}`}
/>
)
})}
</div>
))}
</div>
<div className="legend">
<span>
<i style={{ background: 'var(--blue)' }} />
Opus 4.8
</span>
<span>
<i style={{ background: 'var(--purple)' }} />
Sonnet 5
</span>
<span>
<i style={{ background: 'var(--lav)' }} />
Haiku 4.5
</span>
<span>
<i style={{ background: 'var(--cyan)' }} />
GPT-5.5 Codex
</span>
</div>
</>
)
}
function fmtUsd(n: number): string {
return `$${n.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
}

View file

@ -0,0 +1,54 @@
import type { DailyHistoryEntry, Period } from './types'
/** 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 {
switch (period) {
case 'today':
return localDateKey(now)
case 'week':
return localDateKey(new Date(now.getFullYear(), now.getMonth(), now.getDate() - 6))
case '30days':
return localDateKey(new Date(now.getFullYear(), now.getMonth(), now.getDate() - 29))
case 'month':
return localDateKey(new Date(now.getFullYear(), now.getMonth(), 1))
case 'all':
return null
}
}
/** `history.daily` entries within the selected period's date window. */
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)
}
/** Length of the selected period in days; `all` spans available history when provided. */
export function periodLengthDays(period: Period, daily: DailyHistoryEntry[] = [], now = new Date()): number {
switch (period) {
case 'today':
return 1
case 'week':
return 7
case '30days':
return 30
case 'month':
return now.getDate()
case 'all': {
if (daily.length === 0) return 1
const earliest = daily.reduce((min, d) => (d.date < min ? d.date : min), daily[0].date)
const [y, m, d] = earliest.split('-').map(Number)
const start = new Date(y, m - 1, d)
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate())
return Math.max(1, Math.round((today.getTime() - start.getTime()) / 86_400_000) + 1)
}
}
}

View file

@ -0,0 +1,149 @@
// @vitest-environment jsdom
import { render, screen } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { MenubarPayload, SpendFlow } from '../lib/types'
import { Spend } from './Spend'
const { getOverview, getSpendFlow } = vi.hoisted(() => ({
getOverview: vi.fn<(period: string, provider: string) => Promise<MenubarPayload>>(),
getSpendFlow: vi.fn<(period: string, provider: string) => Promise<SpendFlow>>(),
}))
vi.mock('../lib/ipc', async orig => {
const actual = await orig<typeof import('../lib/ipc')>()
return { ...actual, codeburn: { getOverview, getSpendFlow } }
})
function daily(date: string, cost: number, models: Array<{ name: string; cost: number }>) {
return {
date,
cost,
savingsUSD: 0,
calls: 10,
inputTokens: 0,
outputTokens: 0,
cacheReadTokens: 0,
cacheWriteTokens: 0,
topModels: models.map(m => ({
name: m.name,
cost: m.cost,
savingsUSD: 0,
calls: 5,
inputTokens: 0,
outputTokens: 0,
})),
}
}
function makePayload(now: Date): MenubarPayload {
return {
generated: now.toISOString(),
current: {
label: 'Last 30 days',
cost: 612.48,
calls: 1220,
sessions: 88,
oneShotRate: null,
inputTokens: 0,
outputTokens: 0,
cacheReadTokens: 0,
cacheWriteTokens: 0,
cacheHitPercent: 0,
codexCredits: 0,
topActivities: [{ name: 'coding', cost: 42, savingsUSD: 0, turns: 12, oneShotRate: null }],
topModels: [],
localModelSavings: { totalUSD: 0, calls: 0, byModel: [], byProvider: [] },
providers: {},
topProjects: [
{
name: 'codeburn',
cost: 246.1,
savingsUSD: 0,
sessions: 124,
avgCostPerSession: 1.98,
sessionDetails: [],
},
{
name: 'agentseal-dash',
cost: 141.3,
savingsUSD: 0,
sessions: 74,
avgCostPerSession: 1.91,
sessionDetails: [],
},
],
modelEfficiency: [],
topSessions: [],
retryTax: { totalUSD: 0, retries: 0, editTurns: 0, byModel: [] },
routingWaste: { totalSavingsUSD: 0, baselineModel: '', baselineCostPerEdit: 0, byModel: [] },
tools: [{ name: 'Read', calls: 30 }],
skills: [{ name: 'imagegen', turns: 3, cost: 1.25 }],
subagents: [{ name: 'reviewer', calls: 2, cost: 2.5 }],
mcpServers: [{ name: 'filesystem', calls: 9 }],
},
optimize: { findingCount: 0, savingsUSD: 0, topFindings: [] },
history: {
daily: [
daily('2026-06-30', 11, [{ name: 'claude-opus-4', cost: 11 }]),
daily('2026-07-01', 12, [{ name: 'gpt-5.5-codex', cost: 12 }]),
daily('2026-07-04', 13, [{ name: 'claude-opus-4', cost: 9 }, { name: 'claude-sonnet-5', cost: 4 }]),
daily('2026-07-06', 8, [{ name: 'claude-haiku-4', cost: 8 }]),
daily('2026-07-10', 15, [{ name: 'gpt-5.5-codex', cost: 15 }]),
],
},
}
}
function makeFlow(): SpendFlow {
return {
period: { label: 'Last 7 days', start: '2026-07-04', end: '2026-07-10' },
models: [
{ id: 'claude-opus-4', label: 'Opus 4.8', cost: 22 },
{ id: 'gpt-5.5-codex', label: 'GPT-5.5 Codex', cost: 18 },
],
projects: [
{ id: 'codeburn', label: 'codeburn', cost: 30 },
{ id: '__other__', label: 'Other', cost: 10 },
],
links: [
{ model: 'claude-opus-4', project: 'codeburn', cost: 18 },
{ model: 'claude-opus-4', project: '__other__', cost: 4 },
{ model: 'gpt-5.5-codex', project: 'codeburn', cost: 12 },
{ model: 'gpt-5.5-codex', project: '__other__', cost: 6 },
],
}
}
describe('Spend', () => {
beforeEach(() => {
vi.useFakeTimers({ toFake: ['Date'] })
vi.setSystemTime(new Date(2026, 6, 10, 12, 0, 0))
getOverview.mockReset()
getSpendFlow.mockReset()
})
afterEach(() => {
vi.useRealTimers()
})
it('slices stacked spend bars to the selected period, renders projects, and draws one Sankey ribbon per link', async () => {
getOverview.mockResolvedValue(makePayload(new Date()))
getSpendFlow.mockResolvedValue(makeFlow())
const { container } = render(<Spend period="week" provider="all" />)
expect(await screen.findByText('codeburn')).toBeInTheDocument()
expect(screen.getByText('$246.10')).toBeInTheDocument()
expect(screen.getByText('agentseal-dash')).toBeInTheDocument()
const barColumns = container.querySelectorAll('.sbars .c')
expect(barColumns).toHaveLength(3)
expect([...barColumns].map(col => col.getAttribute('data-date'))).toEqual([
'2026-07-04',
'2026-07-06',
'2026-07-10',
])
expect(container.querySelectorAll('[data-testid="sankey-ribbon"]')).toHaveLength(makeFlow().links.length)
})
})

View file

@ -0,0 +1,194 @@
import { useState } from 'react'
import { ListRow } from '../components/ListRow'
import { Panel } from '../components/Panel'
import { Sankey } from '../components/Sankey'
import { SegTabs } from '../components/SegTabs'
import { StackedBars } from '../components/StackedBars'
import { usePolled } from '../hooks/usePolled'
import { codeburn } from '../lib/ipc'
import { sliceDailyToPeriod } from '../lib/period'
import type { MenubarPayload, Period, SpendFlow } from '../lib/types'
type Lens = 'projects' | 'activity' | 'tools' | 'mcp' | 'subagents'
const LENSES = [
{ value: 'projects', label: 'Projects' },
{ value: 'activity', label: 'Activity' },
{ value: 'tools', label: 'Tools' },
{ value: 'mcp', label: 'MCP' },
{ value: 'subagents', label: 'Subagents' },
]
function fmtUsd(n: number): string {
return `$${n.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
}
function EmptyNote({ children }: { children: React.ReactNode }) {
return <p style={{ color: 'var(--t3)', margin: 0, fontSize: 12 }}>{children}</p>
}
export function Spend({ period, provider }: { period: Period; provider: string }) {
const overview = usePolled<MenubarPayload>(() => codeburn.getOverview(period, provider), [period, provider])
const flow = usePolled<SpendFlow>(() => codeburn.getSpendFlow(period, provider), [period, provider])
const [lens, setLens] = useState<Lens>('projects')
if (!overview.data) {
if (overview.error?.kind === 'not-found') {
return (
<Panel title="Locate the codeburn CLI">
<p style={{ color: 'var(--t2)', margin: '0 0 6px', fontSize: 12.5 }}>
CodeBurn Desktop reads your usage by running the{' '}
<code style={{ fontFamily: 'var(--mono)', color: 'var(--lav)' }}>codeburn</code> command, but it isn&apos;t
on your PATH yet.
</p>
<p style={{ color: 'var(--t3)', margin: 0, fontSize: 11.5 }}>
Install it with <code style={{ fontFamily: 'var(--mono)', color: 'var(--lav)' }}>npm i -g codeburn</code>,
then reopen this window.
</p>
</Panel>
)
}
if (overview.error) {
return (
<Panel title="Couldn't read spend">
<p style={{ color: 'var(--red)', margin: 0, fontSize: 12 }}>{overview.error.message}</p>
</Panel>
)
}
return (
<Panel title="Spend">
<EmptyNote>Scanning spend</EmptyNote>
</Panel>
)
}
return (
<>
<SegTabs options={LENSES} value={lens} onChange={value => setLens(value as Lens)} style={{ alignSelf: 'flex-start' }} />
{lens === 'projects' ? (
<ProjectsLens data={overview.data} flow={flow} period={period} />
) : (
<DetailLens data={overview.data} lens={lens} />
)}
</>
)
}
function ProjectsLens({
data,
flow,
period,
}: {
data: MenubarPayload
flow: ReturnType<typeof usePolled<SpendFlow>>
period: Period
}) {
const daily = sliceDailyToPeriod(data.history.daily, period)
const projects = data.current.topProjects
return (
<>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}>
<Panel title="Daily spend by model">
{daily.length ? <StackedBars daily={daily} /> : <EmptyNote>No model spend in this range yet.</EmptyNote>}
</Panel>
<Panel title="By project" right={`${projects.length} ${projects.length === 1 ? 'project' : 'projects'}`}>
{projects.length ? (
projects.map((project, i) => (
<ListRow
key={project.name}
no={String(i + 1).padStart(2, '0')}
title={project.name}
sub={`${project.sessions.toLocaleString('en-US')} ${project.sessions === 1 ? 'session' : 'sessions'}`}
value={fmtUsd(project.cost)}
/>
))
) : (
<EmptyNote>No project spend in this range yet.</EmptyNote>
)}
</Panel>
</div>
<Panel title="Cost flow · model → project" right="click a ribbon to filter" bodyStyle={{ overflowX: 'auto' }}>
{flow.data && flow.data.links.length ? (
<Sankey flow={flow.data} />
) : flow.error ? (
<p style={{ color: 'var(--red)', margin: 0, fontSize: 12 }}>{flow.error.message}</p>
) : (
<EmptyNote>{flow.loading ? 'Loading cost flow…' : 'No model-project flow in this range yet.'}</EmptyNote>
)}
</Panel>
</>
)
}
function DetailLens({ data, lens }: { data: MenubarPayload; lens: Exclude<Lens, 'projects'> }) {
if (lens === 'activity') {
const rows = [
...data.current.topActivities.map(row => ({
key: `activity-${row.name}`,
title: row.name,
sub: `${row.turns.toLocaleString('en-US')} turns`,
value: fmtUsd(row.cost),
})),
...data.current.skills.map(row => ({
key: `skill-${row.name}`,
title: row.name,
sub: `${row.turns.toLocaleString('en-US')} turns · skill`,
value: fmtUsd(row.cost),
})),
]
return <RowsPanel title="Activity" rows={rows} empty="No activity or skill spend in this range yet." />
}
if (lens === 'tools') {
const rows = data.current.tools.map(row => ({
key: row.name,
title: row.name,
sub: `${row.calls.toLocaleString('en-US')} calls`,
value: undefined,
}))
return <RowsPanel title="Tools" rows={rows} empty="No tool calls in this range yet." />
}
if (lens === 'mcp') {
const rows = data.current.mcpServers.map(row => ({
key: row.name,
title: row.name,
sub: `${row.calls.toLocaleString('en-US')} calls`,
value: undefined,
}))
return <RowsPanel title="MCP" rows={rows} empty="No MCP server calls in this range yet." />
}
const rows = data.current.subagents.map(row => ({
key: row.name,
title: row.name,
sub: `${row.calls.toLocaleString('en-US')} calls`,
value: fmtUsd(row.cost),
}))
return <RowsPanel title="Subagents" rows={rows} empty="No subagent spend in this range yet." />
}
function RowsPanel({
title,
rows,
empty,
}: {
title: string
rows: Array<{ key: string; title: string; sub: string; value?: string }>
empty: string
}) {
return (
<Panel title={title}>
{rows.length ? (
rows.map((row, i) => (
<ListRow key={row.key} no={String(i + 1).padStart(2, '0')} title={row.title} sub={row.sub} value={row.value} />
))
) : (
<EmptyNote>{empty}</EmptyNote>
)}
</Panel>
)
}

View file

@ -121,6 +121,7 @@ h2 { font-size: 20px; font-weight: 650; letter-spacing: -.015em; margin: 0 0 6px
.s-son { background: var(--purple); }
.s-hai { background: var(--lav); }
.s-gpt { background: var(--cyan); }
.s-other { background: var(--t3); }
.legend { display: flex; gap: 16px; padding: 9px 4px 0; font-size: 10.5px; color: var(--t2); flex-wrap: wrap; }
.legend i { width: 8px; height: 8px; border-radius: 2.5px; display: inline-block; margin-right: 6px; vertical-align: -1px; }