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.
337 lines
10 KiB
Swift
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()
|
|
}
|