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",
|
||||
"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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: """
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@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()
|
||||
|
||||
Reference in New Issue
Block a user