Add all-time usage statistics tab

Extends the existing Stats sheet (⌘⇧S) with a Session/All-Time segmented
picker. All-Time aggregates tokens, cost, and message counts across every
saved conversation, broken down by model and by conversation, using the
modelId already stored per message — no schema change needed.
This commit is contained in:
2026-07-28 12:35:45 +02:00
parent 71e8a49d77
commit 0642f3746f
4 changed files with 489 additions and 71 deletions
+183 -71
View File
@@ -23,87 +23,44 @@
import SwiftUI
private enum StatsTab: String, CaseIterable {
case session = "Session"
case allTime = "All-Time"
}
struct StatsView: View {
let stats: SessionStats
let model: ModelInfo?
let provider: Settings.Provider
@Environment(\.dismiss) var dismiss
@State private var selectedTab: StatsTab = .session
@State private var overallStats = UsageStats()
@State private var modelStats: [ModelUsageStat] = []
@State private var conversationStats: [ConversationUsageStat] = []
var body: some View {
NavigationStack {
List {
Section("Session Info") {
StatRow(label: "Provider", value: provider.displayName)
StatRow(label: "Model", value: model?.name ?? "None selected")
StatRow(label: "Messages", value: "\(stats.messageCount)")
VStack(spacing: 0) {
Picker("", selection: $selectedTab) {
Text("Session").tag(StatsTab.session)
Text("All-Time").tag(StatsTab.allTime)
}
Section("Token Usage") {
StatRow(label: "Input Tokens", value: stats.totalInputTokens.formatted())
StatRow(label: "Output Tokens", value: stats.totalOutputTokens.formatted())
StatRow(label: "Total Tokens", value: stats.totalTokens.formatted())
if stats.totalTokens > 0 {
HStack {
Text("Token Distribution")
.font(.caption)
.foregroundColor(.secondary)
Spacer()
GeometryReader { geo in
HStack(spacing: 0) {
Rectangle()
.fill(Color.blue)
.frame(width: geo.size.width * CGFloat(stats.totalInputTokens) / CGFloat(stats.totalTokens))
Rectangle()
.fill(Color.green)
.frame(width: geo.size.width * CGFloat(stats.totalOutputTokens) / CGFloat(stats.totalTokens))
}
}
.frame(height: 20)
.cornerRadius(4)
}
}
}
Section("Costs") {
StatRow(label: "Total Cost", value: stats.totalCostDisplay)
if stats.messageCount > 0 {
StatRow(label: "Avg per Message", value: stats.averageCostDisplay)
}
}
if let model = model {
Section("Model Details") {
StatRow(label: "Context Length", value: model.contextLengthDisplay)
StatRow(label: "Prompt Price", value: model.promptPriceDisplay + "/1M tokens")
StatRow(label: "Completion Price", value: model.completionPriceDisplay + "/1M tokens")
HStack {
Text("Capabilities")
.font(.caption)
.foregroundColor(.secondary)
Spacer()
HStack(spacing: 8) {
if model.capabilities.vision {
CapabilityBadge(icon: "👁️", label: "Vision")
}
if model.capabilities.tools {
CapabilityBadge(icon: "🔧", label: "Tools")
}
if model.capabilities.online {
CapabilityBadge(icon: "🌐", label: "Online")
}
}
}
.pickerStyle(.segmented)
.labelsHidden()
.padding(.horizontal, 16)
.padding(.top, 12)
.padding(.bottom, 4)
Group {
switch selectedTab {
case .session:
sessionList
case .allTime:
allTimeList
}
}
}
#if os(iOS)
.listStyle(.insetGrouped)
#else
.listStyle(.sidebar)
#endif
.navigationTitle("Statistics")
.toolbar {
ToolbarItem(placement: .confirmationAction) {
@@ -112,8 +69,163 @@ struct StatsView: View {
}
}
}
.frame(minWidth: 500, idealWidth: 550, minHeight: 450, idealHeight: 500)
.frame(minWidth: 500, idealWidth: 550, minHeight: 450, idealHeight: 500)
}
.task {
loadAllTimeStats()
}
}
private var sessionList: some View {
List {
Section("Session Info") {
StatRow(label: "Provider", value: provider.displayName)
StatRow(label: "Model", value: model?.name ?? "None selected")
StatRow(label: "Messages", value: "\(stats.messageCount)")
}
Section("Token Usage") {
StatRow(label: "Input Tokens", value: stats.totalInputTokens.formatted())
StatRow(label: "Output Tokens", value: stats.totalOutputTokens.formatted())
StatRow(label: "Total Tokens", value: stats.totalTokens.formatted())
if stats.totalTokens > 0 {
HStack {
Text("Token Distribution")
.font(.caption)
.foregroundColor(.secondary)
Spacer()
GeometryReader { geo in
HStack(spacing: 0) {
Rectangle()
.fill(Color.blue)
.frame(width: geo.size.width * CGFloat(stats.totalInputTokens) / CGFloat(stats.totalTokens))
Rectangle()
.fill(Color.green)
.frame(width: geo.size.width * CGFloat(stats.totalOutputTokens) / CGFloat(stats.totalTokens))
}
}
.frame(height: 20)
.cornerRadius(4)
}
}
}
Section("Costs") {
StatRow(label: "Total Cost", value: stats.totalCostDisplay)
if stats.messageCount > 0 {
StatRow(label: "Avg per Message", value: stats.averageCostDisplay)
}
}
if let model = model {
Section("Model Details") {
StatRow(label: "Context Length", value: model.contextLengthDisplay)
StatRow(label: "Prompt Price", value: model.promptPriceDisplay + "/1M tokens")
StatRow(label: "Completion Price", value: model.completionPriceDisplay + "/1M tokens")
HStack {
Text("Capabilities")
.font(.caption)
.foregroundColor(.secondary)
Spacer()
HStack(spacing: 8) {
if model.capabilities.vision {
CapabilityBadge(icon: "👁️", label: "Vision")
}
if model.capabilities.tools {
CapabilityBadge(icon: "🔧", label: "Tools")
}
if model.capabilities.online {
CapabilityBadge(icon: "🌐", label: "Online")
}
}
}
}
}
}
#if os(iOS)
.listStyle(.insetGrouped)
#else
.listStyle(.sidebar)
#endif
}
private var allTimeList: some View {
List {
Section("Totals") {
StatRow(label: "Total Messages", value: "\(overallStats.totalMessages)")
StatRow(label: "Total Tokens", value: overallStats.totalTokensDisplay)
StatRow(label: "Total Cost", value: overallStats.totalCostDisplay)
if let first = overallStats.firstMessageDate {
StatRow(label: "Since", value: first.formatted(date: .abbreviated, time: .omitted))
}
}
if !modelStats.isEmpty {
Section("By Model") {
ForEach(modelStats) { stat in
VStack(alignment: .leading, spacing: 4) {
HStack {
Text(stat.modelId)
.font(.body)
.lineLimit(1)
Spacer()
Text(stat.totalCostDisplay)
.font(.body.monospacedDigit())
.foregroundColor(.secondary)
}
HStack {
Text("^[\(stat.messageCount) message](inflect: true) · \(stat.totalTokensDisplay) tokens")
.font(.caption)
.foregroundColor(.secondary)
}
}
.padding(.vertical, 2)
}
}
}
if !conversationStats.isEmpty {
Section("Top Conversations") {
ForEach(conversationStats) { stat in
VStack(alignment: .leading, spacing: 4) {
HStack {
Text(stat.name)
.font(.body)
.lineLimit(1)
Spacer()
Text(stat.totalCostDisplay)
.font(.body.monospacedDigit())
.foregroundColor(.secondary)
}
Text("^[\(stat.messageCount) message](inflect: true) · \(stat.totalTokensDisplay) tokens")
.font(.caption)
.foregroundColor(.secondary)
}
.padding(.vertical, 2)
}
}
}
if overallStats.totalMessages == 0 {
Section {
Text("No usage data yet")
.foregroundColor(.secondary)
}
}
}
#if os(iOS)
.listStyle(.insetGrouped)
#else
.listStyle(.sidebar)
#endif
}
private func loadAllTimeStats() {
overallStats = (try? DatabaseService.shared.getOverallUsageStats()) ?? UsageStats()
modelStats = (try? DatabaseService.shared.getUsageByModel()) ?? []
conversationStats = (try? DatabaseService.shared.getUsageByConversation()) ?? []
}
}