Add all-time usage statistics tab

Extends the existing Stats sheet (⌘⇧S) with a Session/All-Time segmented
picker. All-Time aggregates tokens, cost, and message counts across every
saved conversation, broken down by model and by conversation, using the
modelId already stored per message — no schema change needed.
This commit is contained in:
2026-07-28 12:35:45 +02:00
parent 71e8a49d77
commit 0642f3746f
4 changed files with 489 additions and 71 deletions
+112
View File
@@ -576,6 +576,118 @@ final class DatabaseService: Sendable {
}
}
// MARK: - Usage Statistics
nonisolated func getOverallUsageStats() throws -> UsageStats {
try dbQueue.read { db in
guard let row = try Row.fetchOne(db, sql: """
SELECT COUNT(*) AS cnt,
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
""")
else {
return UsageStats()
}
let costCount: Int = row["costCount"]
let minTs: String? = row["minTs"]
let maxTs: String? = row["maxTs"]
return UsageStats(
totalMessages: row["cnt"],
totalTokens: row["tokens"],
totalCost: row["cost"],
hasCostData: costCount > 0,
firstMessageDate: minTs.flatMap { Self.isoDate(from: $0) },
lastMessageDate: maxTs.flatMap { Self.isoDate(from: $0) }
)
}
}
nonisolated func getUsageByModel() throws -> [ModelUsageStat] {
try dbQueue.read { db in
let rows = try Row.fetchAll(db, sql: """
SELECT modelId,
COUNT(*) AS cnt,
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
GROUP BY modelId
""")
let stats: [ModelUsageStat] = rows.compactMap { row in
guard let modelId: String = row["modelId"],
let lastUsedString: String = row["lastUsed"],
let lastUsed = Self.isoDate(from: lastUsedString)
else { return nil }
let costCount: Int = row["costCount"]
return ModelUsageStat(
modelId: modelId,
messageCount: row["cnt"],
totalTokens: row["tokens"],
totalCost: row["cost"],
hasCostData: costCount > 0,
lastUsed: lastUsed
)
}
return stats.sorted { lhs, rhs in
if lhs.hasCostData || rhs.hasCostData {
return lhs.totalCost > rhs.totalCost
}
return lhs.totalTokens > rhs.totalTokens
}
}
}
nonisolated func getUsageByConversation(limit: Int = 20) throws -> [ConversationUsageStat] {
try dbQueue.read { db in
let rows = try Row.fetchAll(db, sql: """
SELECT m.conversationId AS conversationId,
c.name AS name,
COUNT(*) AS cnt,
COALESCE(SUM(m.tokens), 0) AS tokens,
COALESCE(SUM(m.cost), 0) AS cost,
COUNT(m.cost) AS costCount
FROM messages m
JOIN conversations c ON m.conversationId = c.id
GROUP BY m.conversationId
""")
let stats: [ConversationUsageStat] = rows.compactMap { row in
guard let conversationIdString: String = row["conversationId"],
let conversationId = UUID(uuidString: conversationIdString)
else { return nil }
let costCount: Int = row["costCount"]
return ConversationUsageStat(
conversationId: conversationId,
name: row["name"],
messageCount: row["cnt"],
totalTokens: row["tokens"],
totalCost: row["cost"],
hasCostData: costCount > 0
)
}
let sorted = stats.sorted { lhs, rhs in
if lhs.hasCostData || rhs.hasCostData {
return lhs.totalCost > rhs.totalCost
}
return lhs.totalTokens > rhs.totalTokens
}
return Array(sorted.prefix(limit))
}
}
nonisolated func deleteConversation(id: UUID) throws -> Bool {
Log.db.info("Deleting conversation \(id.uuidString)")
return try dbQueue.write { db in