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
+99
View File
@@ -121,3 +121,102 @@ struct DatabaseServiceConversationTests {
#expect(loaded?.1.first?.content == "hello")
}
}
@Suite("DatabaseService usage statistics, against a throwaway in-memory queue")
struct DatabaseServiceUsageStatsTests {
@Test("Overall stats aggregate tokens, cost, and message count across conversations")
func overallStatsAggregate() throws {
let db = DatabaseService.makeInMemory()
_ = try db.saveConversation(name: "Chat A", messages: [
Message(role: .user, content: "hi", tokens: 10, modelId: "claude-sonnet"),
Message(role: .assistant, content: "hello", tokens: 20, cost: 0.01, modelId: "claude-sonnet"),
])
_ = try db.saveConversation(name: "Chat B", messages: [
Message(role: .user, content: "hey", tokens: 5, modelId: "gpt-4"),
Message(role: .assistant, content: "hi", tokens: 15, cost: 0.02, modelId: "gpt-4"),
])
let stats = try db.getOverallUsageStats()
#expect(stats.totalMessages == 4)
#expect(stats.totalTokens == 50)
#expect(stats.hasCostData == true)
#expect(abs(stats.totalCost - 0.03) < 0.0001)
}
@Test("Overall stats report no cost data when no message has a cost")
func overallStatsNoCostData() throws {
let db = DatabaseService.makeInMemory()
_ = try db.saveConversation(name: "Chat", messages: [
Message(role: .user, content: "hi", tokens: 10),
])
let stats = try db.getOverallUsageStats()
#expect(stats.hasCostData == false)
#expect(stats.totalCost == 0)
}
@Test("Usage by model groups messages by modelId and sums their tokens/cost")
func usageByModelGroups() throws {
let db = DatabaseService.makeInMemory()
_ = try db.saveConversation(name: "Chat A", messages: [
Message(role: .assistant, content: "a1", tokens: 20, cost: 0.01, modelId: "claude-sonnet"),
])
_ = try db.saveConversation(name: "Chat B", messages: [
Message(role: .assistant, content: "b1", tokens: 15, cost: 0.02, modelId: "gpt-4"),
Message(role: .assistant, content: "b2", tokens: 5, cost: 0.02, modelId: "gpt-4"),
])
let byModel = try db.getUsageByModel()
#expect(byModel.count == 2)
let gpt4 = byModel.first { $0.modelId == "gpt-4" }
#expect(gpt4?.messageCount == 2)
#expect(gpt4?.totalTokens == 20)
#expect(abs((gpt4?.totalCost ?? 0) - 0.04) < 0.0001)
// gpt-4 has higher total cost than claude-sonnet, so it should sort first
#expect(byModel.first?.modelId == "gpt-4")
}
@Test("Usage by model excludes messages with no modelId")
func usageByModelExcludesNilModelId() throws {
let db = DatabaseService.makeInMemory()
_ = try db.saveConversation(name: "Chat", messages: [
Message(role: .user, content: "hi", tokens: 10),
Message(role: .assistant, content: "hello", tokens: 20, modelId: "claude-sonnet"),
])
let byModel = try db.getUsageByModel()
#expect(byModel.count == 1)
#expect(byModel.first?.modelId == "claude-sonnet")
}
@Test("Usage by conversation joins conversation names and sorts by cost descending")
func usageByConversationSortsByCost() throws {
let db = DatabaseService.makeInMemory()
_ = try db.saveConversation(name: "Cheap Chat", messages: [
Message(role: .assistant, content: "a", tokens: 10, cost: 0.001, modelId: "m"),
])
_ = try db.saveConversation(name: "Expensive Chat", messages: [
Message(role: .assistant, content: "b", tokens: 10, cost: 0.05, modelId: "m"),
])
let byConversation = try db.getUsageByConversation()
#expect(byConversation.count == 2)
#expect(byConversation.first?.name == "Expensive Chat")
}
@Test("Usage by conversation respects the limit parameter")
func usageByConversationRespectsLimit() throws {
let db = DatabaseService.makeInMemory()
for i in 0..<5 {
_ = try db.saveConversation(name: "Chat \(i)", messages: [
Message(role: .assistant, content: "a", tokens: 10, cost: Double(i) * 0.01, modelId: "m"),
])
}
let byConversation = try db.getUsageByConversation(limit: 3)
#expect(byConversation.count == 3)
}
}