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.
This commit is contained in:
2026-08-12 13:05:41 +02:00
parent 1e0e81b9bc
commit d460230158
6 changed files with 987 additions and 7 deletions
+408
View File
@@ -1,6 +1,414 @@
{
"sourceLanguage" : "en",
"strings" : {
"Today" : {
"localizations" : {
"da" : {
"stringUnit" : {
"state" : "translated",
"value" : "I dag"
}
},
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Heute"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Aujourd'hui"
}
},
"nb" : {
"stringUnit" : {
"state" : "translated",
"value" : "I dag"
}
},
"sv" : {
"stringUnit" : {
"state" : "translated",
"value" : "Idag"
}
}
}
},
"7 Days" : {
"localizations" : {
"da" : {
"stringUnit" : {
"state" : "translated",
"value" : "7 dage"
}
},
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "7 Tage"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "7 jours"
}
},
"nb" : {
"stringUnit" : {
"state" : "translated",
"value" : "7 dager"
}
},
"sv" : {
"stringUnit" : {
"state" : "translated",
"value" : "7 dagar"
}
}
}
},
"Week" : {
"localizations" : {
"da" : {
"stringUnit" : {
"state" : "translated",
"value" : "Uge"
}
},
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Woche"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Semaine"
}
},
"nb" : {
"stringUnit" : {
"state" : "translated",
"value" : "Uke"
}
},
"sv" : {
"stringUnit" : {
"state" : "translated",
"value" : "Vecka"
}
}
}
},
"Month" : {
"localizations" : {
"da" : {
"stringUnit" : {
"state" : "translated",
"value" : "Måned"
}
},
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Monat"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Mois"
}
},
"nb" : {
"stringUnit" : {
"state" : "translated",
"value" : "Måned"
}
},
"sv" : {
"stringUnit" : {
"state" : "translated",
"value" : "Månad"
}
}
}
},
"Year" : {
"localizations" : {
"da" : {
"stringUnit" : {
"state" : "translated",
"value" : "År"
}
},
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Jahr"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Année"
}
},
"nb" : {
"stringUnit" : {
"state" : "translated",
"value" : "År"
}
},
"sv" : {
"stringUnit" : {
"state" : "translated",
"value" : "År"
}
}
}
},
"Questions" : {
"localizations" : {
"da" : {
"stringUnit" : {
"state" : "translated",
"value" : "Spørgsmål"
}
},
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Fragen"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Questions"
}
},
"nb" : {
"stringUnit" : {
"state" : "translated",
"value" : "Spørsmål"
}
},
"sv" : {
"stringUnit" : {
"state" : "translated",
"value" : "Frågor"
}
}
}
},
"Money Used" : {
"localizations" : {
"da" : {
"stringUnit" : {
"state" : "translated",
"value" : "Penge brugt"
}
},
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Ausgegebenes Geld"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Argent dépensé"
}
},
"nb" : {
"stringUnit" : {
"state" : "translated",
"value" : "Penger brukt"
}
},
"sv" : {
"stringUnit" : {
"state" : "translated",
"value" : "Pengar använda"
}
}
}
},
"Over Time" : {
"localizations" : {
"da" : {
"stringUnit" : {
"state" : "translated",
"value" : "Over tid"
}
},
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Im Zeitverlauf"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Dans le temps"
}
},
"nb" : {
"stringUnit" : {
"state" : "translated",
"value" : "Over tid"
}
},
"sv" : {
"stringUnit" : {
"state" : "translated",
"value" : "Över tid"
}
}
}
},
"By Model" : {
"localizations" : {
"da" : {
"stringUnit" : {
"state" : "translated",
"value" : "Efter model"
}
},
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Nach Modell"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Par modèle"
}
},
"nb" : {
"stringUnit" : {
"state" : "translated",
"value" : "Etter modell"
}
},
"sv" : {
"stringUnit" : {
"state" : "translated",
"value" : "Efter modell"
}
}
}
},
"No usage data for this timeframe" : {
"localizations" : {
"da" : {
"stringUnit" : {
"state" : "translated",
"value" : "Ingen brugsdata for denne periode"
}
},
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Keine Nutzungsdaten für diesen Zeitraum"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Aucune donnée d'utilisation pour cette période"
}
},
"nb" : {
"stringUnit" : {
"state" : "translated",
"value" : "Ingen bruksdata for denne perioden"
}
},
"sv" : {
"stringUnit" : {
"state" : "translated",
"value" : "Ingen användningsdata för denna period"
}
}
}
},
"Analytics" : {
"localizations" : {
"da" : {
"stringUnit" : {
"state" : "translated",
"value" : "Analyse"
}
},
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Analyse"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Analytique"
}
},
"nb" : {
"stringUnit" : {
"state" : "translated",
"value" : "Analyse"
}
},
"sv" : {
"stringUnit" : {
"state" : "translated",
"value" : "Analys"
}
}
}
},
"View detailed usage analytics" : {
"localizations" : {
"da" : {
"stringUnit" : {
"state" : "translated",
"value" : "Se detaljeret brugsanalyse"
}
},
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Detaillierte Nutzungsanalyse anzeigen"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Voir l'analyse d'utilisation détaillée"
}
},
"nb" : {
"stringUnit" : {
"state" : "translated",
"value" : "Vis detaljert bruksanalyse"
}
},
"sv" : {
"stringUnit" : {
"state" : "translated",
"value" : "Visa detaljerad användningsanalys"
}
}
}
},
"·" : {
"comment" : "A separator between the message count and the date.",
"isCommentAutoGenerated" : true,
+63
View File
@@ -25,6 +25,7 @@ import Foundation
struct UsageStats: Sendable {
var totalMessages: Int
var totalQuestions: Int
var totalTokens: Int
var totalCost: Double
var hasCostData: Bool
@@ -33,6 +34,7 @@ struct UsageStats: Sendable {
nonisolated init(
totalMessages: Int = 0,
totalQuestions: Int = 0,
totalTokens: Int = 0,
totalCost: Double = 0.0,
hasCostData: Bool = false,
@@ -40,6 +42,7 @@ struct UsageStats: Sendable {
lastMessageDate: Date? = nil
) {
self.totalMessages = totalMessages
self.totalQuestions = totalQuestions
self.totalTokens = totalTokens
self.totalCost = totalCost
self.hasCostData = hasCostData
@@ -66,6 +69,7 @@ struct ModelUsageStat: Identifiable, Sendable {
var id: String { modelId }
let modelId: String
var messageCount: Int
var questionCount: Int
var totalTokens: Int
var totalCost: Double
var hasCostData: Bool
@@ -74,6 +78,7 @@ struct ModelUsageStat: Identifiable, Sendable {
nonisolated init(
modelId: String,
messageCount: Int,
questionCount: Int = 0,
totalTokens: Int,
totalCost: Double,
hasCostData: Bool,
@@ -81,6 +86,7 @@ struct ModelUsageStat: Identifiable, Sendable {
) {
self.modelId = modelId
self.messageCount = messageCount
self.questionCount = questionCount
self.totalTokens = totalTokens
self.totalCost = totalCost
self.hasCostData = hasCostData
@@ -102,6 +108,33 @@ struct ModelUsageStat: Identifiable, Sendable {
}
}
/// One day's usage totals, for time-series charting in the Analytics view.
struct DailyUsageStat: Identifiable, Sendable {
var id: Date { day }
let day: Date
var messageCount: Int
var questionCount: Int
var totalTokens: Int
var totalCost: Double
var hasCostData: Bool
nonisolated init(
day: Date,
messageCount: Int,
questionCount: Int,
totalTokens: Int,
totalCost: Double,
hasCostData: Bool
) {
self.day = day
self.messageCount = messageCount
self.questionCount = questionCount
self.totalTokens = totalTokens
self.totalCost = totalCost
self.hasCostData = hasCostData
}
}
struct ConversationUsageStat: Identifiable, Sendable {
let conversationId: UUID
var id: UUID { conversationId }
@@ -141,3 +174,33 @@ struct ConversationUsageStat: Identifiable, Sendable {
hasCostData ? String(format: "$%.4f", totalCost) : "N/A"
}
}
/// Time window for the Analytics view. `.total` means all-time (no lower bound).
enum AnalyticsTimeframe: String, CaseIterable, Identifiable, Sendable {
case today
case last7Days
case week
case month
case year
case total
var id: String { rawValue }
/// Bounds for this timeframe. `start` is nil for `.total` (all time, no lower bound).
nonisolated func dateRange(now: Date = Date(), calendar: Calendar = .current) -> (start: Date?, end: Date) {
switch self {
case .today:
return (calendar.startOfDay(for: now), now)
case .last7Days:
return (calendar.date(byAdding: .day, value: -7, to: now) ?? now, now)
case .week:
return (calendar.dateInterval(of: .weekOfYear, for: now)?.start ?? now, now)
case .month:
return (calendar.dateInterval(of: .month, for: now)?.start ?? now, now)
case .year:
return (calendar.dateInterval(of: .year, for: now)?.start ?? now, now)
case .total:
return (nil, now)
}
}
}
+90 -7
View File
@@ -806,17 +806,44 @@ final class DatabaseService: Sendable {
// MARK: - Usage Statistics
nonisolated func getOverallUsageStats() throws -> UsageStats {
/// Builds a `WHERE`-ready timestamp range clause (empty if both bounds are nil) plus its bound arguments.
/// `baseClauses` are ANDed in ahead of the range (e.g. "modelId IS NOT NULL") so callers can share one WHERE.
private nonisolated static func timestampRangeClause(
from: Date?,
to: Date?,
baseClauses: [String] = []
) -> (whereSQL: String, arguments: [String]) {
var clauses = baseClauses
var arguments: [String] = []
if let from {
clauses.append("timestamp >= ?")
arguments.append(Self.isoString(from: from))
}
if let to {
clauses.append("timestamp <= ?")
arguments.append(Self.isoString(from: to))
}
guard !clauses.isEmpty else { return ("", []) }
return (" WHERE " + clauses.joined(separator: " AND "), arguments)
}
/// Overall usage totals. Pass `from`/`to` to restrict to a time window; omit both for all-time.
nonisolated func getOverallUsageStats(from: Date? = nil, to: Date? = nil) throws -> UsageStats {
try dbQueue.read { db in
guard let row = try Row.fetchOne(db, sql: """
let (whereSQL, arguments) = Self.timestampRangeClause(from: from, to: to)
let sql = """
SELECT COUNT(*) AS cnt,
COALESCE(SUM(CASE WHEN role = 'user' THEN 1 ELSE 0 END), 0) AS questions,
COALESCE(SUM(tokens), 0) AS tokens,
COALESCE(SUM(cost), 0) AS cost,
COUNT(cost) AS costCount,
MIN(timestamp) AS minTs,
MAX(timestamp) AS maxTs
FROM messages
""")
\(whereSQL)
"""
guard let row = try Row.fetchOne(db, sql: sql, arguments: StatementArguments(arguments))
else {
return UsageStats()
}
@@ -827,6 +854,7 @@ final class DatabaseService: Sendable {
return UsageStats(
totalMessages: row["cnt"],
totalQuestions: row["questions"],
totalTokens: row["tokens"],
totalCost: row["cost"],
hasCostData: costCount > 0,
@@ -836,19 +864,25 @@ final class DatabaseService: Sendable {
}
}
nonisolated func getUsageByModel() throws -> [ModelUsageStat] {
/// Usage grouped by model. Pass `from`/`to` to restrict to a time window; omit both for all-time.
nonisolated func getUsageByModel(from: Date? = nil, to: Date? = nil) throws -> [ModelUsageStat] {
try dbQueue.read { db in
let rows = try Row.fetchAll(db, sql: """
let (whereSQL, arguments) = Self.timestampRangeClause(
from: from, to: to, baseClauses: ["modelId IS NOT NULL"]
)
let sql = """
SELECT modelId,
COUNT(*) AS cnt,
COALESCE(SUM(CASE WHEN role = 'user' THEN 1 ELSE 0 END), 0) AS questions,
COALESCE(SUM(tokens), 0) AS tokens,
COALESCE(SUM(cost), 0) AS cost,
COUNT(cost) AS costCount,
MAX(timestamp) AS lastUsed
FROM messages
WHERE modelId IS NOT NULL
\(whereSQL)
GROUP BY modelId
""")
"""
let rows = try Row.fetchAll(db, sql: sql, arguments: StatementArguments(arguments))
let stats: [ModelUsageStat] = rows.compactMap { row in
guard let modelId: String = row["modelId"],
@@ -860,6 +894,7 @@ final class DatabaseService: Sendable {
return ModelUsageStat(
modelId: modelId,
messageCount: row["cnt"],
questionCount: row["questions"],
totalTokens: row["tokens"],
totalCost: row["cost"],
hasCostData: costCount > 0,
@@ -876,6 +911,54 @@ final class DatabaseService: Sendable {
}
}
/// Usage bucketed by calendar day (in the local timezone the timestamps were stored in GMT, see
/// `isoStyle`), for the Analytics view's time-series chart. `from`/`to` bound the range; days with
/// no messages are simply absent (the view fills gaps when rendering).
nonisolated func getDailyUsage(from: Date, to: Date) throws -> [DailyUsageStat] {
try dbQueue.read { db in
let sql = """
SELECT substr(timestamp, 1, 10) AS day,
COUNT(*) AS cnt,
COALESCE(SUM(CASE WHEN role = 'user' THEN 1 ELSE 0 END), 0) AS questions,
COALESCE(SUM(tokens), 0) AS tokens,
COALESCE(SUM(cost), 0) AS cost,
COUNT(cost) AS costCount
FROM messages
WHERE timestamp >= ? AND timestamp <= ?
GROUP BY day
ORDER BY day
"""
let rows = try Row.fetchAll(
db, sql: sql,
arguments: [Self.isoString(from: from), Self.isoString(from: to)]
)
var gmtCalendar = Calendar(identifier: .gregorian)
gmtCalendar.timeZone = TimeZone(identifier: "GMT")!
return rows.compactMap { row -> DailyUsageStat? in
guard let dayString: String = row["day"] else { return nil }
let parts = dayString.split(separator: "-").compactMap { Int($0) }
guard parts.count == 3 else { return nil }
var components = DateComponents()
components.year = parts[0]
components.month = parts[1]
components.day = parts[2]
guard let day = gmtCalendar.date(from: components) else { return nil }
let costCount: Int = row["costCount"]
return DailyUsageStat(
day: day,
messageCount: row["cnt"],
questionCount: row["questions"],
totalTokens: row["tokens"],
totalCost: row["cost"],
hasCostData: costCount > 0
)
}
}
}
nonisolated func getUsageByConversation(limit: Int = 20) throws -> [ConversationUsageStat] {
try dbQueue.read { db in
let rows = try Row.fetchAll(db, sql: """
+12
View File
@@ -38,6 +38,7 @@ struct StatsView: View {
@State private var overallStats = UsageStats()
@State private var modelStats: [ModelUsageStat] = []
@State private var conversationStats: [ConversationUsageStat] = []
@State private var showAnalytics = false
var body: some View {
NavigationStack {
@@ -63,6 +64,14 @@ struct StatsView: View {
}
.navigationTitle("Statistics")
.toolbar {
ToolbarItem(placement: .navigation) {
Button {
showAnalytics = true
} label: {
Label("Analytics", systemImage: "chart.bar.xaxis")
}
.help("View detailed usage analytics")
}
ToolbarItem(placement: .confirmationAction) {
Button("Done") {
dismiss()
@@ -74,6 +83,9 @@ struct StatsView: View {
.task {
loadAllTimeStats()
}
.sheet(isPresented: $showAnalytics) {
UsageAnalyticsView()
}
}
private var sessionList: some View {
+336
View File
@@ -0,0 +1,336 @@
//
// 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()
}
+78
View File
@@ -192,6 +192,84 @@ struct DatabaseServiceUsageStatsTests {
#expect(byModel.first?.modelId == "claude-sonnet")
}
@Test("Overall stats respect an optional date range and count user questions")
func overallStatsRespectsDateRangeAndQuestions() throws {
let db = DatabaseService.makeInMemory()
let now = Date()
let old = now.addingTimeInterval(-10 * 24 * 3600)
_ = try db.saveConversation(name: "Old Chat", messages: [
Message(role: .user, content: "old question", tokens: 10, timestamp: old),
Message(role: .assistant, content: "old answer", tokens: 20, cost: 0.01, timestamp: old),
])
_ = try db.saveConversation(name: "Recent Chat", messages: [
Message(role: .user, content: "recent question", tokens: 5, timestamp: now),
Message(role: .assistant, content: "recent answer", tokens: 15, cost: 0.02, timestamp: now),
])
let rangeStats = try db.getOverallUsageStats(
from: now.addingTimeInterval(-3600), to: now.addingTimeInterval(3600)
)
#expect(rangeStats.totalMessages == 2)
#expect(rangeStats.totalQuestions == 1)
#expect(rangeStats.totalTokens == 20)
let allStats = try db.getOverallUsageStats()
#expect(allStats.totalMessages == 4)
#expect(allStats.totalQuestions == 2)
}
@Test("Usage by model respects an optional date range and reports question counts")
func usageByModelRespectsDateRangeAndQuestions() throws {
let db = DatabaseService.makeInMemory()
let now = Date()
let old = now.addingTimeInterval(-10 * 24 * 3600)
_ = try db.saveConversation(name: "Old Chat", messages: [
Message(role: .user, content: "hi", tokens: 5, timestamp: old, modelId: "claude-sonnet"),
Message(role: .assistant, content: "hello", tokens: 10, cost: 0.01, timestamp: old, modelId: "claude-sonnet"),
])
_ = try db.saveConversation(name: "Recent Chat", messages: [
Message(role: .user, content: "hey", tokens: 5, timestamp: now, modelId: "gpt-4"),
Message(role: .assistant, content: "hi", tokens: 15, cost: 0.02, timestamp: now, modelId: "gpt-4"),
])
let recent = try db.getUsageByModel(
from: now.addingTimeInterval(-3600), to: now.addingTimeInterval(3600)
)
#expect(recent.count == 1)
#expect(recent.first?.modelId == "gpt-4")
#expect(recent.first?.questionCount == 1)
}
@Test("Daily usage buckets messages by calendar day (GMT, matching stored timestamps)")
func dailyUsageBucketsByDay() throws {
let db = DatabaseService.makeInMemory()
var gmtCalendar = Calendar(identifier: .gregorian)
gmtCalendar.timeZone = TimeZone(identifier: "GMT")!
let day1 = gmtCalendar.date(from: DateComponents(year: 2026, month: 8, day: 1, hour: 10))!
let day2 = gmtCalendar.date(from: DateComponents(year: 2026, month: 8, day: 2, hour: 10))!
_ = try db.saveConversation(name: "Chat", messages: [
Message(role: .user, content: "q1", tokens: 5, timestamp: day1),
Message(role: .assistant, content: "a1", tokens: 10, cost: 0.01, timestamp: day1),
Message(role: .user, content: "q2", tokens: 5, timestamp: day2),
Message(role: .assistant, content: "a2", tokens: 20, cost: 0.02, timestamp: day2),
])
let daily = try db.getDailyUsage(
from: gmtCalendar.date(from: DateComponents(year: 2026, month: 8, day: 1))!,
to: gmtCalendar.date(from: DateComponents(year: 2026, month: 8, day: 3))!
)
#expect(daily.count == 2)
#expect(daily[0].totalTokens == 15)
#expect(daily[0].questionCount == 1)
#expect(daily[0].hasCostData == true)
#expect(daily[1].totalTokens == 25)
#expect(daily[1].questionCount == 1)
}
@Test("Usage by conversation joins conversation names and sorts by cost descending")
func usageByConversationSortsByCost() throws {
let db = DatabaseService.makeInMemory()