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:
@@ -1,6 +1,414 @@
|
|||||||
{
|
{
|
||||||
"sourceLanguage" : "en",
|
"sourceLanguage" : "en",
|
||||||
"strings" : {
|
"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.",
|
"comment" : "A separator between the message count and the date.",
|
||||||
"isCommentAutoGenerated" : true,
|
"isCommentAutoGenerated" : true,
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import Foundation
|
|||||||
|
|
||||||
struct UsageStats: Sendable {
|
struct UsageStats: Sendable {
|
||||||
var totalMessages: Int
|
var totalMessages: Int
|
||||||
|
var totalQuestions: Int
|
||||||
var totalTokens: Int
|
var totalTokens: Int
|
||||||
var totalCost: Double
|
var totalCost: Double
|
||||||
var hasCostData: Bool
|
var hasCostData: Bool
|
||||||
@@ -33,6 +34,7 @@ struct UsageStats: Sendable {
|
|||||||
|
|
||||||
nonisolated init(
|
nonisolated init(
|
||||||
totalMessages: Int = 0,
|
totalMessages: Int = 0,
|
||||||
|
totalQuestions: Int = 0,
|
||||||
totalTokens: Int = 0,
|
totalTokens: Int = 0,
|
||||||
totalCost: Double = 0.0,
|
totalCost: Double = 0.0,
|
||||||
hasCostData: Bool = false,
|
hasCostData: Bool = false,
|
||||||
@@ -40,6 +42,7 @@ struct UsageStats: Sendable {
|
|||||||
lastMessageDate: Date? = nil
|
lastMessageDate: Date? = nil
|
||||||
) {
|
) {
|
||||||
self.totalMessages = totalMessages
|
self.totalMessages = totalMessages
|
||||||
|
self.totalQuestions = totalQuestions
|
||||||
self.totalTokens = totalTokens
|
self.totalTokens = totalTokens
|
||||||
self.totalCost = totalCost
|
self.totalCost = totalCost
|
||||||
self.hasCostData = hasCostData
|
self.hasCostData = hasCostData
|
||||||
@@ -66,6 +69,7 @@ struct ModelUsageStat: Identifiable, Sendable {
|
|||||||
var id: String { modelId }
|
var id: String { modelId }
|
||||||
let modelId: String
|
let modelId: String
|
||||||
var messageCount: Int
|
var messageCount: Int
|
||||||
|
var questionCount: Int
|
||||||
var totalTokens: Int
|
var totalTokens: Int
|
||||||
var totalCost: Double
|
var totalCost: Double
|
||||||
var hasCostData: Bool
|
var hasCostData: Bool
|
||||||
@@ -74,6 +78,7 @@ struct ModelUsageStat: Identifiable, Sendable {
|
|||||||
nonisolated init(
|
nonisolated init(
|
||||||
modelId: String,
|
modelId: String,
|
||||||
messageCount: Int,
|
messageCount: Int,
|
||||||
|
questionCount: Int = 0,
|
||||||
totalTokens: Int,
|
totalTokens: Int,
|
||||||
totalCost: Double,
|
totalCost: Double,
|
||||||
hasCostData: Bool,
|
hasCostData: Bool,
|
||||||
@@ -81,6 +86,7 @@ struct ModelUsageStat: Identifiable, Sendable {
|
|||||||
) {
|
) {
|
||||||
self.modelId = modelId
|
self.modelId = modelId
|
||||||
self.messageCount = messageCount
|
self.messageCount = messageCount
|
||||||
|
self.questionCount = questionCount
|
||||||
self.totalTokens = totalTokens
|
self.totalTokens = totalTokens
|
||||||
self.totalCost = totalCost
|
self.totalCost = totalCost
|
||||||
self.hasCostData = hasCostData
|
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 {
|
struct ConversationUsageStat: Identifiable, Sendable {
|
||||||
let conversationId: UUID
|
let conversationId: UUID
|
||||||
var id: UUID { conversationId }
|
var id: UUID { conversationId }
|
||||||
@@ -141,3 +174,33 @@ struct ConversationUsageStat: Identifiable, Sendable {
|
|||||||
hasCostData ? String(format: "$%.4f", totalCost) : "N/A"
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -806,17 +806,44 @@ final class DatabaseService: Sendable {
|
|||||||
|
|
||||||
// MARK: - Usage Statistics
|
// 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
|
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,
|
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(tokens), 0) AS tokens,
|
||||||
COALESCE(SUM(cost), 0) AS cost,
|
COALESCE(SUM(cost), 0) AS cost,
|
||||||
COUNT(cost) AS costCount,
|
COUNT(cost) AS costCount,
|
||||||
MIN(timestamp) AS minTs,
|
MIN(timestamp) AS minTs,
|
||||||
MAX(timestamp) AS maxTs
|
MAX(timestamp) AS maxTs
|
||||||
FROM messages
|
FROM messages
|
||||||
""")
|
\(whereSQL)
|
||||||
|
"""
|
||||||
|
|
||||||
|
guard let row = try Row.fetchOne(db, sql: sql, arguments: StatementArguments(arguments))
|
||||||
else {
|
else {
|
||||||
return UsageStats()
|
return UsageStats()
|
||||||
}
|
}
|
||||||
@@ -827,6 +854,7 @@ final class DatabaseService: Sendable {
|
|||||||
|
|
||||||
return UsageStats(
|
return UsageStats(
|
||||||
totalMessages: row["cnt"],
|
totalMessages: row["cnt"],
|
||||||
|
totalQuestions: row["questions"],
|
||||||
totalTokens: row["tokens"],
|
totalTokens: row["tokens"],
|
||||||
totalCost: row["cost"],
|
totalCost: row["cost"],
|
||||||
hasCostData: costCount > 0,
|
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
|
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,
|
SELECT modelId,
|
||||||
COUNT(*) AS cnt,
|
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(tokens), 0) AS tokens,
|
||||||
COALESCE(SUM(cost), 0) AS cost,
|
COALESCE(SUM(cost), 0) AS cost,
|
||||||
COUNT(cost) AS costCount,
|
COUNT(cost) AS costCount,
|
||||||
MAX(timestamp) AS lastUsed
|
MAX(timestamp) AS lastUsed
|
||||||
FROM messages
|
FROM messages
|
||||||
WHERE modelId IS NOT NULL
|
\(whereSQL)
|
||||||
GROUP BY modelId
|
GROUP BY modelId
|
||||||
""")
|
"""
|
||||||
|
let rows = try Row.fetchAll(db, sql: sql, arguments: StatementArguments(arguments))
|
||||||
|
|
||||||
let stats: [ModelUsageStat] = rows.compactMap { row in
|
let stats: [ModelUsageStat] = rows.compactMap { row in
|
||||||
guard let modelId: String = row["modelId"],
|
guard let modelId: String = row["modelId"],
|
||||||
@@ -860,6 +894,7 @@ final class DatabaseService: Sendable {
|
|||||||
return ModelUsageStat(
|
return ModelUsageStat(
|
||||||
modelId: modelId,
|
modelId: modelId,
|
||||||
messageCount: row["cnt"],
|
messageCount: row["cnt"],
|
||||||
|
questionCount: row["questions"],
|
||||||
totalTokens: row["tokens"],
|
totalTokens: row["tokens"],
|
||||||
totalCost: row["cost"],
|
totalCost: row["cost"],
|
||||||
hasCostData: costCount > 0,
|
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] {
|
nonisolated func getUsageByConversation(limit: Int = 20) throws -> [ConversationUsageStat] {
|
||||||
try dbQueue.read { db in
|
try dbQueue.read { db in
|
||||||
let rows = try Row.fetchAll(db, sql: """
|
let rows = try Row.fetchAll(db, sql: """
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ struct StatsView: View {
|
|||||||
@State private var overallStats = UsageStats()
|
@State private var overallStats = UsageStats()
|
||||||
@State private var modelStats: [ModelUsageStat] = []
|
@State private var modelStats: [ModelUsageStat] = []
|
||||||
@State private var conversationStats: [ConversationUsageStat] = []
|
@State private var conversationStats: [ConversationUsageStat] = []
|
||||||
|
@State private var showAnalytics = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
@@ -63,6 +64,14 @@ struct StatsView: View {
|
|||||||
}
|
}
|
||||||
.navigationTitle("Statistics")
|
.navigationTitle("Statistics")
|
||||||
.toolbar {
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .navigation) {
|
||||||
|
Button {
|
||||||
|
showAnalytics = true
|
||||||
|
} label: {
|
||||||
|
Label("Analytics", systemImage: "chart.bar.xaxis")
|
||||||
|
}
|
||||||
|
.help("View detailed usage analytics")
|
||||||
|
}
|
||||||
ToolbarItem(placement: .confirmationAction) {
|
ToolbarItem(placement: .confirmationAction) {
|
||||||
Button("Done") {
|
Button("Done") {
|
||||||
dismiss()
|
dismiss()
|
||||||
@@ -74,6 +83,9 @@ struct StatsView: View {
|
|||||||
.task {
|
.task {
|
||||||
loadAllTimeStats()
|
loadAllTimeStats()
|
||||||
}
|
}
|
||||||
|
.sheet(isPresented: $showAnalytics) {
|
||||||
|
UsageAnalyticsView()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var sessionList: some View {
|
private var sessionList: some View {
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -192,6 +192,84 @@ struct DatabaseServiceUsageStatsTests {
|
|||||||
#expect(byModel.first?.modelId == "claude-sonnet")
|
#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")
|
@Test("Usage by conversation joins conversation names and sorts by cost descending")
|
||||||
func usageByConversationSortsByCost() throws {
|
func usageByConversationSortsByCost() throws {
|
||||||
let db = DatabaseService.makeInMemory()
|
let db = DatabaseService.makeInMemory()
|
||||||
|
|||||||
Reference in New Issue
Block a user