// // StatsView.swift // Confab // // Session statistics screen // // SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 // Copyright (C) 2026 Rune Olsen // // This file is part of Confab. // // Confab is licensed under the PolyForm Noncommercial License 1.0.0. // You may use, study, modify, and share it for any noncommercial // purpose. Commercial use โ€” including selling Confab or any part of // it, standalone or bundled into another product or service โ€” // requires a separate commercial license from the copyright holder. // // See the LICENSE file or // for // the full license text. For commercial licensing, contact Rune // Olsen via . 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 { VStack(spacing: 0) { Picker("", selection: $selectedTab) { Text("Session").tag(StatsTab.session) Text("All-Time").tag(StatsTab.allTime) } .pickerStyle(.segmented) .labelsHidden() .padding(.horizontal, 16) .padding(.top, 12) .padding(.bottom, 4) Group { switch selectedTab { case .session: sessionList case .allTime: allTimeList } } } .navigationTitle("Statistics") .toolbar { ToolbarItem(placement: .confirmationAction) { Button("Done") { dismiss() } } } .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()) ?? [] } } struct StatRow: View { let label: LocalizedStringKey let value: String var body: some View { HStack { Text(label) .font(.body) Spacer() Text(value) .font(.body.monospacedDigit()) .foregroundColor(.secondary) } } } struct CapabilityBadge: View { let icon: String let label: LocalizedStringKey var body: some View { HStack(spacing: 2) { Text(icon) Text(label) } .font(.caption2) .padding(.horizontal, 6) .padding(.vertical, 3) .background(Color.blue.opacity(0.1)) .cornerRadius(4) } } #Preview { StatsView( stats: SessionStats( totalInputTokens: 1250, totalOutputTokens: 3420, totalCost: 0.0152, messageCount: 12 ), model: ModelInfo.mockModels.first, provider: .openrouter ) }