mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-05-08 14:50:57 +00:00
Merge pull request #51 from lfl1337/feat/power-user-stats
feat: All Time period, avg/s per project, Top Sessions panel
This commit is contained in:
commit
e228903d68
3 changed files with 185 additions and 17 deletions
|
|
@ -50,10 +50,11 @@ function getDateRange(period: string): { range: DateRange; label: string } {
|
|||
}
|
||||
}
|
||||
|
||||
function toPeriod(s: string): 'today' | 'week' | '30days' | 'month' {
|
||||
function toPeriod(s: string): 'today' | 'week' | '30days' | 'month' | 'all' {
|
||||
if (s === 'today') return 'today'
|
||||
if (s === 'month') return 'month'
|
||||
if (s === '30days') return '30days'
|
||||
if (s === 'all') return 'all'
|
||||
return 'week'
|
||||
}
|
||||
|
||||
|
|
@ -69,7 +70,7 @@ program.hook('preAction', async () => {
|
|||
program
|
||||
.command('report', { isDefault: true })
|
||||
.description('Interactive usage dashboard')
|
||||
.option('-p, --period <period>', 'Starting period: today, week, 30days, month', 'week')
|
||||
.option('-p, --period <period>', 'Starting period: today, week, 30days, month, all', 'week')
|
||||
.option('--provider <provider>', 'Filter by provider: all, claude, codex, cursor', 'all')
|
||||
.option('--refresh <seconds>', 'Auto-refresh interval in seconds', parseInt)
|
||||
.action(async (opts) => {
|
||||
|
|
|
|||
|
|
@ -8,14 +8,15 @@ import { parseAllSessions } from './parser.js'
|
|||
import { loadPricing } from './models.js'
|
||||
import { getAllProviders } from './providers/index.js'
|
||||
|
||||
type Period = 'today' | 'week' | '30days' | 'month'
|
||||
type Period = 'today' | 'week' | '30days' | 'month' | 'all'
|
||||
|
||||
const PERIODS: Period[] = ['today', 'week', '30days', 'month']
|
||||
const PERIODS: Period[] = ['today', 'week', '30days', 'month', 'all']
|
||||
const PERIOD_LABELS: Record<Period, string> = {
|
||||
today: 'Today',
|
||||
week: '7 Days',
|
||||
'30days': '30 Days',
|
||||
month: 'This Month',
|
||||
all: 'All Time',
|
||||
}
|
||||
|
||||
const MIN_WIDE = 90
|
||||
|
|
@ -37,6 +38,7 @@ const PANEL_COLORS = {
|
|||
overview: '#FF8C42',
|
||||
daily: '#5B9EF5',
|
||||
project: '#5BF5A0',
|
||||
sessions: '#FF6B6B',
|
||||
model: '#E05BF5',
|
||||
activity: '#F5C85B',
|
||||
tools: '#5BF5E0',
|
||||
|
|
@ -99,6 +101,7 @@ function getDateRange(period: Period): { start: Date; end: Date } {
|
|||
case 'week': return { start: new Date(now.getFullYear(), now.getMonth(), now.getDate() - 7), end }
|
||||
case '30days': return { start: new Date(now.getFullYear(), now.getMonth(), now.getDate() - 30), end }
|
||||
case 'month': return { start: new Date(now.getFullYear(), now.getMonth(), 1), end }
|
||||
case 'all': return { start: new Date(0), end }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -191,7 +194,7 @@ function DailyActivity({ projects, days = 14, pw, bw }: { projects: ProjectSumma
|
|||
}
|
||||
}
|
||||
}
|
||||
const sortedDays = Object.keys(dailyCosts).sort().slice(-days)
|
||||
const sortedDays = days !== undefined ? Object.keys(dailyCosts).sort().slice(-days) : Object.keys(dailyCosts).sort()
|
||||
const maxCost = Math.max(...sortedDays.map(d => dailyCosts[d] ?? 0))
|
||||
|
||||
return (
|
||||
|
|
@ -230,20 +233,28 @@ function shortProject(encoded: string): string {
|
|||
return parts.slice(-3).join('/')
|
||||
}
|
||||
|
||||
const PROJECT_COL_AVG = 7
|
||||
|
||||
function ProjectBreakdown({ projects, pw, bw }: { projects: ProjectSummary[]; pw: number; bw: number }) {
|
||||
const maxCost = Math.max(...projects.map(p => p.totalCostUSD))
|
||||
const nw = Math.max(8, pw - bw - 23)
|
||||
const nw = Math.max(8, pw - bw - 30)
|
||||
return (
|
||||
<Panel title="By Project" color={PANEL_COLORS.project} width={pw}>
|
||||
<Text dimColor wrap="truncate-end">{''.padEnd(bw + 1 + nw)}{'cost'.padStart(8)}{'sess'.padStart(6)}</Text>
|
||||
{projects.slice(0, 8).map((project, i) => (
|
||||
<Text key={`${project.project}-${i}`} wrap="truncate-end">
|
||||
<HBar value={project.totalCostUSD} max={maxCost} width={bw} />
|
||||
<Text dimColor> {fit(shortProject(project.project), nw)}</Text>
|
||||
<Text color={GOLD}>{formatCost(project.totalCostUSD).padStart(8)}</Text>
|
||||
<Text>{String(project.sessions.length).padStart(6)}</Text>
|
||||
</Text>
|
||||
))}
|
||||
<Text dimColor wrap="truncate-end">{''.padEnd(bw + 1 + nw)}{'cost'.padStart(8)}{'avg/s'.padStart(PROJECT_COL_AVG)}{'sess'.padStart(6)}</Text>
|
||||
{projects.slice(0, 8).map((project, i) => {
|
||||
const avgCost = project.sessions.length > 0
|
||||
? formatCost(project.totalCostUSD / project.sessions.length)
|
||||
: '-'
|
||||
return (
|
||||
<Text key={`${project.project}-${i}`} wrap="truncate-end">
|
||||
<HBar value={project.totalCostUSD} max={maxCost} width={bw} />
|
||||
<Text dimColor> {fit(shortProject(project.project), nw)}</Text>
|
||||
<Text color={GOLD}>{formatCost(project.totalCostUSD).padStart(8)}</Text>
|
||||
<Text color={GOLD}>{avgCost.padStart(PROJECT_COL_AVG)}</Text>
|
||||
<Text>{String(project.sessions.length).padStart(6)}</Text>
|
||||
</Text>
|
||||
)
|
||||
})}
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
|
|
@ -364,6 +375,44 @@ function ToolBreakdown({ projects, pw, bw, title, filterPrefix }: { projects: Pr
|
|||
)
|
||||
}
|
||||
|
||||
const TOP_SESSIONS_DATE_LEN = 10
|
||||
const TOP_SESSIONS_COST_COL = 8
|
||||
const TOP_SESSIONS_CALLS_COL = 6
|
||||
|
||||
function TopSessions({ projects, pw, bw }: { projects: ProjectSummary[]; pw: number; bw: number }) {
|
||||
const allSessions = projects.flatMap(p =>
|
||||
p.sessions.map(s => ({ ...s, projectName: p.project }))
|
||||
)
|
||||
const top = [...allSessions].sort((a, b) => b.totalCostUSD - a.totalCostUSD).slice(0, 5)
|
||||
|
||||
if (top.length === 0) {
|
||||
return <Panel title="Top Sessions" color={PANEL_COLORS.sessions} width={pw}><Text dimColor>No sessions</Text></Panel>
|
||||
}
|
||||
|
||||
const maxCost = top[0].totalCostUSD
|
||||
const nw = Math.max(8, pw - bw - TOP_SESSIONS_COST_COL - TOP_SESSIONS_CALLS_COL - 1)
|
||||
|
||||
return (
|
||||
<Panel title="Top Sessions" color={PANEL_COLORS.sessions} width={pw}>
|
||||
<Text dimColor wrap="truncate-end">{''.padEnd(bw + 1 + nw)}{'cost'.padStart(TOP_SESSIONS_COST_COL)}{'calls'.padStart(TOP_SESSIONS_CALLS_COL)}</Text>
|
||||
{top.map((session, i) => {
|
||||
const date = session.firstTimestamp
|
||||
? session.firstTimestamp.slice(0, TOP_SESSIONS_DATE_LEN)
|
||||
: '----------'
|
||||
const label = `${date} ${shortProject(session.projectName)}`
|
||||
return (
|
||||
<Text key={`${session.sessionId}-${i}`} wrap="truncate-end">
|
||||
<HBar value={session.totalCostUSD} max={maxCost} width={bw} />
|
||||
<Text dimColor> {fit(label, nw - 1)}</Text>
|
||||
<Text color={GOLD}>{formatCost(session.totalCostUSD).padStart(TOP_SESSIONS_COST_COL)}</Text>
|
||||
<Text>{String(session.apiCalls).padStart(TOP_SESSIONS_CALLS_COL)}</Text>
|
||||
</Text>
|
||||
)
|
||||
})}
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
|
||||
function McpBreakdown({ projects, pw, bw }: { projects: ProjectSummary[]; pw: number; bw: number }) {
|
||||
const mcpTotals: Record<string, number> = {}
|
||||
for (const project of projects) {
|
||||
|
|
@ -477,7 +526,9 @@ function StatusBar({ width, showProvider }: { width: number; showProvider?: bool
|
|||
<Text color={ORANGE} bold>3</Text>
|
||||
<Text dimColor> 30 days </Text>
|
||||
<Text color={ORANGE} bold>4</Text>
|
||||
<Text dimColor> month</Text>
|
||||
<Text dimColor> month </Text>
|
||||
<Text color={ORANGE} bold>5</Text>
|
||||
<Text dimColor> all time</Text>
|
||||
{showProvider && (
|
||||
<>
|
||||
<Text dimColor> </Text>
|
||||
|
|
@ -508,7 +559,8 @@ function DashboardContent({ projects, period, columns, activeProvider }: { proje
|
|||
}
|
||||
|
||||
const pw = wide ? halfWidth : dashWidth
|
||||
const days = period === 'month' || period === '30days' ? 31 : 14
|
||||
// undefined = no cutoff (show all days); 31 for month/30-day ranges; 14 for shorter periods
|
||||
const days = period === 'all' ? undefined : (period === 'month' || period === '30days' ? 31 : 14)
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" width={dashWidth}>
|
||||
|
|
@ -519,6 +571,8 @@ function DashboardContent({ projects, period, columns, activeProvider }: { proje
|
|||
<ProjectBreakdown projects={projects} pw={pw} bw={barWidth} />
|
||||
</Row>
|
||||
|
||||
<TopSessions projects={projects} pw={dashWidth} bw={barWidth} />
|
||||
|
||||
<Row wide={wide} width={dashWidth}>
|
||||
<ActivityBreakdown projects={projects} pw={pw} bw={barWidth} />
|
||||
<ModelBreakdown projects={projects} pw={pw} bw={barWidth} />
|
||||
|
|
@ -629,6 +683,7 @@ function InteractiveDashboard({ initialProjects, initialPeriod, initialProvider,
|
|||
else if (input === '2') switchPeriodImmediate('week')
|
||||
else if (input === '3') switchPeriodImmediate('30days')
|
||||
else if (input === '4') switchPeriodImmediate('month')
|
||||
else if (input === '5') switchPeriodImmediate('all')
|
||||
})
|
||||
|
||||
if (loading) {
|
||||
|
|
|
|||
112
tests/dashboard.test.ts
Normal file
112
tests/dashboard.test.ts
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
import { describe, it, expect } from 'vitest'
|
||||
|
||||
import { formatCost } from '../src/format.js'
|
||||
import type { ProjectSummary, SessionSummary } from '../src/types.js'
|
||||
|
||||
const EMPTY_CATEGORY_BREAKDOWN = {
|
||||
coding: { turns: 0, costUSD: 0, retries: 0, editTurns: 0, oneShotTurns: 0 },
|
||||
debugging: { turns: 0, costUSD: 0, retries: 0, editTurns: 0, oneShotTurns: 0 },
|
||||
feature: { turns: 0, costUSD: 0, retries: 0, editTurns: 0, oneShotTurns: 0 },
|
||||
refactoring: { turns: 0, costUSD: 0, retries: 0, editTurns: 0, oneShotTurns: 0 },
|
||||
testing: { turns: 0, costUSD: 0, retries: 0, editTurns: 0, oneShotTurns: 0 },
|
||||
exploration: { turns: 0, costUSD: 0, retries: 0, editTurns: 0, oneShotTurns: 0 },
|
||||
planning: { turns: 0, costUSD: 0, retries: 0, editTurns: 0, oneShotTurns: 0 },
|
||||
delegation: { turns: 0, costUSD: 0, retries: 0, editTurns: 0, oneShotTurns: 0 },
|
||||
git: { turns: 0, costUSD: 0, retries: 0, editTurns: 0, oneShotTurns: 0 },
|
||||
'build/deploy': { turns: 0, costUSD: 0, retries: 0, editTurns: 0, oneShotTurns: 0 },
|
||||
conversation: { turns: 0, costUSD: 0, retries: 0, editTurns: 0, oneShotTurns: 0 },
|
||||
brainstorming: { turns: 0, costUSD: 0, retries: 0, editTurns: 0, oneShotTurns: 0 },
|
||||
general: { turns: 0, costUSD: 0, retries: 0, editTurns: 0, oneShotTurns: 0 },
|
||||
} satisfies SessionSummary['categoryBreakdown']
|
||||
|
||||
function makeSession(id: string, cost: number, timestamp = '2026-04-14T10:00:00Z'): SessionSummary {
|
||||
return {
|
||||
sessionId: id,
|
||||
project: 'test-project',
|
||||
firstTimestamp: timestamp,
|
||||
lastTimestamp: timestamp,
|
||||
totalCostUSD: cost,
|
||||
totalInputTokens: 0,
|
||||
totalOutputTokens: 0,
|
||||
totalCacheReadTokens: 0,
|
||||
totalCacheWriteTokens: 0,
|
||||
apiCalls: 1,
|
||||
turns: [],
|
||||
modelBreakdown: {},
|
||||
toolBreakdown: {},
|
||||
mcpBreakdown: {},
|
||||
bashBreakdown: {},
|
||||
categoryBreakdown: { ...EMPTY_CATEGORY_BREAKDOWN },
|
||||
}
|
||||
}
|
||||
|
||||
function makeProject(name: string, sessions: SessionSummary[]): ProjectSummary {
|
||||
return {
|
||||
project: name,
|
||||
projectPath: name,
|
||||
sessions,
|
||||
totalCostUSD: sessions.reduce((s, x) => s + x.totalCostUSD, 0),
|
||||
totalApiCalls: sessions.reduce((s, x) => s + x.apiCalls, 0),
|
||||
}
|
||||
}
|
||||
|
||||
// Logic replicated from TopSessions component
|
||||
function getTopSessions(projects: ProjectSummary[], n = 5) {
|
||||
const all = projects.flatMap(p => p.sessions.map(s => ({ ...s, projectName: p.project })))
|
||||
return [...all].sort((a, b) => b.totalCostUSD - a.totalCostUSD).slice(0, n)
|
||||
}
|
||||
|
||||
// Logic replicated from ProjectBreakdown component
|
||||
function avgCostLabel(project: ProjectSummary): string {
|
||||
return project.sessions.length > 0
|
||||
? formatCost(project.totalCostUSD / project.sessions.length)
|
||||
: '-'
|
||||
}
|
||||
|
||||
describe('TopSessions - top-5 selection', () => {
|
||||
it('returns all sessions when fewer than 5 exist', () => {
|
||||
const project = makeProject('proj', [
|
||||
makeSession('s1', 1.0),
|
||||
makeSession('s2', 2.0),
|
||||
])
|
||||
const top = getTopSessions([project])
|
||||
expect(top).toHaveLength(2)
|
||||
expect(top[0].totalCostUSD).toBe(2.0)
|
||||
expect(top[1].totalCostUSD).toBe(1.0)
|
||||
})
|
||||
|
||||
it('returns exactly 5 when more than 5 sessions exist', () => {
|
||||
const sessions = [0.1, 0.5, 3.0, 1.0, 0.8, 2.0].map((cost, i) =>
|
||||
makeSession(`s${i}`, cost)
|
||||
)
|
||||
const project = makeProject('proj', sessions)
|
||||
const top = getTopSessions([project])
|
||||
expect(top).toHaveLength(5)
|
||||
expect(top[0].totalCostUSD).toBe(3.0)
|
||||
expect(top[4].totalCostUSD).toBe(0.5)
|
||||
})
|
||||
|
||||
it('is stable on tied costs - preserves input order for equal values', () => {
|
||||
const sessions = [
|
||||
makeSession('s1', 1.0),
|
||||
makeSession('s2', 1.0),
|
||||
makeSession('s3', 1.0),
|
||||
]
|
||||
const project = makeProject('proj', sessions)
|
||||
const top = getTopSessions([project])
|
||||
expect(top.map(s => s.sessionId)).toEqual(['s1', 's2', 's3'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('avg/s in ProjectBreakdown', () => {
|
||||
it('returns dash for a project with no sessions', () => {
|
||||
const project = makeProject('proj', [])
|
||||
expect(avgCostLabel(project)).toBe('-')
|
||||
})
|
||||
|
||||
it('returns formatted average cost across sessions', () => {
|
||||
const sessions = [makeSession('s1', 2.0), makeSession('s2', 4.0)]
|
||||
const project = makeProject('proj', sessions)
|
||||
expect(avgCostLabel(project)).toBe(formatCost(3.0))
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue