diff --git a/oAI/Localizable.xcstrings b/oAI/Localizable.xcstrings index 5088a2a..4e6a8e3 100644 --- a/oAI/Localizable.xcstrings +++ b/oAI/Localizable.xcstrings @@ -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, diff --git a/oAI/Models/UsageStats.swift b/oAI/Models/UsageStats.swift index cc66e91..666eccb 100644 --- a/oAI/Models/UsageStats.swift +++ b/oAI/Models/UsageStats.swift @@ -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) + } + } +} diff --git a/oAI/Services/DatabaseService.swift b/oAI/Services/DatabaseService.swift index 85a8db5..a7a5803 100644 --- a/oAI/Services/DatabaseService.swift +++ b/oAI/Services/DatabaseService.swift @@ -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: """ diff --git a/oAI/Views/Screens/StatsView.swift b/oAI/Views/Screens/StatsView.swift index e2738b4..e819eb3 100644 --- a/oAI/Views/Screens/StatsView.swift +++ b/oAI/Views/Screens/StatsView.swift @@ -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 { diff --git a/oAI/Views/Screens/UsageAnalyticsView.swift b/oAI/Views/Screens/UsageAnalyticsView.swift new file mode 100644 index 0000000..be8366f --- /dev/null +++ b/oAI/Views/Screens/UsageAnalyticsView.swift @@ -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 +// for +// the full license text. For commercial licensing, contact Rune +// Olsen via . + + +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() +} diff --git a/oAITests/DatabaseServiceTests.swift b/oAITests/DatabaseServiceTests.swift index b860bb6..c9a36c1 100644 --- a/oAITests/DatabaseServiceTests.swift +++ b/oAITests/DatabaseServiceTests.swift @@ -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()