Conversation text is still only persisted when explicitly saved (⌘S), but tokens/cost/model/provider are now logged to a new usage_events table for every completed AI response regardless — the Analytics view now reads from this table instead of messages, so it reflects real usage even for conversations that were never saved. Adds a By Provider chart mode alongside Over Time/By Model. Also fixes the Analytics entry point: ToolbarItem(placement: .navigation) silently doesn't render in a plain .sheet-presented NavigationStack on macOS. Moved the button inline next to the segmented picker, matching the codebase's existing convention (e.g. the model-favorites star filter).
419 lines
13 KiB
Swift
419 lines
13 KiB
Swift
//
|
|
// 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
|
|
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for
|
|
// the full license text. For commercial licensing, contact Rune
|
|
// Olsen via <https://confab.no>.
|
|
|
|
|
|
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()
|
|
}
|