// // UsageStats.swift // Confab // // All-time usage statistics (aggregated from the messages table) // // 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 // for // the full license text. For commercial licensing, contact Rune // Olsen via . import Foundation struct UsageStats: Sendable { var totalMessages: Int var totalQuestions: Int var totalTokens: Int var totalCost: Double var hasCostData: Bool var firstMessageDate: Date? var lastMessageDate: Date? nonisolated init( totalMessages: Int = 0, totalQuestions: Int = 0, totalTokens: Int = 0, totalCost: Double = 0.0, hasCostData: Bool = false, firstMessageDate: Date? = nil, lastMessageDate: Date? = nil ) { self.totalMessages = totalMessages self.totalQuestions = totalQuestions self.totalTokens = totalTokens self.totalCost = totalCost self.hasCostData = hasCostData self.firstMessageDate = firstMessageDate self.lastMessageDate = lastMessageDate } 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" } } 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 var lastUsed: Date nonisolated init( modelId: String, messageCount: Int, questionCount: Int = 0, totalTokens: Int, totalCost: Double, hasCostData: Bool, lastUsed: Date ) { self.modelId = modelId self.messageCount = messageCount 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" } } /// 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 } 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 } var name: String var messageCount: Int var totalTokens: Int var totalCost: Double var hasCostData: Bool nonisolated init( conversationId: UUID, name: String, messageCount: Int, totalTokens: Int, totalCost: Double, hasCostData: Bool ) { self.conversationId = conversationId self.name = name self.messageCount = messageCount self.totalTokens = totalTokens self.totalCost = totalCost self.hasCostData = hasCostData } 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" } } /// 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) } } }