// // UsageAnalyticsView.swift // Confab // // Usage analytics: tokens / questions / money, over time and by model // // 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 import Charts private enum AnalyticsMetric: String, CaseIterable, Identifiable { case tokens case questions case cost var id: String { rawValue } var icon: String { switch self { case .tokens: return "number" case .questions: return "bubble.left.and.bubble.right" case .cost: return "dollarsign.circle" } } var color: Color { switch self { case .tokens: return .confabAccent case .questions: return .confabSuccess case .cost: return .confabWarning } } } private enum AnalyticsChartMode: String, CaseIterable, Identifiable { case overTime case byModel case byProvider var id: String { rawValue } } struct UsageAnalyticsView: View { @Environment(\.dismiss) var dismiss @State private var timeframe: AnalyticsTimeframe = .last7Days @State private var metric: AnalyticsMetric = .tokens @State private var chartMode: AnalyticsChartMode = .overTime @State private var overallStats = UsageStats() @State private var modelStats: [ModelUsageStat] = [] @State private var providerStats: [ProviderUsageStat] = [] @State private var dailyStats: [DailyUsageStat] = [] var body: some View { NavigationStack { VStack(spacing: 0) { timeframePicker Divider() ScrollView { VStack(alignment: .leading, spacing: 20) { summaryTiles VStack(alignment: .leading, spacing: 12) { chartModePicker chartSection } if chartMode == .byModel && !modelStats.isEmpty { modelBreakdownList } if chartMode == .byProvider && !providerStats.isEmpty { providerBreakdownList } } .padding(20) } } .navigationTitle("Analytics") .toolbar { ToolbarItem(placement: .confirmationAction) { Button("Done") { dismiss() } } } .frame(minWidth: 700, idealWidth: 780, minHeight: 620, idealHeight: 680) } .task(id: timeframe) { loadStats() } } // MARK: - Timeframe private var timeframePicker: some View { Picker("", selection: $timeframe) { Text("Today").tag(AnalyticsTimeframe.today) Text("7 Days").tag(AnalyticsTimeframe.last7Days) Text("Week").tag(AnalyticsTimeframe.week) Text("Month").tag(AnalyticsTimeframe.month) Text("Year").tag(AnalyticsTimeframe.year) Text("Total").tag(AnalyticsTimeframe.total) } .pickerStyle(.segmented) .labelsHidden() .padding(.horizontal, 20) .padding(.top, 12) .padding(.bottom, 12) } // MARK: - Summary Tiles private var summaryTiles: some View { HStack(spacing: 12) { MetricTile( title: "Tokens", value: overallStats.totalTokensDisplay, icon: AnalyticsMetric.tokens.icon, color: AnalyticsMetric.tokens.color, isSelected: metric == .tokens ) { metric = .tokens } MetricTile( title: "Questions", value: "\(overallStats.totalQuestions)", icon: AnalyticsMetric.questions.icon, color: AnalyticsMetric.questions.color, isSelected: metric == .questions ) { metric = .questions } MetricTile( title: "Money Used", value: overallStats.totalCostDisplay, icon: AnalyticsMetric.cost.icon, color: AnalyticsMetric.cost.color, isSelected: metric == .cost ) { metric = .cost } } } // MARK: - Chart private var chartModePicker: some View { Picker("", selection: $chartMode) { Text("Over Time").tag(AnalyticsChartMode.overTime) Text("By Model").tag(AnalyticsChartMode.byModel) Text("By Provider").tag(AnalyticsChartMode.byProvider) } .pickerStyle(.segmented) .labelsHidden() .frame(maxWidth: 400) } @ViewBuilder private var chartSection: some View { switch chartMode { case .overTime: timeSeriesChart case .byModel: byModelChart case .byProvider: byProviderChart } } private var timeSeriesChart: some View { Group { if dailyStats.isEmpty { emptyChartPlaceholder } else { Chart(dailyStats) { stat in BarMark( x: .value("Day", stat.day, unit: .day), y: .value("Value", dailyValue(for: stat)) ) .foregroundStyle(metric.color) } .frame(height: 220) } } } private var byModelChart: some View { Group { if modelStats.isEmpty { emptyChartPlaceholder } else { Chart(modelStats) { stat in SectorMark( angle: .value("Value", modelValue(for: stat)), innerRadius: .ratio(0.55), angularInset: 1.5 ) .foregroundStyle(by: .value("Model", stat.modelId)) .cornerRadius(4) } .frame(height: 240) .chartLegend(position: .bottom, alignment: .center, spacing: 8) } } } private var byProviderChart: some View { Group { if providerStats.isEmpty { emptyChartPlaceholder } else { Chart(providerStats) { stat in SectorMark( angle: .value("Value", providerValue(for: stat)), innerRadius: .ratio(0.55), angularInset: 1.5 ) .foregroundStyle(by: .value("Provider", providerDisplayName(stat.provider))) .cornerRadius(4) } .frame(height: 240) .chartLegend(position: .bottom, alignment: .center, spacing: 8) } } } private var emptyChartPlaceholder: some View { VStack(spacing: 8) { Image(systemName: "chart.bar.xaxis") .font(.system(size: 32)) .foregroundColor(.secondary) Text("No usage data for this timeframe") .font(.callout) .foregroundColor(.secondary) } .frame(maxWidth: .infinity) .frame(height: 220) } // MARK: - By Model List private var modelBreakdownList: some View { VStack(alignment: .leading, spacing: 4) { Text("By Model") .font(.headline) .padding(.bottom, 4) ForEach(modelStats) { stat in HStack { Text(stat.modelId) .font(.body) .lineLimit(1) Spacer() Text(modelValueDisplay(for: stat)) .font(.body.monospacedDigit()) .foregroundColor(.secondary) } .padding(.vertical, 4) if stat.id != modelStats.last?.id { Divider() } } } .padding(16) .background(Color.confabSurface.opacity(0.5)) .cornerRadius(10) } // MARK: - By Provider List private var providerBreakdownList: some View { VStack(alignment: .leading, spacing: 4) { Text("By Provider") .font(.headline) .padding(.bottom, 4) ForEach(providerStats) { stat in HStack { Text(providerDisplayName(stat.provider)) .font(.body) .lineLimit(1) Spacer() Text(providerValueDisplay(for: stat)) .font(.body.monospacedDigit()) .foregroundColor(.secondary) } .padding(.vertical, 4) if stat.id != providerStats.last?.id { Divider() } } } .padding(16) .background(Color.confabSurface.opacity(0.5)) .cornerRadius(10) } private func providerDisplayName(_ rawProvider: String) -> String { Settings.Provider(rawValue: rawProvider)?.displayName ?? rawProvider } // MARK: - Value helpers private func dailyValue(for stat: DailyUsageStat) -> Double { switch metric { case .tokens: return Double(stat.totalTokens) case .questions: return Double(stat.questionCount) case .cost: return stat.totalCost } } private func modelValue(for stat: ModelUsageStat) -> Double { switch metric { case .tokens: return Double(stat.totalTokens) case .questions: return Double(stat.questionCount) case .cost: return stat.totalCost } } private func modelValueDisplay(for stat: ModelUsageStat) -> String { switch metric { case .tokens: return stat.totalTokensDisplay case .questions: return "\(stat.questionCount)" case .cost: return stat.totalCostDisplay } } private func providerValue(for stat: ProviderUsageStat) -> Double { switch metric { case .tokens: return Double(stat.totalTokens) case .questions: return Double(stat.questionCount) case .cost: return stat.totalCost } } private func providerValueDisplay(for stat: ProviderUsageStat) -> String { switch metric { case .tokens: return stat.totalTokensDisplay case .questions: return "\(stat.questionCount)" case .cost: return stat.totalCostDisplay } } // MARK: - Data Loading /// Sourced from `usage_events`, not `messages` — reflects usage for every completed /// generation regardless of whether the conversation it belongs to was ever saved. private func loadStats() { let range = timeframe.dateRange() overallStats = (try? DatabaseService.shared.getUsageEventTotals(from: range.start, to: range.end)) ?? UsageStats() modelStats = (try? DatabaseService.shared.getUsageEventsByModel(from: range.start, to: range.end)) ?? [] providerStats = (try? DatabaseService.shared.getUsageEventsByProvider(from: range.start, to: range.end)) ?? [] let dailyFrom = range.start ?? overallStats.firstMessageDate ?? range.end dailyStats = (try? DatabaseService.shared.getDailyUsageEvents(from: dailyFrom, to: range.end)) ?? [] } } // MARK: - Metric Tile private struct MetricTile: View { let title: LocalizedStringKey let value: String let icon: String let color: Color let isSelected: Bool let action: () -> Void var body: some View { Button(action: action) { VStack(alignment: .leading, spacing: 8) { HStack { Image(systemName: icon) .foregroundColor(color) Spacer() } Text(value) .font(.title2.monospacedDigit()) .fontWeight(.bold) .foregroundColor(.primary) .lineLimit(1) .minimumScaleFactor(0.6) Text(title) .font(.caption) .foregroundColor(.secondary) } .padding(14) .frame(maxWidth: .infinity, alignment: .leading) .background(isSelected ? color.opacity(0.15) : Color.confabSurface.opacity(0.5)) .overlay( RoundedRectangle(cornerRadius: 10) .stroke(isSelected ? color : Color.clear, lineWidth: 1.5) ) .cornerRadius(10) } .buttonStyle(.plain) } } #Preview { UsageAnalyticsView() }