Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions internal/stats/aggregator.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ func Aggregate(records []SessionRecord, from, to time.Time) []DailyStat {
Date: date,
ByProject: make(map[string]float64),
ByModel: make(map[string]float64),
ByProfile: make(map[string]float64),
}
dailyMap[date] = ds
}
Expand All @@ -36,6 +37,11 @@ func Aggregate(records []SessionRecord, from, to time.Time) []DailyStat {
ds.OutputTokens += r.OutputTokens
ds.ByProject[r.Project] += r.CostUSD
ds.ByModel[r.Model] += r.CostUSD
profile := r.Profile
if profile == "" {
profile = "unknown"
}
ds.ByProfile[profile] += r.CostUSD
}

// Convert map to sorted slice
Expand Down Expand Up @@ -154,6 +160,28 @@ func ModelBreakdown(stats []DailyStat) []ModelCost {
return result
}

// ProfileBreakdown aggregates costs by API profile across all daily stats.
// Returns a slice of ProfileCost sorted by cost descending.
func ProfileBreakdown(stats []DailyStat) []ProfileCost {
totals := make(map[string]float64)
for _, s := range stats {
for profile, cost := range s.ByProfile {
totals[profile] += cost
}
}

result := make([]ProfileCost, 0, len(totals))
for profile, cost := range totals {
result = append(result, ProfileCost{Profile: profile, Cost: cost})
}

sort.Slice(result, func(i, j int) bool {
return result[i].Cost > result[j].Cost
})

return result
}

// GenerateSummary creates a comprehensive summary from session records.
func GenerateSummary(records []SessionRecord, from, to time.Time) Summary {
dailyStats := Aggregate(records, from, to)
Expand Down Expand Up @@ -183,6 +211,7 @@ func GenerateSummary(records []SessionRecord, from, to time.Time) Summary {
CacheRead: cacheRead,
TopProjects: ProjectBreakdown(dailyStats),
TopModels: ModelBreakdown(dailyStats),
TopProfiles: ProfileBreakdown(dailyStats),
DailyBreakdown: dailyStats,
}
}
11 changes: 9 additions & 2 deletions internal/stats/scanner.go
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,12 @@ func ScanSessions(opts ScanOptions) ([]SessionRecord, error) {
projects = make(map[string]config.ProjectEntry)
}

// Determine current default profile name
currentProfile := "unknown"
if cfg, err := config.LoadConfig(); err == nil && cfg.Default != "" {
currentProfile = cfg.Default
}

var records []SessionRecord

for _, entry := range entries {
Expand Down Expand Up @@ -126,7 +132,7 @@ func ScanSessions(opts ScanOptions) ([]SessionRecord, error) {
sessionID := strings.TrimSuffix(sf.Name(), ".jsonl")
filePath := filepath.Join(dirPath, sf.Name())

record, err := parseSessionFile(filePath, sessionID, projectAlias, projectPath)
record, err := parseSessionFile(filePath, sessionID, projectAlias, projectPath, currentProfile)
if err != nil {
continue // skip unparseable files
}
Expand All @@ -140,7 +146,7 @@ func ScanSessions(opts ScanOptions) ([]SessionRecord, error) {
}

// parseSessionFile reads a single JSONL session file and extracts a SessionRecord.
func parseSessionFile(path, sessionID, project, projectPath string) (*SessionRecord, error) {
func parseSessionFile(path, sessionID, project, projectPath, profile string) (*SessionRecord, error) {
f, err := os.Open(path)
if err != nil {
return nil, fmt.Errorf("open session file: %w", err)
Expand All @@ -151,6 +157,7 @@ func parseSessionFile(path, sessionID, project, projectPath string) (*SessionRec
SessionID: sessionID,
Project: project,
ProjectPath: projectPath,
Profile: profile,
}

var (
Expand Down
9 changes: 9 additions & 0 deletions internal/stats/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ type SessionRecord struct {
SessionID string `json:"sessionId"`
Project string `json:"project"` // codes project alias (e.g. "codes"), falls back to path
ProjectPath string `json:"projectPath"` // full filesystem path
Profile string `json:"profile"` // API profile name (config.Default at scan time), falls back to "unknown"
Model string `json:"model"`
StartTime time.Time `json:"startTime"`
EndTime time.Time `json:"endTime"`
Expand All @@ -28,6 +29,7 @@ type DailyStat struct {
OutputTokens int64 `json:"outputTokens"`
ByProject map[string]float64 `json:"byProject"` // project alias -> cost
ByModel map[string]float64 `json:"byModel"` // model name -> cost
ByProfile map[string]float64 `json:"byProfile"` // API profile name -> cost
}

// StatsCache is the on-disk cache of all scanned session data.
Expand Down Expand Up @@ -55,6 +57,7 @@ type Summary struct {
CacheRead int64 `json:"cacheReadTokens"`
TopProjects []ProjectCost `json:"topProjects"`
TopModels []ModelCost `json:"topModels"`
TopProfiles []ProfileCost `json:"topProfiles"`
DailyBreakdown []DailyStat `json:"dailyBreakdown"`
}

Expand All @@ -69,3 +72,9 @@ type ModelCost struct {
Model string `json:"model"`
Cost float64 `json:"cost"`
}

// ProfileCost represents cost aggregation for a single API profile.
type ProfileCost struct {
Profile string `json:"profile"`
Cost float64 `json:"cost"`
}
25 changes: 24 additions & 1 deletion internal/tui/stats_view.go
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,8 @@ func (m Model) updateStats(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
case "project":
m.statsBreakdown = "model"
case "model":
m.statsBreakdown = "profile"
case "profile":
m.statsBreakdown = "both"
}
return m, nil
Expand Down Expand Up @@ -186,6 +188,17 @@ func renderStatsView(daily []stats.DailyStat, records []stats.SessionRecord, tim
}
}

// By Profile breakdown
if breakdown == "both" || breakdown == "profile" {
profileCosts := aggregateByProfile(daily)
if len(profileCosts) > 0 {
b.WriteString(statsHeaderStyle.Render(" By Profile:"))
b.WriteString("\n")
renderBreakdown(&b, profileCosts, totalCost, barWidth, 8)
b.WriteString("\n")
}
}

// Daily trend chart
if len(daily) > 1 {
b.WriteString(statsHeaderStyle.Render(" Daily Trend:"))
Expand All @@ -195,7 +208,7 @@ func renderStatsView(daily []stats.DailyStat, records []stats.SessionRecord, tim

// Help footer
b.WriteString("\n")
breakdownLabel := map[string]string{"both": "all", "project": "project", "model": "model"}[breakdown]
breakdownLabel := map[string]string{"both": "all", "project": "project", "model": "model", "profile": "profile"}[breakdown]
help := fmt.Sprintf(" w:week m:month a:all f:group(%s) r:refresh", breakdownLabel)
b.WriteString(statsDimStyle.Render(help))

Expand Down Expand Up @@ -228,6 +241,16 @@ func aggregateByModel(daily []stats.DailyStat) []costEntry {
return sortedEntries(totals)
}

func aggregateByProfile(daily []stats.DailyStat) []costEntry {
totals := make(map[string]float64)
for _, d := range daily {
for profile, cost := range d.ByProfile {
totals[profile] += cost
}
}
return sortedEntries(totals)
}

func sortedEntries(m map[string]float64) []costEntry {
entries := make([]costEntry, 0, len(m))
for name, cost := range m {
Expand Down