Files
oai-swift/oAI/Views/Screens/UsageAnalyticsView.swift
T
rune d460230158 Add Usage Analytics view: tokens/questions/cost over time and by model
New large modal off the Stats sheet with a 6-way timeframe picker
(Today/7 Days/Week/Month/Year/Total), three tappable summary tiles,
and native Swift Charts (bar chart over time, pie chart by model).
Backed by date-range-filtered DatabaseService queries plus a new
daily-bucketed query — no schema changes needed since messages
already carry timestamp/tokens/cost/role.
2026-08-12 13:05:41 +02:00

337 lines
10 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
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 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
}
}
.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)
}
.pickerStyle(.segmented)
.labelsHidden()
.frame(maxWidth: 280)
}
@ViewBuilder
private var chartSection: some View {
switch chartMode {
case .overTime:
timeSeriesChart
case .byModel:
byModelChart
}
}
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 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: - 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
}
}
// MARK: - Data Loading
private func loadStats() {
let range = timeframe.dateRange()
overallStats = (try? DatabaseService.shared.getOverallUsageStats(from: range.start, to: range.end)) ?? UsageStats()
modelStats = (try? DatabaseService.shared.getUsageByModel(from: range.start, to: range.end)) ?? []
let dailyFrom = range.start ?? overallStats.firstMessageDate ?? range.end
dailyStats = (try? DatabaseService.shared.getDailyUsage(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()
}