diff --git a/oAI/Localizable.xcstrings b/oAI/Localizable.xcstrings index 4e6a8e3..9ca6654 100644 --- a/oAI/Localizable.xcstrings +++ b/oAI/Localizable.xcstrings @@ -1,6 +1,40 @@ { "sourceLanguage" : "en", "strings" : { + "By Provider" : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Efter udbyder" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nach Anbieter" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Par fournisseur" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Etter leverandør" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Efter leverantör" + } + } + } + }, "Today" : { "localizations" : { "da" : { diff --git a/oAI/Models/UsageStats.swift b/oAI/Models/UsageStats.swift index 666eccb..1a5beb8 100644 --- a/oAI/Models/UsageStats.swift +++ b/oAI/Models/UsageStats.swift @@ -108,6 +108,47 @@ struct ModelUsageStat: Identifiable, Sendable { } } +/// Usage grouped by provider (openrouter/anthropic/openai/ollama/appleOnDevice), from `usage_events`. +struct ProviderUsageStat: Identifiable, Sendable { + var id: String { provider } + let provider: String + var questionCount: Int + var totalTokens: Int + var totalCost: Double + var hasCostData: Bool + var lastUsed: Date + + nonisolated init( + provider: String, + questionCount: Int, + totalTokens: Int, + totalCost: Double, + hasCostData: Bool, + lastUsed: Date + ) { + self.provider = provider + self.questionCount = questionCount + self.totalTokens = totalTokens + self.totalCost = totalCost + self.hasCostData = hasCostData + self.lastUsed = lastUsed + } + + var totalTokensDisplay: String { + if totalTokens >= 1_000_000 { + return String(format: "%.1fM", Double(totalTokens) / 1_000_000) + } else if totalTokens >= 1000 { + return String(format: "%.1fK", Double(totalTokens) / 1000) + } else { + return "\(totalTokens)" + } + } + + var totalCostDisplay: String { + hasCostData ? String(format: "$%.4f", totalCost) : "N/A" + } +} + /// One day's usage totals, for time-series charting in the Analytics view. struct DailyUsageStat: Identifiable, Sendable { var id: Date { day } diff --git a/oAI/Services/DatabaseService.swift b/oAI/Services/DatabaseService.swift index a7a5803..e7699a9 100644 --- a/oAI/Services/DatabaseService.swift +++ b/oAI/Services/DatabaseService.swift @@ -65,6 +65,19 @@ struct MessageRecord: Codable, FetchableRecord, PersistableRecord, Sendable { var modelId: String? } +struct UsageEventRecord: Codable, FetchableRecord, PersistableRecord, Sendable { + static let databaseTableName = "usage_events" + + var id: String + var timestamp: String + var provider: String + var modelId: String + var promptTokens: Int? + var completionTokens: Int? + var cost: Double? + var conversationId: String? +} + struct SettingRecord: Codable, FetchableRecord, PersistableRecord, Sendable { static let databaseTableName = "settings" @@ -404,6 +417,26 @@ final class DatabaseService: Sendable { } } + migrator.registerMigration("v13") { db in + // Usage tracking independent of conversation persistence: conversation *text* is only + // ever saved when the user explicitly saves (⌘S), but usage (tokens/cost/model/provider) + // should be recorded for every completed AI response regardless — one row per finished + // generation. conversationId is a best-effort link (set only if the conversation had + // already been saved at least once at logging time) and is not a foreign key, since the + // referenced conversation may never exist. + try db.create(table: "usage_events") { t in + t.column("id", .text).primaryKey() + t.column("timestamp", .text).notNull() + t.column("provider", .text).notNull() + t.column("modelId", .text).notNull() + t.column("promptTokens", .integer) + t.column("completionTokens", .integer) + t.column("cost", .double) + t.column("conversationId", .text) + } + try db.create(index: "idx_usage_events_timestamp", on: "usage_events", columns: ["timestamp"]) + } + return migrator } @@ -959,6 +992,204 @@ final class DatabaseService: Sendable { } } + // MARK: - Usage Events (save-independent usage tracking) + + /// Records one completed AI generation's usage — independent of whether the conversation it + /// belongs to is ever explicitly saved. `conversationId` is a best-effort link, set only if the + /// conversation had already been saved at least once when this was logged. + nonisolated func logUsageEvent( + provider: String, + modelId: String, + promptTokens: Int?, + completionTokens: Int?, + cost: Double?, + conversationId: UUID? + ) throws { + let record = UsageEventRecord( + id: UUID().uuidString, + timestamp: Self.isoString(from: Date()), + provider: provider, + modelId: modelId, + promptTokens: promptTokens, + completionTokens: completionTokens, + cost: cost, + conversationId: conversationId?.uuidString + ) + try dbQueue.write { db in + try record.insert(db) + } + } + + /// Overall usage-event totals (each row = one completed generation = one "question"). + nonisolated func getUsageEventTotals(from: Date? = nil, to: Date? = nil) throws -> UsageStats { + try dbQueue.read { db in + let (whereSQL, arguments) = Self.timestampRangeClause(from: from, to: to) + let sql = """ + SELECT COUNT(*) AS cnt, + COALESCE(SUM(COALESCE(promptTokens, 0) + COALESCE(completionTokens, 0)), 0) AS tokens, + COALESCE(SUM(cost), 0) AS cost, + COUNT(cost) AS costCount, + MIN(timestamp) AS minTs, + MAX(timestamp) AS maxTs + FROM usage_events + \(whereSQL) + """ + guard let row = try Row.fetchOne(db, sql: sql, arguments: StatementArguments(arguments)) + else { + return UsageStats() + } + + let costCount: Int = row["costCount"] + let cnt: Int = row["cnt"] + let minTs: String? = row["minTs"] + let maxTs: String? = row["maxTs"] + + return UsageStats( + totalMessages: cnt, + totalQuestions: cnt, + totalTokens: row["tokens"], + totalCost: row["cost"], + hasCostData: costCount > 0, + firstMessageDate: minTs.flatMap { Self.isoDate(from: $0) }, + lastMessageDate: maxTs.flatMap { Self.isoDate(from: $0) } + ) + } + } + + /// Usage events grouped by model. + nonisolated func getUsageEventsByModel(from: Date? = nil, to: Date? = nil) throws -> [ModelUsageStat] { + try dbQueue.read { db in + let (whereSQL, arguments) = Self.timestampRangeClause(from: from, to: to) + let sql = """ + SELECT modelId, + COUNT(*) AS cnt, + COALESCE(SUM(COALESCE(promptTokens, 0) + COALESCE(completionTokens, 0)), 0) AS tokens, + COALESCE(SUM(cost), 0) AS cost, + COUNT(cost) AS costCount, + MAX(timestamp) AS lastUsed + FROM usage_events + \(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"], + let lastUsedString: String = row["lastUsed"], + let lastUsed = Self.isoDate(from: lastUsedString) + else { return nil } + + let costCount: Int = row["costCount"] + let cnt: Int = row["cnt"] + return ModelUsageStat( + modelId: modelId, + messageCount: cnt, + questionCount: 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 + } + } + } + + /// Usage events grouped by provider. + nonisolated func getUsageEventsByProvider(from: Date? = nil, to: Date? = nil) throws -> [ProviderUsageStat] { + try dbQueue.read { db in + let (whereSQL, arguments) = Self.timestampRangeClause(from: from, to: to) + let sql = """ + SELECT provider, + COUNT(*) AS cnt, + COALESCE(SUM(COALESCE(promptTokens, 0) + COALESCE(completionTokens, 0)), 0) AS tokens, + COALESCE(SUM(cost), 0) AS cost, + COUNT(cost) AS costCount, + MAX(timestamp) AS lastUsed + FROM usage_events + \(whereSQL) + GROUP BY provider + """ + let rows = try Row.fetchAll(db, sql: sql, arguments: StatementArguments(arguments)) + + let stats: [ProviderUsageStat] = rows.compactMap { row in + guard let provider: String = row["provider"], + let lastUsedString: String = row["lastUsed"], + let lastUsed = Self.isoDate(from: lastUsedString) + else { return nil } + + let costCount: Int = row["costCount"] + return ProviderUsageStat( + provider: provider, + questionCount: 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 + } + } + } + + /// Usage events bucketed by calendar day (GMT, matching `isoStyle`), for the Analytics view. + nonisolated func getDailyUsageEvents(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(COALESCE(promptTokens, 0) + COALESCE(completionTokens, 0)), 0) AS tokens, + COALESCE(SUM(cost), 0) AS cost, + COUNT(cost) AS costCount + FROM usage_events + 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"] + let cnt: Int = row["cnt"] + return DailyUsageStat( + day: day, + messageCount: cnt, + questionCount: cnt, + 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: """ diff --git a/oAI/ViewModels/ChatViewModel.swift b/oAI/ViewModels/ChatViewModel.swift index 25c7e95..5f0420c 100644 --- a/oAI/ViewModels/ChatViewModel.swift +++ b/oAI/ViewModels/ChatViewModel.swift @@ -1153,6 +1153,7 @@ Don't narrate future actions ("Let me...") - just use the tools. let cost = Self.resolveCost(usage: usage, pricing: model.pricing) messages[index].cost = cost sessionStats.addMessage(inputTokens: usage.promptTokens, outputTokens: usage.completionTokens, cost: cost) + logUsageEvent(modelId: modelId, promptTokens: usage.promptTokens, completionTokens: usage.completionTokens, cost: cost) } } } @@ -1216,6 +1217,7 @@ Don't narrate future actions ("Let me...") - just use the tools. let cost = Self.resolveCost(usage: usage, pricing: model.pricing) messages[index].cost = cost sessionStats.addMessage(inputTokens: usage.promptTokens, outputTokens: usage.completionTokens, cost: cost) + logUsageEvent(modelId: modelId, promptTokens: usage.promptTokens, completionTokens: usage.completionTokens, cost: cost) } } @@ -1572,6 +1574,7 @@ Don't narrate future actions ("Let me...") - just use the tools. let cost = usage.rawCostUSD messages[index].cost = cost sessionStats.addMessage(inputTokens: usage.promptTokens, outputTokens: usage.completionTokens, cost: cost) + logUsageEvent(modelId: modelId, promptTokens: usage.promptTokens, completionTokens: usage.completionTokens, cost: cost) } } } catch { @@ -1863,6 +1866,7 @@ Don't narrate future actions ("Let me...") - just use the tools. outputTokens: usage.completionTokens, cost: cost ) + logUsageEvent(modelId: modelId, promptTokens: usage.promptTokens, completionTokens: usage.completionTokens, cost: cost) } } else { let assistantMessage = Message( @@ -1890,6 +1894,7 @@ Don't narrate future actions ("Let me...") - just use the tools. outputTokens: usage.completionTokens, cost: cost ) + logUsageEvent(modelId: modelId, promptTokens: usage.promptTokens, completionTokens: usage.completionTokens, cost: cost) } } @@ -2610,6 +2615,24 @@ Don't narrate future actions ("Let me...") - just use the tools. return calculateCost(usage: usage, pricing: pricing) } + /// Records usage independent of whether this conversation ever gets explicitly saved (⌘S) — + /// conversation *text* is opt-in, but tokens/cost/model/provider are tracked for every + /// completed generation regardless, so the Analytics view reflects real usage either way. + private func logUsageEvent(modelId: String, promptTokens: Int?, completionTokens: Int?, cost: Double?) { + do { + try DatabaseService.shared.logUsageEvent( + provider: currentProvider.rawValue, + modelId: modelId, + promptTokens: promptTokens, + completionTokens: completionTokens, + cost: cost, + conversationId: currentConversationId + ) + } catch { + Log.db.error("Failed to log usage event: \(error.localizedDescription)") + } + } + /// Summarize a chunk of messages into a concise summary private func summarizeMessageChunk(_ messages: [Message]) async -> String? { guard let provider = providerRegistry.getProvider(for: currentProvider), diff --git a/oAI/Views/Screens/StatsView.swift b/oAI/Views/Screens/StatsView.swift index e819eb3..96c91a7 100644 --- a/oAI/Views/Screens/StatsView.swift +++ b/oAI/Views/Screens/StatsView.swift @@ -43,12 +43,21 @@ struct StatsView: View { var body: some View { NavigationStack { VStack(spacing: 0) { - Picker("", selection: $selectedTab) { - Text("Session").tag(StatsTab.session) - Text("All-Time").tag(StatsTab.allTime) + HStack(spacing: 8) { + Picker("", selection: $selectedTab) { + Text("Session").tag(StatsTab.session) + Text("All-Time").tag(StatsTab.allTime) + } + .pickerStyle(.segmented) + .labelsHidden() + + Button { + showAnalytics = true + } label: { + Image(systemName: "chart.bar.xaxis") + } + .help("View detailed usage analytics") } - .pickerStyle(.segmented) - .labelsHidden() .padding(.horizontal, 16) .padding(.top, 12) .padding(.bottom, 4) @@ -64,14 +73,6 @@ 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() diff --git a/oAI/Views/Screens/UsageAnalyticsView.swift b/oAI/Views/Screens/UsageAnalyticsView.swift index be8366f..a2e6bf3 100644 --- a/oAI/Views/Screens/UsageAnalyticsView.swift +++ b/oAI/Views/Screens/UsageAnalyticsView.swift @@ -51,6 +51,7 @@ private enum AnalyticsMetric: String, CaseIterable, Identifiable { private enum AnalyticsChartMode: String, CaseIterable, Identifiable { case overTime case byModel + case byProvider var id: String { rawValue } } @@ -64,6 +65,7 @@ struct UsageAnalyticsView: View { @State private var overallStats = UsageStats() @State private var modelStats: [ModelUsageStat] = [] + @State private var providerStats: [ProviderUsageStat] = [] @State private var dailyStats: [DailyUsageStat] = [] var body: some View { @@ -85,6 +87,10 @@ struct UsageAnalyticsView: View { if chartMode == .byModel && !modelStats.isEmpty { modelBreakdownList } + + if chartMode == .byProvider && !providerStats.isEmpty { + providerBreakdownList + } } .padding(20) } @@ -156,10 +162,11 @@ struct UsageAnalyticsView: View { Picker("", selection: $chartMode) { Text("Over Time").tag(AnalyticsChartMode.overTime) Text("By Model").tag(AnalyticsChartMode.byModel) + Text("By Provider").tag(AnalyticsChartMode.byProvider) } .pickerStyle(.segmented) .labelsHidden() - .frame(maxWidth: 280) + .frame(maxWidth: 400) } @ViewBuilder @@ -169,6 +176,8 @@ struct UsageAnalyticsView: View { timeSeriesChart case .byModel: byModelChart + case .byProvider: + byProviderChart } } @@ -209,6 +218,26 @@ struct UsageAnalyticsView: View { } } + private var byProviderChart: some View { + Group { + if providerStats.isEmpty { + emptyChartPlaceholder + } else { + Chart(providerStats) { stat in + SectorMark( + angle: .value("Value", providerValue(for: stat)), + innerRadius: .ratio(0.55), + angularInset: 1.5 + ) + .foregroundStyle(by: .value("Provider", providerDisplayName(stat.provider))) + .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") @@ -252,6 +281,40 @@ struct UsageAnalyticsView: View { .cornerRadius(10) } + // MARK: - By Provider List + + private var providerBreakdownList: some View { + VStack(alignment: .leading, spacing: 4) { + Text("By Provider") + .font(.headline) + .padding(.bottom, 4) + + ForEach(providerStats) { stat in + HStack { + Text(providerDisplayName(stat.provider)) + .font(.body) + .lineLimit(1) + Spacer() + Text(providerValueDisplay(for: stat)) + .font(.body.monospacedDigit()) + .foregroundColor(.secondary) + } + .padding(.vertical, 4) + + if stat.id != providerStats.last?.id { + Divider() + } + } + } + .padding(16) + .background(Color.confabSurface.opacity(0.5)) + .cornerRadius(10) + } + + private func providerDisplayName(_ rawProvider: String) -> String { + Settings.Provider(rawValue: rawProvider)?.displayName ?? rawProvider + } + // MARK: - Value helpers private func dailyValue(for stat: DailyUsageStat) -> Double { @@ -278,15 +341,34 @@ struct UsageAnalyticsView: View { } } + private func providerValue(for stat: ProviderUsageStat) -> Double { + switch metric { + case .tokens: return Double(stat.totalTokens) + case .questions: return Double(stat.questionCount) + case .cost: return stat.totalCost + } + } + + private func providerValueDisplay(for stat: ProviderUsageStat) -> String { + switch metric { + case .tokens: return stat.totalTokensDisplay + case .questions: return "\(stat.questionCount)" + case .cost: return stat.totalCostDisplay + } + } + // MARK: - Data Loading + /// Sourced from `usage_events`, not `messages` — reflects usage for every completed + /// generation regardless of whether the conversation it belongs to was ever saved. 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)) ?? [] + overallStats = (try? DatabaseService.shared.getUsageEventTotals(from: range.start, to: range.end)) ?? UsageStats() + modelStats = (try? DatabaseService.shared.getUsageEventsByModel(from: range.start, to: range.end)) ?? [] + providerStats = (try? DatabaseService.shared.getUsageEventsByProvider(from: range.start, to: range.end)) ?? [] let dailyFrom = range.start ?? overallStats.firstMessageDate ?? range.end - dailyStats = (try? DatabaseService.shared.getDailyUsage(from: dailyFrom, to: range.end)) ?? [] + dailyStats = (try? DatabaseService.shared.getDailyUsageEvents(from: dailyFrom, to: range.end)) ?? [] } } diff --git a/oAITests/DatabaseServiceTests.swift b/oAITests/DatabaseServiceTests.swift index c9a36c1..8addd70 100644 --- a/oAITests/DatabaseServiceTests.swift +++ b/oAITests/DatabaseServiceTests.swift @@ -25,6 +25,17 @@ struct DatabaseServiceMigrationTests { } } + @Test("v13 adds the usage_events table with the expected columns") + func v13AddsUsageEventsTable() { + let db = DatabaseService.makeInMemory() + #expect(db.tableExists("usage_events")) + let columns = Set(db.columnNames(in: "usage_events")) + let expected: Set = [ + "id", "timestamp", "provider", "modelId", "promptTokens", "completionTokens", "cost", "conversationId", + ] + #expect(expected.isSubset(of: columns)) + } + @Test("v4 adds modelId to messages and primaryModel to conversations") func v4AddsModelColumns() { let db = DatabaseService.makeInMemory() @@ -299,6 +310,80 @@ struct DatabaseServiceUsageStatsTests { } } +@Suite("DatabaseService usage events, against a throwaway in-memory queue") +struct DatabaseServiceUsageEventsTests { + + @Test("Logged usage events are readable regardless of any saved conversation") + func logUsageEventPersistsWithNoConversation() throws { + let db = DatabaseService.makeInMemory() + try db.logUsageEvent( + provider: "openrouter", modelId: "gpt-5", promptTokens: 100, completionTokens: 50, + cost: 0.02, conversationId: nil + ) + + let totals = try db.getUsageEventTotals() + #expect(totals.totalQuestions == 1) + #expect(totals.totalTokens == 150) + #expect(totals.hasCostData == true) + #expect(abs(totals.totalCost - 0.02) < 0.0001) + } + + @Test("Usage events respect an optional date range") + func usageEventTotalsRespectsDateRange() throws { + let db = DatabaseService.makeInMemory() + let now = Date() + let old = now.addingTimeInterval(-10 * 24 * 3600) + + try db.logUsageEvent(provider: "openrouter", modelId: "gpt-5", promptTokens: 10, completionTokens: 10, cost: 0.01, conversationId: nil) + // Directly insert an old-dated row via the private record path isn't exposed, so approximate + // "old" coverage by asserting the fresh row is excluded when the range is set entirely in the past. + let rangeExcludingNow = try db.getUsageEventTotals(from: old, to: old.addingTimeInterval(3600)) + #expect(rangeExcludingNow.totalQuestions == 0) + + let rangeIncludingNow = try db.getUsageEventTotals(from: now.addingTimeInterval(-3600), to: now.addingTimeInterval(3600)) + #expect(rangeIncludingNow.totalQuestions == 1) + } + + @Test("Usage events group by model and by provider independently") + func usageEventsGroupByModelAndProvider() throws { + let db = DatabaseService.makeInMemory() + try db.logUsageEvent(provider: "openrouter", modelId: "gpt-5", promptTokens: 10, completionTokens: 10, cost: 0.01, conversationId: nil) + try db.logUsageEvent(provider: "openrouter", modelId: "claude-sonnet", promptTokens: 5, completionTokens: 5, cost: 0.02, conversationId: nil) + try db.logUsageEvent(provider: "anthropic", modelId: "claude-sonnet", promptTokens: 20, completionTokens: 20, cost: 0.03, conversationId: nil) + + let byModel = try db.getUsageEventsByModel() + #expect(byModel.count == 2) + let sonnet = byModel.first { $0.modelId == "claude-sonnet" } + #expect(sonnet?.questionCount == 2) + #expect(sonnet?.totalTokens == 50) + + let byProvider = try db.getUsageEventsByProvider() + #expect(byProvider.count == 2) + let openrouter = byProvider.first { $0.provider == "openrouter" } + #expect(openrouter?.questionCount == 2) + #expect(openrouter?.totalTokens == 30) + let anthropic = byProvider.first { $0.provider == "anthropic" } + #expect(anthropic?.questionCount == 1) + #expect(anthropic?.totalTokens == 40) + } + + @Test("Daily usage events bucket by calendar day (GMT, matching stored timestamps)") + func dailyUsageEventsBucketByDay() throws { + let db = DatabaseService.makeInMemory() + try db.logUsageEvent(provider: "openrouter", modelId: "gpt-5", promptTokens: 10, completionTokens: 10, cost: 0.01, conversationId: nil) + + var gmtCalendar = Calendar(identifier: .gregorian) + gmtCalendar.timeZone = TimeZone(identifier: "GMT")! + let farPast = gmtCalendar.date(from: DateComponents(year: 2020, month: 1, day: 1))! + let farFuture = gmtCalendar.date(from: DateComponents(year: 2030, month: 1, day: 1))! + + let daily = try db.getDailyUsageEvents(from: farPast, to: farFuture) + #expect(daily.count == 1) + #expect(daily.first?.totalTokens == 20) + #expect(daily.first?.questionCount == 1) + } +} + @Suite("DatabaseService folders, against a throwaway in-memory queue") struct DatabaseServiceFolderTests {