diff --git a/app/electron/main.test.ts b/app/electron/main.test.ts index 208bd865..1bd27d49 100644 --- a/app/electron/main.test.ts +++ b/app/electron/main.test.ts @@ -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', diff --git a/app/electron/main.ts b/app/electron/main.ts index 5308f42f..7d2c2bb4 100644 --- a/app/electron/main.ts +++ b/app/electron/main.ts @@ -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) => [ diff --git a/app/electron/preload.ts b/app/electron/preload.ts index 1c4bc3e9..f681c8a7 100644 --- a/app/electron/preload.ts +++ b/app/electron/preload.ts @@ -21,6 +21,7 @@ async function invoke(channel: string, ...args: unknown[]): Promise { 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), diff --git a/app/renderer/App.test.tsx b/app/renderer/App.test.tsx index c1957a3e..dbf5e8ff 100644 --- a/app/renderer/App.test.tsx +++ b/app/renderer/App.test.tsx @@ -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>(), getSpendFlow: vi.fn<(period: string, provider: string, range?: DateRange) => Promise>(), + getTimeline: vi.fn<(period: string, provider: string, range?: DateRange) => Promise>(), getOptimizeReport: vi.fn<(period: string, provider: string, range?: DateRange) => Promise>(), getModels: vi.fn(), getSessions: vi.fn(), @@ -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 }, diff --git a/app/renderer/components/Punchcard.tsx b/app/renderer/components/Punchcard.tsx new file mode 100644 index 00000000..8e1478ce --- /dev/null +++ b/app/renderer/components/Punchcard.tsx @@ -0,0 +1,133 @@ +import { useMemo, useRef, useState } from 'react' + +import { formatUsd } from '../lib/format' +import type { MenubarPayload } from '../lib/types' + +type Timeline = NonNullable + +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(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
No timestamped usage in this period.
+ } + if (!hourResolved) { + return ( +
+ Hour-of-day detail needs sub-daily buckets. Switch to Today or 7D to see the punchcard. +
+ ) + } + + 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 ( +
+
+ + {timeline.bucketMinutes >= 60 ? 'Hourly buckets' : `${timeline.bucketMinutes}-minute buckets`} · local time + + + Less + {[0.12, 0.4, 0.7, 1].map(t => ( + + ))} + More + +
+
+
setHover(null)}> +
+ + {HOURS.map(h => ( + + {h % 3 === 0 ? h : ''} + + ))} +
+ {WEEKDAYS.map((wdLabel, wd) => ( +
+ {wdLabel} + {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 ( +
+
+ {cell.cost > 0 ? ( +
+ ) : cell.covered ? ( +
+ ) : null} +
+
+ ) + })} +
+ ))} + {hover && hovered && ( +
+
{WEEKDAYS_FULL[hover.wd]} {String(hover.h).padStart(2, '0')}:00
+
{formatUsd(hovered.cost)}
+
+ )} +
+
+
+ ) +} diff --git a/app/renderer/lib/types.ts b/app/renderer/lib/types.ts index faf00a92..25394087 100644 --- a/app/renderer/lib/types.ts +++ b/app/renderer/lib/types.ts @@ -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. @@ -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 + getTimeline(period: Period, provider: string, range?: DateRange): Promise getPlans(period: Period): Promise getActReport(): Promise readonly platform: string diff --git a/app/renderer/sections/Spend.test.tsx b/app/renderer/sections/Spend.test.tsx index b79fe54c..114d01e5 100644 --- a/app/renderer/sections/Spend.test.tsx +++ b/app/renderer/sections/Spend.test.tsx @@ -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>(), getSpendFlow: vi.fn<(period: string, provider: string) => Promise>(), + getTimeline: vi.fn<(period: string, provider: string) => Promise>(), })) vi.mock('../lib/ipc', async orig => { const actual = await orig() - return { ...actual, codeburn: { getOverview, getSpendFlow } } + return { ...actual, codeburn: { getOverview, getSpendFlow, getTimeline } } }) function daily(date: string, cost: number, models: Array<{ name: string; cost: number }>) { @@ -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() @@ -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() expect(await screen.findByLabelText('Daily spend by model')).toBeInTheDocument() @@ -195,6 +198,7 @@ describe('Spend', () => { } getOverview.mockResolvedValue(payload) getSpendFlow.mockResolvedValue(makeFlow()) + getTimeline.mockResolvedValue(makePayload(new Date())) render() expect(await screen.findByText('codeburn')).toBeInTheDocument() @@ -210,6 +214,7 @@ describe('Spend', () => { payload.current.subagents = [] getOverview.mockResolvedValue(payload) getSpendFlow.mockResolvedValue(makeFlow()) + getTimeline.mockResolvedValue(makePayload(new Date())) render() @@ -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() expect(await screen.findByText('codeburn')).toBeInTheDocument() @@ -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() expect(await screen.findByText('codeburn')).toBeInTheDocument() @@ -289,6 +296,7 @@ describe('Spend', () => { ] getOverview.mockResolvedValue(payload) getSpendFlow.mockResolvedValue(makeFlow()) + getTimeline.mockResolvedValue(makePayload(new Date())) const { container } = render() expect(await screen.findByLabelText('Daily spend by model')).toBeInTheDocument() @@ -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() @@ -354,6 +363,7 @@ describe('Spend', () => { ] getOverview.mockResolvedValue(payload) getSpendFlow.mockResolvedValue(makeFlow()) + getTimeline.mockResolvedValue(makePayload(new Date())) render() diff --git a/app/renderer/sections/Spend.tsx b/app/renderer/sections/Spend.tsx index 364b7703..415b5364 100644 --- a/app/renderer/sections/Spend.tsx +++ b/app/renderer/sections/Spend.tsx @@ -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' @@ -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( + () => 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 ( + + + + ) +} + export function Spend({ period, provider, range = null }: { period: Period; provider: string; range?: DateRange | null }) { const overview = usePolled( () => range ? codeburn.getOverview(period, provider, range) : codeburn.getOverview(period, provider), @@ -70,12 +85,13 @@ export function SpendContent({ } const animateKey = `${period}|${provider}|${range?.from ?? ''}|${range?.to ?? ''}` - return + return } function SpendPage({ data, flow, + period, provider, range, staleError, @@ -83,6 +99,7 @@ function SpendPage({ }: { data: MenubarPayload flow: ReturnType> + period: Period provider: string range: DateRange | null staleError: CliError | null @@ -169,6 +186,8 @@ function SpendPage({ )} + +
{breakdowns.length ? ( breakdowns.map(section => ) diff --git a/mac/Sources/CodeBurnMenubar/Data/MenubarPayload.swift b/mac/Sources/CodeBurnMenubar/Data/MenubarPayload.swift index 69cdd926..d09db99d 100644 --- a/mac/Sources/CodeBurnMenubar/Data/MenubarPayload.swift +++ b/mac/Sources/CodeBurnMenubar/Data/MenubarPayload.swift @@ -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 { @@ -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) @@ -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) } } diff --git a/mac/Sources/CodeBurnMenubar/Views/MenuBarContent.swift b/mac/Sources/CodeBurnMenubar/Views/MenuBarContent.swift index afc11ded..51f0426d 100644 --- a/mac/Sources/CodeBurnMenubar/Views/MenuBarContent.swift +++ b/mac/Sources/CodeBurnMenubar/Views/MenuBarContent.swift @@ -37,6 +37,7 @@ struct MenuBarContent: View { Divider().opacity(0.5) ModelsSection() WorkflowSection() + PullRequestsSection() Divider().opacity(0.5) ToolingSection() Divider().opacity(0.5) diff --git a/mac/Sources/CodeBurnMenubar/Views/PullRequestsSection.swift b/mac/Sources/CodeBurnMenubar/Views/PullRequestsSection.swift new file mode 100644 index 00000000..be7bbc4d --- /dev/null +++ b/mac/Sources/CodeBurnMenubar/Views/PullRequestsSection.swift @@ -0,0 +1,36 @@ +import SwiftUI + +/// Compact top-pull-requests strip: the menubar's slice of the PR attribution +/// the desktop app shows in full. Renders the top three PRs by attributed +/// spend for the selected period; hides entirely when the payload carries no +/// PR block (older CLI, provider-scoped payload, or nothing attributed). +struct PullRequestsSection: View { + @Environment(AppStore.self) private var store + + var body: some View { + let rows = Array((store.payload.current.pullRequests?.rows ?? []).prefix(3)) + if !rows.isEmpty { + VStack(spacing: 0) { + Divider().opacity(0.5) + VStack(alignment: .leading, spacing: 6) { + SectionCaption(text: "Pull requests") + ForEach(rows, id: \.url) { row in + HStack(spacing: 8) { + Text(row.label) + .font(.system(size: 11.5, design: .monospaced)) + .lineLimit(1) + .truncationMode(.middle) + Spacer(minLength: 8) + Text(row.cost.asCurrency()) + .font(.system(size: 11.5, weight: .medium)) + .monospacedDigit() + } + .frame(maxWidth: .infinity) + } + } + .padding(.horizontal, 14) + .padding(.vertical, 11) + } + } + } +}