mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-08-06 23:24:32 +00:00
payload: spend by pull request and by branch; desktop Pull requests tab
Part 1 (src): expose two add-only, optional aggregations on the menubar payload's `current`, computed on the unscoped all-provider path from the surviving-session parse (carried history cannot contribute, as expected). - current.pullRequests: aggregateByPr rows (top 20 by cost) plus the multi-link-safe distinctCost/distinctSessions. Rows are by-reference, so they are never summed. - current.byBranch: new aggregateByBranch, per-branch spend (top 15 by cost) that carries each session's last-seen git branch forward across its turns. The cache stores a turn's branch only when it changes, so the parser now resolves the branch at reconstruction (before the date slice) and records SessionSummary.everHadBranch from the full transcript. That lets the report keep a branch-bearing session's pre-branch spend in an explicit null row even when the range clipped the anchor turn, while a provider that never captures a branch still contributes nothing. Part 2 (app): new "Pull requests" sidebar entry between Sessions and Spend (shortcuts reflow to number 8 for Plans). It renders the pullRequests rows as a refined table with the PR label linking out via openExternal, cost, sessions, calls and an active-date span, plus a footnote stating the distinct total and the by-reference attribution. A quiet explanatory line shows when no PR links exist, never a fake table. Tests: aggregateByBranch unit tests (carry-forward, null bucket, clipped anchor, by-reference session counts) and the Pull requests component tests (table, external link, footnote, both empty states). Nav-reflow assertions in Sidebar and App tests updated.
This commit is contained in:
parent
9676f53d0c
commit
d0b02c00e6
14 changed files with 549 additions and 27 deletions
|
|
@ -215,7 +215,7 @@ describe('App shortcuts', () => {
|
|||
render(<App />)
|
||||
|
||||
expect(await screen.findByText('Most expensive sessions')).toBeInTheDocument()
|
||||
expect(screen.getByText('⌘1-7')).toBeInTheDocument()
|
||||
expect(screen.getByText('⌘1-8')).toBeInTheDocument()
|
||||
expect(screen.getAllByText('⌘,').length).toBeGreaterThan(0)
|
||||
expect(screen.getByText('⌘R')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Command')).not.toBeInTheDocument()
|
||||
|
|
@ -225,18 +225,21 @@ describe('App shortcuts', () => {
|
|||
expect(await screen.findByText('No sessions in this range yet.')).toBeInTheDocument()
|
||||
|
||||
fireEvent.keyDown(document, { key: '3', metaKey: true })
|
||||
expect(await screen.findByText('Cost flow · model → project')).toBeInTheDocument()
|
||||
expect(await screen.findByText(/PR links are captured as sessions are parsed/)).toBeInTheDocument()
|
||||
|
||||
fireEvent.keyDown(document, { key: '4', metaKey: true })
|
||||
expect(await screen.findByText('No waste findings in this range yet.')).toBeInTheDocument()
|
||||
expect(await screen.findByText('Cost flow · model → project')).toBeInTheDocument()
|
||||
|
||||
fireEvent.keyDown(document, { key: '5', metaKey: true })
|
||||
expect(await screen.findByText('No model usage in this range yet.')).toBeInTheDocument()
|
||||
expect(await screen.findByText('No waste findings in this range yet.')).toBeInTheDocument()
|
||||
|
||||
fireEvent.keyDown(document, { key: '6', metaKey: true })
|
||||
expect(await screen.findByText('Need at least two models with usage in this range to compare.')).toBeInTheDocument()
|
||||
expect(await screen.findByText('No model usage in this range yet.')).toBeInTheDocument()
|
||||
|
||||
fireEvent.keyDown(document, { key: '7', metaKey: true })
|
||||
expect(await screen.findByText('Need at least two models with usage in this range to compare.')).toBeInTheDocument()
|
||||
|
||||
fireEvent.keyDown(document, { key: '8', metaKey: true })
|
||||
expect(await screen.findByText('Not connected. Log in with the Claude CLI.')).toBeInTheDocument()
|
||||
|
||||
fireEvent.keyDown(document, { key: ',', metaKey: true })
|
||||
|
|
@ -251,7 +254,7 @@ describe('App shortcuts', () => {
|
|||
it('re-polls visible section data when period or provider changes', async () => {
|
||||
render(<App />)
|
||||
|
||||
fireEvent.keyDown(document, { key: '3', metaKey: true })
|
||||
fireEvent.keyDown(document, { key: '4', metaKey: true })
|
||||
expect(await screen.findByText('Cost flow · model → project')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByText('Today'))
|
||||
|
|
@ -389,7 +392,7 @@ describe('App shortcuts', () => {
|
|||
it('applies a calendar range to overview and visible section polls', async () => {
|
||||
render(<App />)
|
||||
|
||||
fireEvent.keyDown(document, { key: '3', metaKey: true })
|
||||
fireEvent.keyDown(document, { key: '4', metaKey: true })
|
||||
expect(await screen.findByText('Cost flow · model → project')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Choose date range' }))
|
||||
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import { OverviewContent } from './sections/Overview'
|
|||
import { OptimizeContent } from './sections/Optimize'
|
||||
import { Models } from './sections/Models'
|
||||
import { Sessions } from './sections/Sessions'
|
||||
import { PullRequestsContent } from './sections/PullRequests'
|
||||
import { Compare } from './sections/Compare'
|
||||
import { Plans } from './sections/Plans'
|
||||
import { Settings, type SettingsPane } from './sections/Settings'
|
||||
|
|
@ -106,6 +107,7 @@ export function usageSnapshotProps(payload: MenubarPayload, modelCategories?: Ma
|
|||
const SECTION_TITLES: Record<Section, string> = {
|
||||
overview: 'Overview',
|
||||
sessions: 'Sessions',
|
||||
pullRequests: 'Pull requests',
|
||||
spend: 'Spend',
|
||||
optimize: 'Optimize',
|
||||
models: 'Models',
|
||||
|
|
@ -424,11 +426,12 @@ function AppMain() {
|
|||
const key = event.key.toLowerCase()
|
||||
if (key === '1') navigate('overview')
|
||||
else if (key === '2') navigate('sessions')
|
||||
else if (key === '3') navigate('spend')
|
||||
else if (key === '4') navigate('optimize')
|
||||
else if (key === '5') navigate('models')
|
||||
else if (key === '6') navigate('compare')
|
||||
else if (key === '7') navigate('plans')
|
||||
else if (key === '3') navigate('pullRequests')
|
||||
else if (key === '4') navigate('spend')
|
||||
else if (key === '5') navigate('optimize')
|
||||
else if (key === '6') navigate('models')
|
||||
else if (key === '7') navigate('compare')
|
||||
else if (key === '8') navigate('plans')
|
||||
else if (key === ',') navigate('settings')
|
||||
else if (key === 'r') refreshVisible()
|
||||
else return
|
||||
|
|
@ -514,6 +517,8 @@ function AppMain() {
|
|||
<OverviewContent period={period} provider={provider} range={customRange} overview={overview} onNavigate={navigate} ready={ready} />
|
||||
) : section === 'sessions' ? (
|
||||
<Sessions period={period} provider={provider} range={customRange} refreshToken={refreshToken} detectedProviders={detectedProviders} onProviderChange={onProviderSelect} ready={ready} />
|
||||
) : section === 'pullRequests' ? (
|
||||
<PullRequestsContent overview={overview} />
|
||||
) : section === 'spend' ? (
|
||||
<SpendContent period={period} provider={provider} range={customRange} overview={overview} refreshToken={refreshToken} ready={ready} />
|
||||
) : section === 'optimize' ? (
|
||||
|
|
@ -532,7 +537,7 @@ function AppMain() {
|
|||
{section !== 'settings' && (
|
||||
<Hint
|
||||
items={[
|
||||
{ k: '⌘1-7', label: 'Navigate' },
|
||||
{ k: '⌘1-8', label: 'Navigate' },
|
||||
{ k: '⌘,', label: 'Settings' },
|
||||
{ k: '⌘R', label: 'Refresh' },
|
||||
]}
|
||||
|
|
|
|||
|
|
@ -5,13 +5,14 @@ import { render, screen, fireEvent } from '@testing-library/react'
|
|||
import { Sidebar } from './Sidebar'
|
||||
|
||||
describe('Sidebar', () => {
|
||||
it('renders all eight nav items in the desktop order', () => {
|
||||
it('renders all nine nav items in the desktop order', () => {
|
||||
render(<Sidebar active="overview" onNavigate={() => {}} />)
|
||||
const labels = screen.getAllByRole('button').map(item => item.textContent?.replace(/⌘[\d,]/, ''))
|
||||
expect(labels).toEqual(['Overview', 'Sessions', 'Spend', 'Optimize', 'Models', 'Compare', 'Plans', 'Settings'])
|
||||
expect(labels).toEqual(['Overview', 'Sessions', 'Pull requests', 'Spend', 'Optimize', 'Models', 'Compare', 'Plans', 'Settings'])
|
||||
expect(screen.getByRole('button', { name: /Sessions.*⌘2/ })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /Compare.*⌘6/ })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /Plans.*⌘7/ })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /Pull requests.*⌘3/ })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /Compare.*⌘7/ })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /Plans.*⌘8/ })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onNavigate with the section id when a nav item is clicked', () => {
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { codeburn } from '../lib/ipc'
|
|||
import { AboutModal, type SocialLink } from './AboutModal'
|
||||
import { FlameMark } from './FlameMark'
|
||||
|
||||
export type Section = 'overview' | 'sessions' | 'spend' | 'optimize' | 'models' | 'compare' | 'plans' | 'settings'
|
||||
export type Section = 'overview' | 'sessions' | 'pullRequests' | 'spend' | 'optimize' | 'models' | 'compare' | 'plans' | 'settings'
|
||||
|
||||
export const NAV_ITEMS: Array<{ id: Section; label: string; key: string; icon: ReactNode }> = [
|
||||
{ id: 'overview', label: 'Overview', key: '⌘1', icon: (
|
||||
|
|
@ -13,19 +13,22 @@ export const NAV_ITEMS: Array<{ id: Section; label: string; key: string; icon: R
|
|||
{ id: 'sessions', label: 'Sessions', key: '⌘2', icon: (
|
||||
<svg viewBox="0 0 24 24"><rect x="4" y="4" width="16" height="4" rx="1"/><rect x="4" y="10" width="16" height="4" rx="1"/><rect x="4" y="16" width="16" height="4" rx="1"/></svg>
|
||||
) },
|
||||
{ id: 'spend', label: 'Spend', key: '⌘3', icon: (
|
||||
{ id: 'pullRequests', label: 'Pull requests', key: '⌘3', icon: (
|
||||
<svg viewBox="0 0 24 24"><circle cx="6" cy="6" r="3"/><circle cx="18" cy="18" r="3"/><path d="M13 6h3a2 2 0 0 1 2 2v7"/><line x1="6" y1="9" x2="6" y2="21"/></svg>
|
||||
) },
|
||||
{ id: 'spend', label: 'Spend', key: '⌘4', icon: (
|
||||
<svg viewBox="0 0 24 24"><line x1="6" y1="20" x2="6" y2="13" /><line x1="12" y1="20" x2="12" y2="4" /><line x1="18" y1="20" x2="18" y2="9" /></svg>
|
||||
) },
|
||||
{ id: 'optimize', label: 'Optimize', key: '⌘4', icon: (
|
||||
{ id: 'optimize', label: 'Optimize', key: '⌘5', icon: (
|
||||
<svg viewBox="0 0 24 24"><circle cx="10.5" cy="10.5" r="3.4"/><path d="M10.5 3v1.7M10.5 16.3V18M3 10.5h1.7M16.3 10.5H18M5.3 5.3l1.2 1.2M14.5 14.5l1.2 1.2M15.7 5.3l-1.2 1.2M6.5 14.5l-1.2 1.2"/><line x1="15.5" y1="15.5" x2="20" y2="20"/></svg>
|
||||
) },
|
||||
{ id: 'models', label: 'Models', key: '⌘5', icon: (
|
||||
{ id: 'models', label: 'Models', key: '⌘6', icon: (
|
||||
<svg viewBox="0 0 24 24"><path d="M21 16V8a2 2 0 0 0-1-1.7l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.7l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z" /><path d="M3.3 7 12 12l8.7-5M12 22V12" /></svg>
|
||||
) },
|
||||
{ id: 'compare', label: 'Compare', key: '⌘6', icon: (
|
||||
{ id: 'compare', label: 'Compare', key: '⌘7', icon: (
|
||||
<svg viewBox="0 0 24 24"><path d="M8 3 4 7l4 4"/><path d="M4 7h16"/><path d="M16 21l4-4-4-4"/><path d="M20 17H4"/></svg>
|
||||
) },
|
||||
{ id: 'plans', label: 'Plans', key: '⌘7', icon: (
|
||||
{ id: 'plans', label: 'Plans', key: '⌘8', icon: (
|
||||
<svg viewBox="0 0 24 24"><rect x="2" y="5" width="20" height="14" rx="2" /><line x1="2" y1="10" x2="22" y2="10" /></svg>
|
||||
) },
|
||||
{ id: 'settings', label: 'Settings', key: '⌘,', icon: (
|
||||
|
|
|
|||
|
|
@ -206,6 +206,25 @@ export type MenubarPayload = {
|
|||
skills: Array<{ name: string; turns: number; cost: number }>
|
||||
subagents: Array<{ name: string; calls: number; cost: number }>
|
||||
mcpServers: Array<{ name: string; calls: number }>
|
||||
// Spend by referenced pull request (top 20 by cost) plus the multi-link-safe
|
||||
// distinct total. Optional: older CLIs omit it, and it is absent when no PR
|
||||
// links were observed. Rows are by-reference — a session referencing several
|
||||
// PRs counts toward each — so `rows[].cost` must never be summed; use
|
||||
// `distinctCost`/`distinctSessions` for a total.
|
||||
pullRequests?: {
|
||||
rows: Array<{
|
||||
url: string
|
||||
label: string
|
||||
cost: number
|
||||
savingsUSD: number
|
||||
sessions: number
|
||||
calls: number
|
||||
firstStarted: string
|
||||
lastEnded: string
|
||||
}>
|
||||
distinctCost: number
|
||||
distinctSessions: number
|
||||
}
|
||||
}
|
||||
optimize: {
|
||||
findingCount: number
|
||||
|
|
|
|||
110
app/renderer/sections/PullRequests.test.tsx
Normal file
110
app/renderer/sections/PullRequests.test.tsx
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
// @vitest-environment jsdom
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { formatDayShort } from '../lib/format'
|
||||
import type { MenubarPayload } from '../lib/types'
|
||||
import { PullRequests } from './PullRequests'
|
||||
|
||||
type PrPayload = NonNullable<MenubarPayload['current']['pullRequests']>
|
||||
|
||||
const { getOverview, openExternal } = vi.hoisted(() => ({
|
||||
getOverview: vi.fn<(period: string, provider: string) => Promise<MenubarPayload>>(),
|
||||
openExternal: vi.fn<(url: string) => Promise<void>>(),
|
||||
}))
|
||||
vi.mock('../lib/ipc', async orig => {
|
||||
const actual = await orig<typeof import('../lib/ipc')>()
|
||||
return { ...actual, codeburn: { getOverview, openExternal } }
|
||||
})
|
||||
|
||||
// Mirror the component's span rule so the assertion stays timezone-safe.
|
||||
function expectedSpan(first: string, last: string): string {
|
||||
const start = formatDayShort(first)
|
||||
const end = formatDayShort(last)
|
||||
return start === end ? start : `${start} - ${end}`
|
||||
}
|
||||
|
||||
function makePayload(pullRequests?: PrPayload): MenubarPayload {
|
||||
return {
|
||||
generated: '2026-07-20T00:00:00Z',
|
||||
current: {
|
||||
label: 'Lifetime', cost: 0, calls: 0, sessions: 0, oneShotRate: null,
|
||||
inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, cacheHitPercent: 0,
|
||||
codexCredits: 0, topActivities: [], topModels: [],
|
||||
localModelSavings: { totalUSD: 0, calls: 0, byModel: [], byProvider: [] },
|
||||
providers: {}, topProjects: [], modelEfficiency: [], topSessions: [],
|
||||
retryTax: { totalUSD: 0, retries: 0, editTurns: 0, byModel: [] },
|
||||
routingWaste: { totalSavingsUSD: 0, baselineModel: '', baselineCostPerEdit: 0, byModel: [] },
|
||||
tools: [], skills: [], subagents: [], mcpServers: [],
|
||||
...(pullRequests ? { pullRequests } : {}),
|
||||
},
|
||||
optimize: { findingCount: 0, savingsUSD: 0, topFindings: [] },
|
||||
history: { daily: [] },
|
||||
}
|
||||
}
|
||||
|
||||
const SAMPLE: PrPayload = {
|
||||
rows: [
|
||||
{ url: 'https://github.com/getagentseal/codeburn/pull/780', label: 'getagentseal/codeburn#780', cost: 240.5, savingsUSD: 0, sessions: 3, calls: 512, firstStarted: '2026-07-01T10:00:00Z', lastEnded: '2026-07-03T18:00:00Z' },
|
||||
{ url: 'https://github.com/getagentseal/codeburn/pull/781', label: 'getagentseal/codeburn#781', cost: 90.25, savingsUSD: 0, sessions: 1, calls: 120, firstStarted: '2026-07-05T13:00:00Z', lastEnded: '2026-07-05T15:00:00Z' },
|
||||
],
|
||||
distinctCost: 300.75,
|
||||
distinctSessions: 3,
|
||||
}
|
||||
|
||||
describe('PullRequests', () => {
|
||||
beforeEach(() => {
|
||||
getOverview.mockReset()
|
||||
openExternal.mockReset()
|
||||
openExternal.mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
it('renders PR rows as a table with linked labels, cost, and a date span', async () => {
|
||||
getOverview.mockResolvedValue(makePayload(SAMPLE))
|
||||
render(<PullRequests period="lifetime" provider="all" />)
|
||||
|
||||
const link = await screen.findByRole('link', { name: 'getagentseal/codeburn#780' })
|
||||
expect(link).toHaveAttribute('href', 'https://github.com/getagentseal/codeburn/pull/780')
|
||||
expect(screen.getByText('$240.50')).toBeInTheDocument()
|
||||
expect(screen.getByText('512')).toBeInTheDocument()
|
||||
expect(screen.getByText(expectedSpan(SAMPLE.rows[0]!.firstStarted, SAMPLE.rows[0]!.lastEnded))).toBeInTheDocument()
|
||||
// A same-day PR collapses its span to a single label.
|
||||
expect(screen.getByText(expectedSpan(SAMPLE.rows[1]!.firstStarted, SAMPLE.rows[1]!.lastEnded))).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('opens the PR URL externally instead of navigating', async () => {
|
||||
getOverview.mockResolvedValue(makePayload(SAMPLE))
|
||||
render(<PullRequests period="lifetime" provider="all" />)
|
||||
|
||||
const link = await screen.findByRole('link', { name: 'getagentseal/codeburn#780' })
|
||||
await userEvent.click(link)
|
||||
expect(openExternal).toHaveBeenCalledWith('https://github.com/getagentseal/codeburn/pull/780')
|
||||
})
|
||||
|
||||
it('states the distinct-total footer explaining by-reference attribution', async () => {
|
||||
getOverview.mockResolvedValue(makePayload(SAMPLE))
|
||||
render(<PullRequests period="lifetime" provider="all" />)
|
||||
|
||||
const note = await screen.findByText(/produced pull requests/)
|
||||
expect(note.textContent).toContain('$300.75')
|
||||
expect(note.textContent).toContain('3 distinct sessions')
|
||||
expect(note.textContent).toContain('counts toward each')
|
||||
})
|
||||
|
||||
it('shows the quiet empty state (never a fake table) when no PR links exist', async () => {
|
||||
getOverview.mockResolvedValue(makePayload())
|
||||
render(<PullRequests period="lifetime" provider="all" />)
|
||||
|
||||
expect(await screen.findByText(/PR links are captured as sessions are parsed/)).toBeInTheDocument()
|
||||
expect(screen.queryByRole('table')).toBeNull()
|
||||
})
|
||||
|
||||
it('shows the empty state when the PR array is present but empty', async () => {
|
||||
getOverview.mockResolvedValue(makePayload({ rows: [], distinctCost: 0, distinctSessions: 0 }))
|
||||
render(<PullRequests period="lifetime" provider="all" />)
|
||||
|
||||
expect(await screen.findByText(/PR links are captured as sessions are parsed/)).toBeInTheDocument()
|
||||
expect(screen.queryByRole('table')).toBeNull()
|
||||
})
|
||||
})
|
||||
102
app/renderer/sections/PullRequests.tsx
Normal file
102
app/renderer/sections/PullRequests.tsx
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
import type { MouseEvent } from 'react'
|
||||
|
||||
import { CliErrorPanel } from '../components/CliErrorPanel'
|
||||
import { EmptyNote } from '../components/EmptyState'
|
||||
import { Panel } from '../components/Panel'
|
||||
import { SectionSkeleton } from '../components/Skeleton'
|
||||
import { StaleBanner } from '../components/StaleBanner'
|
||||
import { type Polled, usePolled } from '../hooks/usePolled'
|
||||
import { formatDayShort, formatUsd } from '../lib/format'
|
||||
import { codeburn } from '../lib/ipc'
|
||||
import type { CliError, DateRange, MenubarPayload, Period } from '../lib/types'
|
||||
|
||||
type PullRequests = NonNullable<MenubarPayload['current']['pullRequests']>
|
||||
type PrRow = PullRequests['rows'][number]
|
||||
|
||||
// A PR's active window: one day collapses to a single label, otherwise the two
|
||||
// endpoints joined with a hyphen (never an en/em dash, per repo copy rules).
|
||||
function spanLabel(firstStarted: string, lastEnded: string): string {
|
||||
const start = formatDayShort(firstStarted)
|
||||
const end = formatDayShort(lastEnded)
|
||||
if (start === '—' && end === '—') return '—'
|
||||
return start === end ? start : `${start} - ${end}`
|
||||
}
|
||||
|
||||
function openPr(event: MouseEvent<HTMLAnchorElement>, url: string): void {
|
||||
event.preventDefault()
|
||||
void codeburn.openExternal(url)
|
||||
}
|
||||
|
||||
/** Standalone entry: self-fetches the overview payload (used in tests). The App
|
||||
* passes its shared overview poll straight into PullRequestsContent instead. */
|
||||
export function PullRequests({ 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 <PullRequestsContent overview={overview} />
|
||||
}
|
||||
|
||||
export function PullRequestsContent({ overview }: { overview: Polled<MenubarPayload> }) {
|
||||
if (!overview.data) {
|
||||
if (overview.error) return <CliErrorPanel error={overview.error} subject="pull requests" />
|
||||
return <SectionSkeleton label="Scanning pull requests…" rows={5} />
|
||||
}
|
||||
return <PullRequestsPage pullRequests={overview.data.current.pullRequests} staleError={overview.error} />
|
||||
}
|
||||
|
||||
function PullRequestsPage({ pullRequests, staleError }: { pullRequests?: PullRequests; staleError: CliError | null }) {
|
||||
return (
|
||||
<>
|
||||
{staleError && <StaleBanner error={staleError} />}
|
||||
<Panel title="Spend by pull request">
|
||||
{pullRequests && pullRequests.rows.length > 0
|
||||
? <PrTable pullRequests={pullRequests} />
|
||||
: <EmptyNote>PR links are captured as sessions are parsed. Once a session references a pull request, it appears here.</EmptyNote>}
|
||||
</Panel>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function PrTable({ pullRequests }: { pullRequests: PullRequests }) {
|
||||
const { rows, distinctCost, distinctSessions } = pullRequests
|
||||
const sessionWord = distinctSessions === 1 ? 'session' : 'sessions'
|
||||
return (
|
||||
<>
|
||||
<div className="ov-model-scroll">
|
||||
<table className="ov-models pr-table" aria-label="Spend by pull request">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Pull request</th>
|
||||
<th className="num">Cost</th>
|
||||
<th className="num">Sessions</th>
|
||||
<th className="num">Calls</th>
|
||||
<th className="num">Active</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map(pr => <PrRowView key={pr.url} pr={pr} />)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p className="pr-footnote">
|
||||
{formatUsd(distinctCost)} across {distinctSessions.toLocaleString('en-US')} distinct {sessionWord} produced pull requests.
|
||||
{' '}Attribution is by reference: a session referencing several PRs counts toward each, so the rows above are not summed.
|
||||
</p>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function PrRowView({ pr }: { pr: PrRow }) {
|
||||
return (
|
||||
<tr>
|
||||
<td className="ov-model-name">
|
||||
<a className="pr-link" href={pr.url} title={pr.url} onClick={event => openPr(event, pr.url)}>{pr.label}</a>
|
||||
</td>
|
||||
<td className="num mono">{formatUsd(pr.cost)}</td>
|
||||
<td className="num">{pr.sessions.toLocaleString('en-US')}</td>
|
||||
<td className="num">{pr.calls.toLocaleString('en-US')}</td>
|
||||
<td className="num pr-span">{spanLabel(pr.firstStarted, pr.lastEnded)}</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
|
|
@ -580,6 +580,10 @@ td:first-child { font-size: var(--fs-body); font-weight: var(--fw-body); }
|
|||
.ov-models th:first-child, .ov-models td:first-child { width: 100%; text-align: left; }
|
||||
.ov-models .ov-model-name { overflow: hidden; color: var(--ink); font-weight: var(--fw-medium); text-overflow: ellipsis; }
|
||||
.ov-models td.mono { font-family: var(--mono); color: var(--ink); }
|
||||
.pr-link { color: var(--accent-text); font-weight: var(--fw-medium); text-decoration: none; cursor: pointer; }
|
||||
.pr-link:hover { text-decoration: underline; }
|
||||
.pr-table td.pr-span { color: var(--mut2); font-variant-numeric: tabular-nums; white-space: nowrap; }
|
||||
.pr-footnote { margin: 12px 2px 2px; color: var(--mut); font-size: var(--fs-meta); line-height: 1.55; }
|
||||
.opt-waste { min-width: 0; }
|
||||
.opt-summary { padding: 0 0 10px; color: var(--mut); font-size: 11.5px; font-variant-numeric: tabular-nums; }
|
||||
.opt-findings { display: grid; min-width: 0; }
|
||||
|
|
|
|||
|
|
@ -42,6 +42,26 @@ export type PeriodData = {
|
|||
topReworkedFiles?: ReworkedFile[]
|
||||
/// Share (0-1) of cost-bearing calls that resolved a price.
|
||||
pricingCoverage?: number
|
||||
/// Spend attributed by referenced pull request (from Claude session
|
||||
/// transcripts). Rows are by-reference — a session referencing several PRs
|
||||
/// counts toward EACH — so never sum them; `distinctCost`/`distinctSessions`
|
||||
/// are the multi-link-safe total. Absent when no PR links were observed.
|
||||
pullRequests?: PullRequestsPayload
|
||||
/// Per-branch spend, last-seen branch carried forward across each session's
|
||||
/// turns. A `null` branch is unbranched spend inside a branch-bearing session.
|
||||
/// Rows are by-reference (a session that switched branches counts toward each),
|
||||
/// so never sum them. Absent when no branch data was observed.
|
||||
byBranch?: BranchRow[]
|
||||
}
|
||||
|
||||
export type PullRequestsPayload = {
|
||||
/// Per-PR rows, cost-descending, capped at the top 20 by the producer.
|
||||
rows: PrRow[]
|
||||
/// Distinct-session spend across every PR-linked session — the figure safe to
|
||||
/// present as a total, since the per-PR rows double-count sessions that
|
||||
/// reference more than one PR.
|
||||
distinctCost: number
|
||||
distinctSessions: number
|
||||
}
|
||||
|
||||
export type ProviderCost = {
|
||||
|
|
@ -55,6 +75,7 @@ import type { OptimizeResult } from './optimize.js'
|
|||
import { getCurrency } from './currency.js'
|
||||
import type { GranularHistory } from './granular-history.js'
|
||||
import type { ReworkedFile } from './workflow-insights.js'
|
||||
import type { PrRow, BranchRow } from './sessions-report.js'
|
||||
|
||||
const TOP_ACTIVITIES_LIMIT = 20
|
||||
const TOP_MODELS_LIMIT = 20
|
||||
|
|
@ -272,6 +293,17 @@ export type MenubarPayload = {
|
|||
skills: Array<{ name: string; turns: number; cost: number }>
|
||||
subagents: Array<{ name: string; calls: number; cost: number }>
|
||||
mcpServers: Array<{ name: string; calls: number }>
|
||||
/// Spend attributed by referenced pull request (top 20 by cost) plus the
|
||||
/// multi-link-safe distinct total. Rows are by-reference (a session
|
||||
/// referencing several PRs counts toward each), so never summed. Absent when
|
||||
/// no PR links were observed, and on payloads produced before the field
|
||||
/// existed — the client renders its empty state for both.
|
||||
pullRequests?: PullRequestsPayload
|
||||
/// Per-branch spend (top 15 by cost), last-seen branch carried forward across
|
||||
/// each session's turns; a `null` branch is unbranched spend inside a
|
||||
/// branch-bearing session. By-reference like the PR rows. Absent when no
|
||||
/// branch data was observed, and on payloads produced before the field.
|
||||
byBranch?: BranchRow[]
|
||||
}
|
||||
optimize: {
|
||||
findingCount: number
|
||||
|
|
@ -486,6 +518,11 @@ export function buildMenubarPayload(
|
|||
skills: breakdowns?.skills ?? [],
|
||||
subagents: breakdowns?.subagents ?? [],
|
||||
mcpServers: breakdowns?.mcpServers ?? [],
|
||||
// Add-only: emitted only when the producer computed them (all-provider
|
||||
// path), omitted otherwise so the schema stays stable for consumers that
|
||||
// predate the fields.
|
||||
...(current.pullRequests ? { pullRequests: current.pullRequests } : {}),
|
||||
...(current.byBranch ? { byBranch: current.byBranch } : {}),
|
||||
},
|
||||
optimize: buildOptimize(optimize),
|
||||
history: buildHistory(dailyHistory, granularHistory),
|
||||
|
|
|
|||
|
|
@ -2026,7 +2026,19 @@ async function scanProjectDirs(
|
|||
const cachedFile = section.files[filePath]
|
||||
if (!cachedFile || cachedFile.turns.length === 0) continue
|
||||
|
||||
let classifiedTurns = cachedFile.turns.map(cachedTurnToClassified)
|
||||
// Carry the git branch forward BEFORE the date filter below: the cache
|
||||
// stores a turn's branch only when it changes, so resolving here (over the
|
||||
// full ordered turn list) means a later date slice can drop the anchor turn
|
||||
// without the surviving turns losing their branch.
|
||||
let carriedBranch: string | undefined
|
||||
let classifiedTurns = cachedFile.turns.map(turn => {
|
||||
if (turn.gitBranch) carriedBranch = turn.gitBranch
|
||||
return cachedTurnToClassified(turn, carriedBranch)
|
||||
})
|
||||
// Captured from the FULL turn list, before the date slice below can drop the
|
||||
// turn a branch was first seen on. Lets the by-branch report keep this
|
||||
// session's in-range unbranched spend as `null` instead of discarding it.
|
||||
const everHadBranch = carriedBranch !== undefined
|
||||
|
||||
if (dateRange) {
|
||||
classifiedTurns = classifiedTurns.filter(turn => {
|
||||
|
|
@ -2046,6 +2058,7 @@ async function scanProjectDirs(
|
|||
const mcpInv = cachedFile.mcpInventory.length > 0 ? cachedFile.mcpInventory : undefined
|
||||
const session = buildSessionSummary(sessionId, projectName, classifiedTurns, mcpInv, source)
|
||||
session.agentType = cachedFile.agentType
|
||||
if (everHadBranch) session.everHadBranch = true
|
||||
if (cachedFile.prLinks?.length) session.prLinks = [...new Set(cachedFile.prLinks)].sort()
|
||||
if (cachedFile.title) session.title = cachedFile.title
|
||||
|
||||
|
|
@ -2318,12 +2331,19 @@ function cachedCallToApiCall(call: CachedCall): ParsedApiCall {
|
|||
})
|
||||
}
|
||||
|
||||
function cachedTurnToClassified(turn: CachedTurn): ClassifiedTurn {
|
||||
// `resolvedBranch` restores the turn's git branch after the cache's per-turn
|
||||
// dedup (branch stored only when it changes). Callers that serve a full session's
|
||||
// turns in order carry the last stored value forward and pass it here, so each
|
||||
// reconstructed turn regains the "branch active for this turn" the cache elided —
|
||||
// and downstream date/day filtering can slice turns without losing the anchor.
|
||||
function cachedTurnToClassified(turn: CachedTurn, resolvedBranch?: string): ClassifiedTurn {
|
||||
const branch = turn.gitBranch ?? resolvedBranch
|
||||
const parsed: ParsedTurn = {
|
||||
userMessage: turn.userMessage,
|
||||
assistantCalls: turn.calls.map(cachedCallToApiCall),
|
||||
timestamp: turn.timestamp,
|
||||
sessionId: turn.sessionId,
|
||||
...(branch ? { gitBranch: branch } : {}),
|
||||
}
|
||||
return classifyTurn(parsed)
|
||||
}
|
||||
|
|
@ -2938,7 +2958,9 @@ export function filterProjectsByDays(projects: ProjectSummary[], days: Set<strin
|
|||
return ds !== null && days.has(ds)
|
||||
})
|
||||
if (turns.length === 0) continue
|
||||
sessions.push(buildSessionSummary(session.sessionId, session.project, turns, session.mcpInventory, session.source))
|
||||
const rebuilt = buildSessionSummary(session.sessionId, session.project, turns, session.mcpInventory, session.source)
|
||||
if (session.everHadBranch) rebuilt.everHadBranch = true
|
||||
sessions.push(rebuilt)
|
||||
}
|
||||
if (sessions.length === 0) continue
|
||||
filtered.push(summarizeProject(project.project, project.projectPath, sessions))
|
||||
|
|
@ -2995,7 +3017,9 @@ export function filterProjectsByDateRange(projects: ProjectSummary[], dateRange:
|
|||
for (const session of project.sessions) {
|
||||
const turns = session.turns.filter(turn => turnIsInDateRange(turn, dateRange))
|
||||
if (turns.length === 0) continue
|
||||
sessions.push(buildSessionSummary(session.sessionId, session.project, turns, session.mcpInventory, session.source))
|
||||
const rebuilt = buildSessionSummary(session.sessionId, session.project, turns, session.mcpInventory, session.source)
|
||||
if (session.everHadBranch) rebuilt.everHadBranch = true
|
||||
sessions.push(rebuilt)
|
||||
}
|
||||
if (sessions.length === 0) continue
|
||||
filtered.push(summarizeProject(project.project, project.projectPath, sessions))
|
||||
|
|
|
|||
|
|
@ -146,3 +146,54 @@ export function prLinkedTotals(projects: ProjectSummary[]): { cost: number; sess
|
|||
}
|
||||
return { cost, sessions }
|
||||
}
|
||||
|
||||
export type BranchRow = {
|
||||
/// The git branch active for the attributed turns, or `null` for spend that
|
||||
/// occurred before any branch was observed within a branch-bearing session.
|
||||
branch: string | null
|
||||
cost: number
|
||||
calls: number
|
||||
sessions: number
|
||||
}
|
||||
|
||||
/// Per-branch spend, carrying each session's last-seen git branch forward across
|
||||
/// its turns. The cache stores a turn's branch only when it CHANGES, so a report
|
||||
/// must reconstruct each turn's branch from the last stored value — this walks a
|
||||
/// session's turns in order and does exactly that.
|
||||
///
|
||||
/// Only sessions that EVER observed a branch participate: a provider that never
|
||||
/// captures branch data (only Claude does today) would otherwise pile all of its
|
||||
/// spend into one `null` bucket that dwarfs every real branch. Within a
|
||||
/// participating session, turns before the first observed branch are attributed
|
||||
/// to a single explicit `null` row the caller can label honestly.
|
||||
///
|
||||
/// A session that switches branches counts toward EACH branch it touched (like
|
||||
/// the by-PR by-reference attribution), so rows must never be summed into a grand
|
||||
/// total. Sorted by cost, descending.
|
||||
export function aggregateByBranch(projects: ProjectSummary[]): BranchRow[] {
|
||||
const byBranch = new Map<string | null, { cost: number; calls: number; sessions: Set<string> }>()
|
||||
for (const project of projects) {
|
||||
for (const session of project.sessions) {
|
||||
// Participate when the session observed a branch anywhere in its full
|
||||
// transcript (`everHadBranch`, set pre-date-filter) — falling back to the
|
||||
// turns in hand for producers/fixtures that don't set the flag. A session
|
||||
// that never observed a branch (every non-Claude provider) is skipped so
|
||||
// it can't pile into the null bucket.
|
||||
if (!session.everHadBranch && !session.turns.some(turn => turn.gitBranch)) continue
|
||||
let current: string | null = null
|
||||
for (const turn of session.turns) {
|
||||
if (turn.gitBranch) current = turn.gitBranch
|
||||
if (turn.assistantCalls.length === 0) continue
|
||||
const turnCost = turn.assistantCalls.reduce((sum, call) => sum + call.costUSD, 0)
|
||||
const row = byBranch.get(current) ?? { cost: 0, calls: 0, sessions: new Set<string>() }
|
||||
row.cost += turnCost
|
||||
row.calls += turn.assistantCalls.length
|
||||
row.sessions.add(session.sessionId)
|
||||
byBranch.set(current, row)
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...byBranch.entries()]
|
||||
.map(([branch, d]) => ({ branch, cost: d.cost, calls: d.calls, sessions: d.sessions.size }))
|
||||
.sort((a, b) => b.cost - a.cost)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -205,6 +205,13 @@ export type SessionSummary = {
|
|||
/// Human session title captured from the transcript (last ai-title entry).
|
||||
/// Absent when the transcript never produced one.
|
||||
title?: string
|
||||
/// True when the session observed a git branch on ANY turn of its FULL
|
||||
/// (pre-date-filter) transcript. Set before turns are sliced to a range so the
|
||||
/// by-branch report can still tell a branch-bearing Claude session (whose
|
||||
/// in-range turns may all predate its first branch → the `null` bucket) apart
|
||||
/// from a provider that never captures branches (→ contributes nothing).
|
||||
/// Claude only; absent otherwise.
|
||||
everHadBranch?: boolean
|
||||
modelBreakdown: Record<string, { calls: number; costUSD: number; tokens: TokenUsage; savingsUSD: number; estimatedCostUSD?: number }>
|
||||
toolBreakdown: Record<string, { calls: number }>
|
||||
mcpBreakdown: Record<string, { calls: number }>
|
||||
|
|
|
|||
|
|
@ -10,10 +10,15 @@ import { aggregateProjectsIntoDays, buildPeriodDataFromDays } from './day-aggreg
|
|||
import { aggregateModelEfficiency } from './model-efficiency.js'
|
||||
import { aggregateModels } from './models-report.js'
|
||||
import { scanUserCorrections, medianTimeToFirstEditMs, aggregateFileChurn, computePricingCoverage } from './workflow-insights.js'
|
||||
import { aggregateByPr, prLinkedTotals, aggregateByBranch } from './sessions-report.js'
|
||||
import { scanAndDetect } from './optimize.js'
|
||||
import { getDaysInRange, ensureCacheHydrated, emptyCache, BACKFILL_DAYS, toDateString, type DailyCache, type DailyEntry } from './daily-cache.js'
|
||||
import { buildGranularHistory } from './granular-history.js'
|
||||
|
||||
// Row caps for the by-PR / by-branch payload aggregations, ranked by cost.
|
||||
const TOP_PULL_REQUESTS = 20
|
||||
const TOP_BRANCHES = 15
|
||||
|
||||
export function buildPeriodData(label: string, projects: ProjectSummary[]): PeriodData {
|
||||
const sessions = projects.flatMap(p => p.sessions)
|
||||
const catTotals: Record<string, { turns: number; cost: number; savingsUSD: number; editTurns: number; oneShotTurns: number }> = {}
|
||||
|
|
@ -649,6 +654,28 @@ export async function buildMenubarPayloadForRange(periodInfo: PeriodInfo, opts:
|
|||
}))
|
||||
).sort((a, b) => (b.cost + b.savingsUSD) - (a.cost + a.savingsUSD)).slice(0, 5)
|
||||
|
||||
// PULL REQUESTS + BRANCHES (all-provider path only). Both are session-layer
|
||||
// aggregations over the surviving-session parse, so carried history cannot
|
||||
// contribute — expected and fine. PR links and per-turn git branches are
|
||||
// captured only from Claude transcripts today; other providers add nothing.
|
||||
// Set only when non-empty so the payload omits them (and the app renders its
|
||||
// quiet empty state) whenever there is nothing to show. Excluded on the
|
||||
// Claude-config-scoped path (which replaces scanProjects with one config's
|
||||
// sessions) so this stays the genuine unscoped all-provider aggregation.
|
||||
if (isAllProviders && !effectivelyScoped) {
|
||||
const prRows = aggregateByPr(scanProjects)
|
||||
if (prRows.length > 0) {
|
||||
const prTotals = prLinkedTotals(scanProjects)
|
||||
currentData.pullRequests = {
|
||||
rows: prRows.slice(0, TOP_PULL_REQUESTS),
|
||||
distinctCost: prTotals.cost,
|
||||
distinctSessions: prTotals.sessions,
|
||||
}
|
||||
}
|
||||
const branchRows = aggregateByBranch(scanProjects)
|
||||
if (branchRows.length > 0) currentData.byBranch = branchRows.slice(0, TOP_BRANCHES)
|
||||
}
|
||||
|
||||
// Routing waste: find cheapest reliable model (≥90% 1-shot, ≥5 edits),
|
||||
// then compute how much each pricier model overpaid.
|
||||
const reliableModels = [...effMap.values()]
|
||||
|
|
|
|||
129
tests/sessions-by-branch.test.ts
Normal file
129
tests/sessions-by-branch.test.ts
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { aggregateByBranch } from '../src/sessions-report.js'
|
||||
import type { ClassifiedTurn, ParsedApiCall, ProjectSummary, SessionSummary, TokenUsage } from '../src/types.js'
|
||||
|
||||
const ZERO_USAGE: TokenUsage = {
|
||||
inputTokens: 0, outputTokens: 0, cacheCreationInputTokens: 0,
|
||||
cacheReadInputTokens: 0, cachedInputTokens: 0, reasoningTokens: 0, webSearchRequests: 0,
|
||||
}
|
||||
|
||||
let keySeq = 0
|
||||
function call(cost: number): ParsedApiCall {
|
||||
return {
|
||||
provider: 'claude', model: 'claude', usage: ZERO_USAGE, costUSD: cost,
|
||||
tools: [], mcpTools: [], skills: [], subagentTypes: [],
|
||||
hasAgentSpawn: false, hasPlanMode: false, speed: 'standard',
|
||||
timestamp: '2026-07-01T10:00:00Z', bashCommands: [], deduplicationKey: `k${keySeq++}`,
|
||||
}
|
||||
}
|
||||
|
||||
// A turn whose total cost `cost` is split across `calls` API calls. `gitBranch`
|
||||
// is set only when supplied — the cache stores it only on the turn where the
|
||||
// branch CHANGES, so omitting it here is exactly what the carry-forward under
|
||||
// test must reconstruct.
|
||||
function turn(cost: number, calls = 1, gitBranch?: string): ClassifiedTurn {
|
||||
return {
|
||||
userMessage: '',
|
||||
assistantCalls: Array.from({ length: calls }, () => call(cost / calls)),
|
||||
timestamp: '2026-07-01T10:00:00Z', sessionId: 's',
|
||||
category: 'coding', retries: 0, hasEdits: false,
|
||||
...(gitBranch ? { gitBranch } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
function session(id: string, turns: ClassifiedTurn[], everHadBranch?: boolean): SessionSummary {
|
||||
return {
|
||||
sessionId: id, project: 'p',
|
||||
firstTimestamp: '2026-07-01T10:00:00Z', lastTimestamp: '2026-07-01T11:00:00Z',
|
||||
totalCostUSD: 0, totalSavingsUSD: 0, totalEstimatedCostUSD: 0,
|
||||
totalInputTokens: 0, totalOutputTokens: 0, totalReasoningTokens: 0,
|
||||
totalCacheReadTokens: 0, totalCacheWriteTokens: 0,
|
||||
apiCalls: 0, turns,
|
||||
modelBreakdown: {}, toolBreakdown: {}, mcpBreakdown: {}, bashBreakdown: {},
|
||||
categoryBreakdown: {} as SessionSummary['categoryBreakdown'],
|
||||
skillBreakdown: {} as SessionSummary['skillBreakdown'],
|
||||
subagentBreakdown: {} as SessionSummary['subagentBreakdown'],
|
||||
...(everHadBranch ? { everHadBranch } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
function project(sessions: SessionSummary[]): ProjectSummary {
|
||||
return { project: 'p', projectPath: '/p', sessions, totalCostUSD: 0, totalSavingsUSD: 0, totalApiCalls: 0, totalProxiedCostUSD: 0 }
|
||||
}
|
||||
|
||||
describe('aggregateByBranch', () => {
|
||||
it('carries the last-seen branch forward across turns that omit it', () => {
|
||||
// The cache stored `main` on turn 1 (its first appearance) and nothing on
|
||||
// turn 2 (unchanged); turn 3 changed to `feature`. The report must attribute
|
||||
// turn 2 to `main` by carry-forward.
|
||||
const rows = aggregateByBranch([project([
|
||||
session('s', [
|
||||
turn(100, 2, 'main'),
|
||||
turn(50, 1),
|
||||
turn(30, 1, 'feature'),
|
||||
]),
|
||||
])])
|
||||
expect(rows.map(r => r.branch)).toEqual(['main', 'feature'])
|
||||
const main = rows[0]!
|
||||
expect(main.cost).toBeCloseTo(150, 6)
|
||||
expect(main.calls).toBe(3)
|
||||
expect(main.sessions).toBe(1)
|
||||
const feature = rows[1]!
|
||||
expect(feature.cost).toBeCloseTo(30, 6)
|
||||
expect(feature.calls).toBe(1)
|
||||
})
|
||||
|
||||
it('attributes spend before the first observed branch to an explicit null row', () => {
|
||||
const rows = aggregateByBranch([project([
|
||||
session('s', [
|
||||
turn(20, 1), // unbranched prefix
|
||||
turn(80, 1, 'main'),
|
||||
]),
|
||||
])])
|
||||
expect(rows.map(r => r.branch)).toEqual(['main', null])
|
||||
expect(rows.find(r => r.branch === null)!.cost).toBeCloseTo(20, 6)
|
||||
expect(rows.find(r => r.branch === 'main')!.cost).toBeCloseTo(80, 6)
|
||||
})
|
||||
|
||||
it('keeps in-range unbranched spend as null when the branch anchor was filtered out (everHadBranch)', () => {
|
||||
// A branch-bearing session whose only in-range turns predate its first
|
||||
// branch: the date slice dropped the anchor, but everHadBranch (captured
|
||||
// pre-filter) still lets its $20 land in the explicit null row.
|
||||
const rows = aggregateByBranch([project([
|
||||
session('s', [turn(20, 1)], true),
|
||||
])])
|
||||
expect(rows).toEqual([{ branch: null, cost: 20, calls: 1, sessions: 1 }])
|
||||
})
|
||||
|
||||
it('drops sessions that never observed a branch (no null bucket that dwarfs the rest)', () => {
|
||||
const rows = aggregateByBranch([project([
|
||||
session('branched', [turn(10, 1, 'main')]),
|
||||
session('unbranched', [turn(999, 3), turn(999, 3)]),
|
||||
])])
|
||||
expect(rows).toEqual([{ branch: 'main', cost: 10, calls: 1, sessions: 1 }])
|
||||
})
|
||||
|
||||
it('counts a session toward each branch it touched, once', () => {
|
||||
const rows = aggregateByBranch([project([
|
||||
session('a', [turn(40, 1, 'main'), turn(10, 1, 'feature')]),
|
||||
session('b', [turn(30, 1, 'main')]),
|
||||
])])
|
||||
const main = rows.find(r => r.branch === 'main')!
|
||||
expect(main.sessions).toBe(2)
|
||||
expect(main.cost).toBeCloseTo(70, 6)
|
||||
const feature = rows.find(r => r.branch === 'feature')!
|
||||
expect(feature.sessions).toBe(1)
|
||||
})
|
||||
|
||||
it('sorts rows by cost, descending', () => {
|
||||
const rows = aggregateByBranch([project([
|
||||
session('a', [turn(5, 1, 'small'), turn(200, 1, 'big'), turn(50, 1, 'mid')]),
|
||||
])])
|
||||
expect(rows.map(r => r.branch)).toEqual(['big', 'mid', 'small'])
|
||||
})
|
||||
|
||||
it('returns nothing when no session carries branch data', () => {
|
||||
expect(aggregateByBranch([project([session('a', [turn(100, 1)])])])).toEqual([])
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue