diff --git a/app/renderer/App.tsx b/app/renderer/App.tsx index de73fb12..b885633b 100644 --- a/app/renderer/App.tsx +++ b/app/renderer/App.tsx @@ -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 = { @@ -68,6 +69,8 @@ export function App() {
{section === 'overview' ? ( + ) : section === 'spend' ? ( + ) : ( )} diff --git a/app/renderer/components/Sankey.tsx b/app/renderer/components/Sankey.tsx new file mode 100644 index 00000000..5da6a878 --- /dev/null +++ b/app/renderer/components/Sankey.tsx @@ -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() + const targetOffset = new Map() + + 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 [ + , + ] + }) + + return ( + + + {models.map(model => ( + + + + + ))} + + + {ribbons} + + {models.map(node => ( + + ))} + {projects.map(node => ( + + ))} + + {models.map(node => ( + + {node.label} · {fmtUsd(node.cost)} + + ))} + {projects.map(node => ( + + {node.label} · {fmtUsd(node.cost)} + + ))} + + ) +} + +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 })}` +} diff --git a/app/renderer/components/StackedBars.tsx b/app/renderer/components/StackedBars.tsx new file mode 100644 index 00000000..607b1a3a --- /dev/null +++ b/app/renderer/components/StackedBars.tsx @@ -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 ( + <> +
+ {daily.map(day => ( +
+ {day.topModels.map(model => { + const pct = Math.max(2, (Math.max(0, model.cost) / maxTotal) * 100) + return ( + + ) + })} +
+ ))} +
+
+ + + Opus 4.8 + + + + Sonnet 5 + + + + Haiku 4.5 + + + + GPT-5.5 Codex + +
+ + ) +} + +function fmtUsd(n: number): string { + return `$${n.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}` +} diff --git a/app/renderer/lib/period.ts b/app/renderer/lib/period.ts new file mode 100644 index 00000000..96d50b6a --- /dev/null +++ b/app/renderer/lib/period.ts @@ -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) + } + } +} diff --git a/app/renderer/sections/Spend.test.tsx b/app/renderer/sections/Spend.test.tsx new file mode 100644 index 00000000..732df234 --- /dev/null +++ b/app/renderer/sections/Spend.test.tsx @@ -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>(), + getSpendFlow: vi.fn<(period: string, provider: string) => Promise>(), +})) +vi.mock('../lib/ipc', async orig => { + const actual = await orig() + 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() + + 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) + }) +}) diff --git a/app/renderer/sections/Spend.tsx b/app/renderer/sections/Spend.tsx new file mode 100644 index 00000000..97361e73 --- /dev/null +++ b/app/renderer/sections/Spend.tsx @@ -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

{children}

+} + +export function Spend({ period, provider }: { period: Period; provider: string }) { + const overview = usePolled(() => codeburn.getOverview(period, provider), [period, provider]) + const flow = usePolled(() => codeburn.getSpendFlow(period, provider), [period, provider]) + const [lens, setLens] = useState('projects') + + if (!overview.data) { + if (overview.error?.kind === 'not-found') { + return ( + +

+ CodeBurn Desktop reads your usage by running the{' '} + codeburn command, but it isn't + on your PATH yet. +

+

+ Install it with npm i -g codeburn, + then reopen this window. +

+
+ ) + } + if (overview.error) { + return ( + +

{overview.error.message}

+
+ ) + } + return ( + + Scanning spend… + + ) + } + + return ( + <> + setLens(value as Lens)} style={{ alignSelf: 'flex-start' }} /> + {lens === 'projects' ? ( + + ) : ( + + )} + + ) +} + +function ProjectsLens({ + data, + flow, + period, +}: { + data: MenubarPayload + flow: ReturnType> + period: Period +}) { + const daily = sliceDailyToPeriod(data.history.daily, period) + const projects = data.current.topProjects + + return ( + <> +
+ + {daily.length ? : No model spend in this range yet.} + + + {projects.length ? ( + projects.map((project, i) => ( + + )) + ) : ( + No project spend in this range yet. + )} + +
+ + + {flow.data && flow.data.links.length ? ( + + ) : flow.error ? ( +

{flow.error.message}

+ ) : ( + {flow.loading ? 'Loading cost flow…' : 'No model-project flow in this range yet.'} + )} +
+ + ) +} + +function DetailLens({ data, lens }: { data: MenubarPayload; lens: Exclude }) { + 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 + } + + 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 + } + + 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 + } + + 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 +} + +function RowsPanel({ + title, + rows, + empty, +}: { + title: string + rows: Array<{ key: string; title: string; sub: string; value?: string }> + empty: string +}) { + return ( + + {rows.length ? ( + rows.map((row, i) => ( + + )) + ) : ( + {empty} + )} + + ) +} diff --git a/app/renderer/styles/indigo.css b/app/renderer/styles/indigo.css index d7d6a501..9e22d6b8 100644 --- a/app/renderer/styles/indigo.css +++ b/app/renderer/styles/indigo.css @@ -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; }