diff --git a/src/analytics.ts b/src/analytics.ts new file mode 100644 index 0000000..7433dc3 --- /dev/null +++ b/src/analytics.ts @@ -0,0 +1,120 @@ +import type { ProjectSummary, SessionSummary, TaskCategory } from './types.js' +import { CATEGORY_LABELS } from './types.js' +import { getShortModelName } from './models.js' + +export const TOP_OUTLIER_COUNT = 5 +export const OUTLIER_MULTIPLIER = 2 + +export type OutlierSession = { + rank: number + project: string + sessionId: string + date: string + totalCostUSD: number + dominantActivity: string + isOutlier: boolean +} + +export type ModelOneShotRow = { + model: string + sessions: number + oneShotTurns: number + editTurns: number + oneShotRate: number | null + costUSD: number +} + +export function dominantActivity(session: SessionSummary): string { + let best: TaskCategory | null = null + let bestCost = 0 + for (const [cat, data] of Object.entries(session.categoryBreakdown)) { + if (data.costUSD > bestCost) { + bestCost = data.costUSD + best = cat as TaskCategory + } + } + return best ? (CATEGORY_LABELS[best] ?? best) : 'General' +} + +export function computeOutlierSessions(projects: ProjectSummary[]): OutlierSession[] { + const projectAvg = new Map() + for (const p of projects) { + const avg = p.sessions.length > 0 ? p.totalCostUSD / p.sessions.length : 0 + projectAvg.set(p.project, avg) + } + + const all = projects.flatMap(p => + p.sessions.map(s => ({ session: s, project: p.project })) + ) + const sorted = [...all].sort((a, b) => b.session.totalCostUSD - a.session.totalCostUSD) + const top = sorted.slice(0, TOP_OUTLIER_COUNT) + + return top.map(({ session, project }, i) => { + const avg = projectAvg.get(project) ?? 0 + return { + rank: i + 1, + project, + sessionId: session.sessionId, + date: session.firstTimestamp ? session.firstTimestamp.slice(0, 10) : '----------', + totalCostUSD: session.totalCostUSD, + dominantActivity: dominantActivity(session), + isOutlier: avg > 0 && session.totalCostUSD > OUTLIER_MULTIPLIER * avg, + } + }) +} + +export function computeModelOneShotRates(projects: ProjectSummary[]): ModelOneShotRow[] { + const modelData = new Map; costUSD: number }>() + + for (const project of projects) { + for (const session of project.sessions) { + for (const turn of session.turns) { + const primaryModel = turn.assistantCalls[0] + ? getShortModelName(turn.assistantCalls[0].model) + : null + if (!primaryModel) continue + const entry = modelData.get(primaryModel) ?? { + oneShotTurns: 0, + editTurns: 0, + sessions: new Set(), + costUSD: 0, + } + if (turn.hasEdits) { + entry.editTurns++ + if (turn.retries === 0) entry.oneShotTurns++ + } + modelData.set(primaryModel, entry) + } + for (const [model, data] of Object.entries(session.modelBreakdown)) { + const entry = modelData.get(model) ?? { + oneShotTurns: 0, + editTurns: 0, + sessions: new Set(), + costUSD: 0, + } + entry.costUSD += data.costUSD + entry.sessions.add(session.sessionId) + modelData.set(model, entry) + } + } + } + + const rows: ModelOneShotRow[] = [] + for (const [model, data] of modelData) { + rows.push({ + model, + sessions: data.sessions.size, + oneShotTurns: data.oneShotTurns, + editTurns: data.editTurns, + oneShotRate: data.editTurns > 0 ? data.oneShotTurns / data.editTurns : null, + costUSD: data.costUSD, + }) + } + + return rows.sort((a, b) => { + if (a.oneShotRate === null && b.oneShotRate === null) return b.costUSD - a.costUSD + if (a.oneShotRate === null) return 1 + if (b.oneShotRate === null) return -1 + return b.oneShotRate - a.oneShotRate || b.costUSD - a.costUSD + }) +} diff --git a/src/cli.ts b/src/cli.ts index ac87127..d6766c6 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -10,6 +10,7 @@ import { buildMenubarPayload } from './menubar-json.js' import { addNewDays, getDaysInRange, loadDailyCache, saveDailyCache, withDailyCacheLock } from './daily-cache.js' import { aggregateProjectsIntoDays, buildPeriodDataFromDays } from './day-aggregator.js' import { CATEGORY_LABELS, type DateRange, type ProjectSummary, type TaskCategory } from './types.js' +import { computeOutlierSessions, computeModelOneShotRates } from './analytics.js' import { renderDashboard } from './dashboard.js' import { runOptimize, scanAndDetect } from './optimize.js' import { getAllProviders } from './providers/index.js' @@ -204,6 +205,25 @@ function buildJsonReport(projects: ProjectSummary[], period: string, periodKey: .sort((a, b) => b.cost - a.cost) .slice(0, 5) + const outlierSessions = computeOutlierSessions(projects).map(r => ({ + rank: r.rank, + project: r.project, + sessionId: r.sessionId, + date: r.date, + cost: convertCost(r.totalCostUSD), + dominantActivity: r.dominantActivity, + isOutlier: r.isOutlier, + })) + + const modelOneShotRates = computeModelOneShotRates(projects).map(r => ({ + model: r.model, + sessions: r.sessions, + oneShotRate: r.oneShotRate !== null ? Math.round(r.oneShotRate * 1000) / 10 : null, + editTurns: r.editTurns, + oneShotTurns: r.oneShotTurns, + cost: convertCost(r.costUSD), + })) + return { generated: new Date().toISOString(), currency: code, @@ -229,6 +249,8 @@ function buildJsonReport(projects: ProjectSummary[], period: string, periodKey: mcpServers: sortedMap(mcpMap), shellCommands: sortedMap(bashMap), topSessions, + outlierSessions, + modelOneShotRates, } } diff --git a/src/dashboard.tsx b/src/dashboard.tsx index 21281f8..b66a488 100644 --- a/src/dashboard.tsx +++ b/src/dashboard.tsx @@ -3,6 +3,7 @@ import { homedir } from 'os' import React, { useState, useCallback, useEffect, useRef } from 'react' import { render, Box, Text, useInput, useApp, useWindowSize } from 'ink' import { CATEGORY_LABELS, type ProjectSummary, type TaskCategory } from './types.js' +import { computeOutlierSessions, computeModelOneShotRates, TOP_OUTLIER_COUNT, OUTLIER_MULTIPLIER } from './analytics.js' import { formatCost, formatTokens } from './format.js' import { parseAllSessions, filterProjectsByName } from './parser.js' import { loadPricing } from './models.js' @@ -374,37 +375,73 @@ 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 +const TOP_SESSIONS_ACT_COL = 13 + +const MODEL_ONESHOT_NAME_WIDTH = 14 +const MODEL_ONESHOT_RATE_COL = 7 +const MODEL_ONESHOT_SESS_COL = 6 +const MODEL_ONESHOT_COST_COL = 8 +const MODEL_ONESHOT_HIGH_THRESHOLD = 0.9 +const MODEL_ONESHOT_MID_THRESHOLD = 0.7 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) + const rows = computeOutlierSessions(projects) - if (top.length === 0) { - return No sessions + if (rows.length === 0) { + return No sessions } - const maxCost = top[0].totalCostUSD - const nw = Math.max(8, pw - bw - TOP_SESSIONS_COST_COL - TOP_SESSIONS_CALLS_COL - 1 - PANEL_CHROME) + const maxCost = rows[0].totalCostUSD + const nw = Math.max(8, pw - bw - TOP_SESSIONS_COST_COL - TOP_SESSIONS_ACT_COL - 2 - PANEL_CHROME) return ( - - {''.padEnd(bw + 1 + nw)}{'cost'.padStart(TOP_SESSIONS_COST_COL)}{'calls'.padStart(TOP_SESSIONS_CALLS_COL)} - {top.map((session, i) => { - const date = session.firstTimestamp - ? session.firstTimestamp.slice(0, TOP_SESSIONS_DATE_LEN) - : '----------' - const label = `${date} ${shortProject(session.projectName)}` + + {''.padEnd(bw + 1 + nw)}{'cost'.padStart(TOP_SESSIONS_COST_COL)}{' activity'.padEnd(TOP_SESSIONS_ACT_COL + 1)} + {rows.map((row, i) => { + const label = `${row.date} ${shortProject(row.project)}` return ( - - + + {fit(label, nw - 1)} - {formatCost(session.totalCostUSD).padStart(TOP_SESSIONS_COST_COL)} - {String(session.apiCalls).padStart(TOP_SESSIONS_CALLS_COL)} + {formatCost(row.totalCostUSD).padStart(TOP_SESSIONS_COST_COL)} + {fit(row.dominantActivity, TOP_SESSIONS_ACT_COL)} + + ) + })} + red cost = outlier ({'>'}{OUTLIER_MULTIPLIER}x project avg) + + ) +} + +function ModelOneShotBreakdown({ projects, pw, bw }: { projects: ProjectSummary[]; pw: number; bw: number }) { + const rows = computeModelOneShotRates(projects) + + if (rows.length === 0) { + return No model data + } + + const maxCost = Math.max(...rows.map(r => r.costUSD)) + + return ( + + {''.padEnd(bw + 1 + MODEL_ONESHOT_NAME_WIDTH)}{'cost'.padStart(MODEL_ONESHOT_COST_COL)}{'sess'.padStart(MODEL_ONESHOT_SESS_COL)}{'1-shot'.padStart(MODEL_ONESHOT_RATE_COL)} + {rows.map((row, i) => { + const rateLabel = row.oneShotRate !== null ? `${Math.round(row.oneShotRate * 100)}%` : '-' + const rateColor = row.oneShotRate === null + ? DIM + : row.oneShotRate >= MODEL_ONESHOT_HIGH_THRESHOLD + ? '#5BF58C' + : row.oneShotRate >= MODEL_ONESHOT_MID_THRESHOLD + ? ORANGE + : '#F55B5B' + return ( + + + {fit(row.model, MODEL_ONESHOT_NAME_WIDTH)} + {formatCost(row.costUSD).padStart(MODEL_ONESHOT_COST_COL)} + {String(row.sessions).padStart(MODEL_ONESHOT_SESS_COL)} + {rateLabel.padStart(MODEL_ONESHOT_RATE_COL)} ) })} @@ -565,6 +602,7 @@ function DashboardContent({ projects, period, columns, activeProvider, budgets } + {isCursor ? ( ) : ( diff --git a/tests/analytics.test.ts b/tests/analytics.test.ts new file mode 100644 index 0000000..4893b7c --- /dev/null +++ b/tests/analytics.test.ts @@ -0,0 +1,169 @@ +import { describe, it, expect } from 'vitest' +import { + computeOutlierSessions, + computeModelOneShotRates, + dominantActivity, + TOP_OUTLIER_COUNT, + OUTLIER_MULTIPLIER, +} from '../src/analytics.js' +import type { ClassifiedTurn, ParsedApiCall, ProjectSummary, SessionSummary, TokenUsage } from '../src/types.js' + +const EMPTY_CATS: SessionSummary['categoryBreakdown'] = { + 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 }, +} + +function makeSession(id: string, cost: number, firstTs = '2026-04-10T10:00:00Z'): SessionSummary { + return { + sessionId: id, project: 'p', firstTimestamp: firstTs, lastTimestamp: firstTs, + totalCostUSD: cost, totalInputTokens: 0, totalOutputTokens: 0, + totalCacheReadTokens: 0, totalCacheWriteTokens: 0, apiCalls: 1, + turns: [], modelBreakdown: {}, toolBreakdown: {}, mcpBreakdown: {}, + bashBreakdown: {}, categoryBreakdown: structuredClone(EMPTY_CATS), + } +} + +function makeProject(name: string, sessions: SessionSummary[]): ProjectSummary { + const totalCostUSD = sessions.reduce((s, x) => s + x.totalCostUSD, 0) + return { project: name, projectPath: name, sessions, totalCostUSD, totalApiCalls: sessions.length } +} + +describe('dominantActivity', () => { + it('returns label of highest-cost category', () => { + const s = makeSession('s1', 10) + s.categoryBreakdown.coding.costUSD = 5 + s.categoryBreakdown.debugging.costUSD = 8 + expect(dominantActivity(s)).toBe('Debugging') + }) + + it('returns General for empty categoryBreakdown costs', () => { + const s = makeSession('s1', 0) + expect(dominantActivity(s)).toBeTypeOf('string') + }) +}) + +describe('computeOutlierSessions', () => { + it('returns at most TOP_OUTLIER_COUNT', () => { + const sessions = Array.from({ length: 8 }, (_, i) => makeSession(`s${i}`, i + 1)) + const rows = computeOutlierSessions([makeProject('p', sessions)]) + expect(rows.length).toBe(TOP_OUTLIER_COUNT) + }) + + it('sorts by cost descending', () => { + const sessions = [makeSession('a', 3), makeSession('b', 10), makeSession('c', 5)] + const rows = computeOutlierSessions([makeProject('p', sessions)]) + expect(rows.map(r => r.sessionId)).toEqual(['b', 'c', 'a']) + }) + + it('flags isOutlier when cost > OUTLIER_MULTIPLIER x project avg', () => { + const sessions = [ + makeSession('big', 100), + makeSession('s1', 10), + makeSession('s2', 10), + makeSession('s3', 10), + ] + const rows = computeOutlierSessions([makeProject('p', sessions)]) + const big = rows.find(r => r.sessionId === 'big')! + expect(big.isOutlier).toBe(true) + const s1 = rows.find(r => r.sessionId === 's1')! + expect(s1.isOutlier).toBe(false) + expect(OUTLIER_MULTIPLIER).toBe(2) + }) + + it('isOutlier is false for a single-session project (no variance)', () => { + const rows = computeOutlierSessions([makeProject('p', [makeSession('only', 5)])]) + expect(rows[0].isOutlier).toBe(false) + }) + + it('returns empty array for empty projects', () => { + expect(computeOutlierSessions([])).toEqual([]) + }) + + it('includes YYYY-MM-DD date from firstTimestamp', () => { + const s = makeSession('s', 1, '2026-04-10T15:30:00Z') + const rows = computeOutlierSessions([makeProject('p', [s])]) + expect(rows[0].date).toBe('2026-04-10') + }) +}) + +function makeTokenUsage(): TokenUsage { + return { + inputTokens: 0, + outputTokens: 0, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + cachedInputTokens: 0, + reasoningTokens: 0, + webSearchRequests: 0, + } +} + +function makeApiCall(model: string): ParsedApiCall { + return { + provider: 'claude', + model, + usage: makeTokenUsage(), + costUSD: 0, + tools: [], + mcpTools: [], + hasAgentSpawn: false, + hasPlanMode: false, + speed: 'standard', + timestamp: '2026-04-10T10:00:00Z', + bashCommands: [], + deduplicationKey: `${model}-0`, + } +} + +function makeTurn(model: string, hasEdits: boolean, retries: number): ClassifiedTurn { + return { + userMessage: '', + assistantCalls: [makeApiCall(model)], + timestamp: '2026-04-10T10:00:00Z', + sessionId: 's1', + category: 'coding', + retries, + hasEdits, + } +} + +describe('computeModelOneShotRates', () => { + it('returns one-shot rate per model', () => { + const s = makeSession('s1', 10) + s.modelBreakdown = { 'Sonnet 4.5': { calls: 3, costUSD: 10, tokens: makeTokenUsage() } } + s.turns = [ + makeTurn('claude-sonnet-4-5', true, 0), + makeTurn('claude-sonnet-4-5', true, 1), + makeTurn('claude-sonnet-4-5', true, 0), + ] + const rows = computeModelOneShotRates([makeProject('p', [s])]) + const sonnet = rows.find(r => r.model === 'Sonnet 4.5') + expect(sonnet).toBeDefined() + expect(sonnet!.oneShotTurns).toBe(2) + expect(sonnet!.editTurns).toBe(3) + expect(sonnet!.oneShotRate).toBeCloseTo(2 / 3) + }) + + it('null oneShotRate when no edit turns', () => { + const s = makeSession('s1', 5) + s.modelBreakdown = { 'Haiku': { calls: 1, costUSD: 5, tokens: makeTokenUsage() } } + s.turns = [] + const rows = computeModelOneShotRates([makeProject('p', [s])]) + expect(rows[0]?.oneShotRate).toBeNull() + }) + + it('returns empty for empty projects', () => { + expect(computeModelOneShotRates([])).toEqual([]) + }) +})