Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions app/electron/main.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ function fakeSpawn(result: unknown = { current: { cost: 12.34 } }) {
// must spawn. cliStatus is the one channel that resolves without spawning.
const CHANNELS = [
'codeburn:getOverview',
'codeburn:getTimeline',
'codeburn:getQuota',
'codeburn:getPlans',
'codeburn:getActReport',
Expand Down
5 changes: 5 additions & 0 deletions app/electron/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,11 @@ export function createBridgeHandlers(deps: Deps = { spawnCli, spawnCliAction, re
catch (error) { return { ok: false, error: { kind: 'nonzero', message: sanitizeError(error) } } }
},
'codeburn:getOverview': getOverview,
// Timeline variant for the Spend punchcard only: identical payload WITH
// history.timeline (every other fetch keeps --no-timeline lean).
'codeburn:getTimeline': run((period: string, provider: string, range?: DateRange) => [
'status', '--format', 'menubar-json', '--period', vPeriod(period), ...providerArgs(vProvider(provider)), ...rangeArgs(vRange(range)),
]),
'codeburn:getPlans': run((period: string) => ['status', '--format', 'json', '--period', vPeriod(period)]),
'codeburn:getActReport': run(() => ['act', 'report', '--json']),
'codeburn:getModels': run((period: string, provider: string, byTask: boolean, range?: DateRange) => [
Expand Down
1 change: 1 addition & 0 deletions app/electron/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ async function invoke<T>(channel: string, ...args: unknown[]): Promise<T> {
const bridge = {
getQuota: (force?: boolean) => invoke('codeburn:getQuota', force),
getOverview: (period: string, provider: string, range?: DateRange, configSource?: string | null, background?: boolean, scope?: string) => invoke('codeburn:getOverview', period, provider, range, configSource, background, scope),
getTimeline: (period: string, provider: string, range?: DateRange) => invoke('codeburn:getTimeline', period, provider, range),
getPlans: (period: string) => invoke('codeburn:getPlans', period),
getActReport: () => invoke('codeburn:getActReport'),
getModels: (period: string, provider: string, byTask: boolean, range?: DateRange) => invoke('codeburn:getModels', period, provider, byTask, range),
Expand Down
2 changes: 2 additions & 0 deletions app/renderer/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ vi.stubGlobal('localStorage', {
const mocks = vi.hoisted(() => ({
getOverview: vi.fn<(period: string, provider: string, range?: DateRange, configSource?: string | null, background?: boolean, scope?: string) => Promise<MenubarPayload>>(),
getSpendFlow: vi.fn<(period: string, provider: string, range?: DateRange) => Promise<SpendFlow>>(),
getTimeline: vi.fn<(period: string, provider: string, range?: DateRange) => Promise<MenubarPayload>>(),
getOptimizeReport: vi.fn<(period: string, provider: string, range?: DateRange) => Promise<OptimizeJsonReport>>(),
getModels: vi.fn(),
getSessions: vi.fn(),
Expand Down Expand Up @@ -130,6 +131,7 @@ function withConfigs(payload: MenubarPayload): MenubarPayload {
function installDefaultMocks() {
for (const mock of Object.values(mocks)) mock.mockReset()
mocks.getOverview.mockResolvedValue(overviewPayload())
mocks.getTimeline.mockResolvedValue(overviewPayload())
mocks.getSpendFlow.mockResolvedValue({ period: { label: 'Last 30 days', start: '', end: '' }, models: [], projects: [], links: [] })
mocks.getOptimizeReport.mockResolvedValue({
period: { label: 'Last 30 days', start: null, end: null },
Expand Down
133 changes: 133 additions & 0 deletions app/renderer/components/Punchcard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import { useMemo, useRef, useState } from 'react'

import { formatUsd } from '../lib/format'
import type { MenubarPayload } from '../lib/types'

type Timeline = NonNullable<MenubarPayload['history']['timeline']>

const WEEKDAYS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
const WEEKDAYS_FULL = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday']
const HOURS = Array.from({ length: 24 }, (_, h) => h)

type Cell = { cost: number; covered: boolean }

// Mon-first weekday index; getDay() is 0=Sun..6=Sat.
function weekdayIndex(d: Date): number {
return (d.getDay() + 6) % 7
}

// Perceptual ramp: sqrt keeps small spends visible against the largest cell.
function intensity(cost: number, max: number): number {
if (max <= 0 || cost <= 0) return 0
return Math.sqrt(cost / max)
}

/** Hour-of-day × weekday spend matrix, computed client-side from the granular
* timeline. Mirrors the web dashboard's punchcard, restyled to the app's
* tokens. Honesty rules shared with the web version: empty cells stay empty,
* and coarser-than-hourly buckets (daily timestamps at midnight) show the
* limitation instead of a fake midnight column. */
export function Punchcard({ timeline }: { timeline: Timeline }) {
const wrapRef = useRef<HTMLDivElement | null>(null)
const [hover, setHover] = useState<{ x: number; y: number; wd: number; h: number } | null>(null)

const { grid, max, hasBucket } = useMemo(() => {
const g: Cell[][] = Array.from({ length: 7 }, () => Array.from({ length: 24 }, () => ({ cost: 0, covered: false })))
let m = 0
let any = false
for (const p of timeline.points) {
const d = new Date(p.timestamp)
if (!Number.isFinite(d.getTime())) continue
any = true
const cell = g[weekdayIndex(d)]![d.getHours()]!
cell.covered = true
cell.cost += p.cost > 0 ? p.cost : 0
if (cell.cost > m) m = cell.cost
}
return { grid: g, max: m, hasBucket: any }
}, [timeline])

const hourResolved = timeline.bucketMinutes < 1440
if (!hasBucket) {
return <div style={{ padding: '28px 0', textAlign: 'center', fontSize: 'var(--fs-meta)', color: 'var(--mut)' }}>No timestamped usage in this period.</div>
}
if (!hourResolved) {
return (
<div style={{ padding: '24px 0', textAlign: 'center', fontSize: 'var(--fs-meta)', color: 'var(--mut)' }}>
Hour-of-day detail needs sub-daily buckets. Switch to Today or 7D to see the punchcard.
</div>
)
}

const hovered = hover ? grid[hover.wd]![hover.h]! : null
const gridCols = { display: 'grid', gridTemplateColumns: '2.25rem repeat(24, minmax(0, 1fr))', alignItems: 'center' } as const

return (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 }}>
<span style={{ fontSize: 'var(--fs-micro)', fontWeight: 'var(--fw-medium)' as never, textTransform: 'uppercase', letterSpacing: '0.08em', color: 'var(--mut)' }}>
{timeline.bucketMinutes >= 60 ? 'Hourly buckets' : `${timeline.bucketMinutes}-minute buckets`} · local time
</span>
<span style={{ display: 'flex', alignItems: 'center', gap: 5, fontSize: 'var(--fs-micro)', color: 'var(--mut)' }}>
Less
{[0.12, 0.4, 0.7, 1].map(t => (
<span key={t} style={{ width: 5 + t * 7, height: 5 + t * 7, borderRadius: '50%', background: 'var(--accent)', opacity: 0.35 + t * 0.65 }} />
))}
More
</span>
</div>
<div style={{ overflowX: 'auto' }}>
<div ref={wrapRef} style={{ position: 'relative', minWidth: 560 }} onMouseLeave={() => setHover(null)}>
<div style={gridCols}>
<span />
{HOURS.map(h => (
<span key={h} style={{ paddingBottom: 3, textAlign: 'center', fontSize: 9.5, fontVariantNumeric: 'tabular-nums', color: 'var(--mut)' }}>
{h % 3 === 0 ? h : ''}
</span>
))}
</div>
{WEEKDAYS.map((wdLabel, wd) => (
<div key={wdLabel} style={gridCols}>
<span style={{ paddingRight: 8, textAlign: 'right', fontSize: 'var(--fs-micro)', fontVariantNumeric: 'tabular-nums', color: 'var(--mut)' }}>{wdLabel}</span>
{HOURS.map(h => {
const cell = grid[wd]![h]!
const t = intensity(cell.cost, max)
const active = hover?.wd === wd && hover?.h === h
const track = (event: React.MouseEvent) => {
if (!cell.covered || !wrapRef.current) return
const r = wrapRef.current.getBoundingClientRect()
setHover({ x: event.clientX - r.left, y: event.clientY - r.top, wd, h })
}
return (
<div key={h} style={{ aspectRatio: '1', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 2 }} onMouseEnter={track} onMouseMove={track}>
<div style={{ width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', borderRadius: 3, background: cell.covered ? 'var(--hover)' : 'transparent' }}>
{cell.cost > 0 ? (
<div style={{
width: `${22 + t * 70}%`, height: `${22 + t * 70}%`, borderRadius: '50%',
background: 'var(--accent)', opacity: 0.45 + t * 0.55,
transform: active ? 'scale(1.18)' : undefined, transition: 'transform 120ms',
}} />
) : cell.covered ? (
<div style={{ width: 3, height: 3, borderRadius: '50%', background: 'var(--mut)', opacity: 0.25 }} />
) : null}
</div>
</div>
)
})}
</div>
))}
{hover && hovered && (
<div style={{
pointerEvents: 'none', position: 'absolute', zIndex: 10, left: hover.x, top: hover.y - 8,
transform: 'translate(-50%, -100%)', borderRadius: 8, border: '1px solid var(--line)',
background: 'var(--bg)', padding: '5px 10px', fontSize: 'var(--fs-meta)', boxShadow: 'var(--card-shadow)',
}}>
<div style={{ fontWeight: 'var(--fw-medium)' as never, color: 'var(--ink)' }}>{WEEKDAYS_FULL[hover.wd]} {String(hover.h).padStart(2, '0')}:00</div>
<div style={{ fontVariantNumeric: 'tabular-nums', color: 'var(--mut)' }}>{formatUsd(hovered.cost)}</div>
</div>
)}
</div>
</div>
</div>
)
}
4 changes: 4 additions & 0 deletions app/renderer/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,9 @@ export type MenubarPayload = {
}
history: {
daily: DailyHistoryEntry[]
// Granular per-bucket timeline. Present only on the punchcard's dedicated
// fetch (every other payload passes --no-timeline).
timeline?: { bucketMinutes: number; points: Array<{ timestamp: string; cost: number }> }
}
// Active display currency. Payload costs are raw USD; the renderer multiplies by
// `rate` and prefixes `symbol` at display time. Optional: older CLIs omit it.
Expand Down Expand Up @@ -644,6 +647,7 @@ export interface CodeburnBridge {
// `scope` selects local-device usage ('local', default) or paired-device
// aggregate ('combined'); optional so an older preload degrades to local.
getOverview(period: Period, provider: string, range?: DateRange, configSource?: string | null, background?: boolean, scope?: string): Promise<MenubarPayload>
getTimeline(period: Period, provider: string, range?: DateRange): Promise<MenubarPayload>
getPlans(period: Period): Promise<StatusJson>
getActReport(): Promise<ActReportJson>
readonly platform: string
Expand Down
14 changes: 12 additions & 2 deletions app/renderer/sections/Spend.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,14 @@ 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(() => ({
const { getOverview, getSpendFlow, getTimeline } = vi.hoisted(() => ({
getOverview: vi.fn<(period: string, provider: string) => Promise<MenubarPayload>>(),
getSpendFlow: vi.fn<(period: string, provider: string) => Promise<SpendFlow>>(),
getTimeline: vi.fn<(period: string, provider: string) => Promise<MenubarPayload>>(),
}))
vi.mock('../lib/ipc', async orig => {
const actual = await orig<typeof import('../lib/ipc')>()
return { ...actual, codeburn: { getOverview, getSpendFlow } }
return { ...actual, codeburn: { getOverview, getSpendFlow, getTimeline } }
})

function daily(date: string, cost: number, models: Array<{ name: string; cost: number }>) {
Expand Down Expand Up @@ -139,6 +140,7 @@ describe('Spend', () => {
it('zero-fills a contiguous 15-day calendar window with a real date axis, projects, and Sankey ribbons', async () => {
getOverview.mockResolvedValue(makePayload(new Date()))
getSpendFlow.mockResolvedValue(makeFlow())
getTimeline.mockResolvedValue(makePayload(new Date()))

const { container } = render(<Spend period="week" provider="all" />)

Expand All @@ -165,6 +167,7 @@ describe('Spend', () => {
it('renders the chart, projects, Sankey, and all non-empty breakdowns on one page', async () => {
getOverview.mockResolvedValue(makePayload(new Date()))
getSpendFlow.mockResolvedValue(makeFlow())
getTimeline.mockResolvedValue(makePayload(new Date()))

render(<Spend period="week" provider="all" />)
expect(await screen.findByLabelText('Daily spend by model')).toBeInTheDocument()
Expand Down Expand Up @@ -195,6 +198,7 @@ describe('Spend', () => {
}
getOverview.mockResolvedValue(payload)
getSpendFlow.mockResolvedValue(makeFlow())
getTimeline.mockResolvedValue(makePayload(new Date()))

render(<Spend period="week" provider="all" />)
expect(await screen.findByText('codeburn')).toBeInTheDocument()
Expand All @@ -210,6 +214,7 @@ describe('Spend', () => {
payload.current.subagents = []
getOverview.mockResolvedValue(payload)
getSpendFlow.mockResolvedValue(makeFlow())
getTimeline.mockResolvedValue(makePayload(new Date()))

render(<Spend period="week" provider="all" />)

Expand All @@ -223,6 +228,7 @@ describe('Spend', () => {
it('does not render the removed lens tabs', async () => {
getOverview.mockResolvedValue(makePayload(new Date()))
getSpendFlow.mockResolvedValue(makeFlow())
getTimeline.mockResolvedValue(makePayload(new Date()))

render(<Spend period="week" provider="all" />)
expect(await screen.findByText('codeburn')).toBeInTheDocument()
Expand All @@ -235,6 +241,7 @@ describe('Spend', () => {
it('groups the top panels and breakdown panels in their page grids', async () => {
getOverview.mockResolvedValue(makePayload(new Date()))
getSpendFlow.mockResolvedValue(makeFlow())
getTimeline.mockResolvedValue(makePayload(new Date()))

const { container } = render(<Spend period="week" provider="all" />)
expect(await screen.findByText('codeburn')).toBeInTheDocument()
Expand Down Expand Up @@ -289,6 +296,7 @@ describe('Spend', () => {
]
getOverview.mockResolvedValue(payload)
getSpendFlow.mockResolvedValue(makeFlow())
getTimeline.mockResolvedValue(makePayload(new Date()))

const { container } = render(<Spend period="week" provider="all" />)
expect(await screen.findByLabelText('Daily spend by model')).toBeInTheDocument()
Expand All @@ -312,6 +320,7 @@ describe('Spend', () => {
it('renders Sankey ribbons with model gradients, neutral other nodes, and shortened labels', async () => {
getOverview.mockResolvedValue(makePayload(new Date()))
getSpendFlow.mockResolvedValue(makeFlow())
getTimeline.mockResolvedValue(makePayload(new Date()))

const { container } = render(<Spend period="week" provider="all" />)

Expand Down Expand Up @@ -354,6 +363,7 @@ describe('Spend', () => {
]
getOverview.mockResolvedValue(payload)
getSpendFlow.mockResolvedValue(makeFlow())
getTimeline.mockResolvedValue(makePayload(new Date()))

render(<Spend period="week" provider="all" />)

Expand Down
21 changes: 20 additions & 1 deletion app/renderer/sections/Spend.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ 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'
Expand Down Expand Up @@ -33,6 +34,20 @@ function providerLabel(provider: string): string {
.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),
Expand Down Expand Up @@ -70,19 +85,21 @@ export function SpendContent({
}

const animateKey = `${period}|${provider}|${range?.from ?? ''}|${range?.to ?? ''}`
return <SpendPage data={overview.data} flow={flow} provider={provider} range={range} staleError={overview.error} animateKey={animateKey} />
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
Expand Down Expand Up @@ -169,6 +186,8 @@ function SpendPage({
)}
</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} />)
Expand Down
18 changes: 17 additions & 1 deletion mac/Sources/CodeBurnMenubar/Data/MenubarPayload.swift
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,21 @@ struct CurrentBlock: Codable, Sendable {
var workflow: WorkflowBlock? = nil
/// Files most reworked by edit-family calls. Empty on older CLIs.
var topReworkedFiles: [ReworkedFileEntry] = []
/// Attributed pull-request spend for the period (all-provider payloads
/// only). Optional so payloads from older CLIs still decode; absent or
/// empty -> the Pull requests section hides.
var pullRequests: PullRequestsBlock? = nil
}

struct PullRequestsBlock: Codable, Sendable {
let rows: [PullRequestRow]
}

struct PullRequestRow: Codable, Sendable {
let url: String
let label: String
let cost: Double
let sessions: Int
}

extension CurrentBlock {
Expand All @@ -235,7 +250,7 @@ extension CurrentBlock {
cacheHitPercent, codexCredits, topActivities, topModels, localModelSavings, providers, topProjects,
modelEfficiency, topSessions, retryTax, routingWaste,
tools, skills, subagents, mcpServers,
workflow, topReworkedFiles
workflow, topReworkedFiles, pullRequests
}
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
Expand Down Expand Up @@ -263,6 +278,7 @@ extension CurrentBlock {
mcpServers = try c.decodeIfPresent([McpServerEntry].self, forKey: .mcpServers) ?? []
workflow = try c.decodeIfPresent(WorkflowBlock.self, forKey: .workflow)
topReworkedFiles = try c.decodeIfPresent([ReworkedFileEntry].self, forKey: .topReworkedFiles) ?? []
pullRequests = try c.decodeIfPresent(PullRequestsBlock.self, forKey: .pullRequests)
}
}

Expand Down
1 change: 1 addition & 0 deletions mac/Sources/CodeBurnMenubar/Views/MenuBarContent.swift
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ struct MenuBarContent: View {
Divider().opacity(0.5)
ModelsSection()
WorkflowSection()
PullRequestsSection()
Divider().opacity(0.5)
ToolingSection()
Divider().opacity(0.5)
Expand Down
Loading
Loading