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:
AgentSeal 2026-04-16 18:54:30 +02:00 committed by GitHub
commit e228903d68
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 185 additions and 17 deletions

View file

@ -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) => {

View file

@ -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
View 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))
})
})