diff --git a/Sources/CodexBar/ShareStatsCardView.swift b/Sources/CodexBar/ShareStatsCardView.swift index 1cd70ccfe2..f475dc1402 100644 --- a/Sources/CodexBar/ShareStatsCardView.swift +++ b/Sources/CodexBar/ShareStatsCardView.swift @@ -68,11 +68,11 @@ struct ShareStatsCardView: View { private var hero: some View { HStack(alignment: .bottom, spacing: 52) { VStack(alignment: .leading, spacing: 2) { - Text("TRACKED TOKENS · \(self.payload.days) DAYS") + Text(self.tokenLabel) .font(.system(size: 20, weight: .semibold, design: .rounded)) .tracking(1.8) .foregroundStyle(self.secondary) - Text(self.payload.totalTokens.map(ShareStatsFormatting.compactCount) ?? "—") + Text(self.tokenHeadline) .font(.system(size: 104, weight: .semibold, design: .rounded)) .monospacedDigit() .lineLimit(1) @@ -91,9 +91,7 @@ struct ShareStatsCardView: View { .font(.system(size: 17, weight: .semibold, design: .rounded)) .foregroundStyle(self.secondary) Spacer() - Text(currency.estimatedCost.map { - ShareStatsFormatting.currency($0, code: currency.currencyCode) - } ?? "Unavailable") + Text(self.spendHeadline(currency)) .font(.system(size: 32, weight: .semibold, design: .rounded)) .monospacedDigit() .lineLimit(1) @@ -109,6 +107,25 @@ struct ShareStatsCardView: View { .frame(height: 132, alignment: .bottom) } + private var tokenLabel: String { + let prefix = self.payload.tokenCoverageIsComplete ? "TRACKED TOKENS" : "KNOWN TOKENS" + return "\(prefix) · \(self.payload.days) DAYS" + } + + private var tokenHeadline: String { + guard let totalTokens = self.payload.totalTokens else { return "—" } + let value = ShareStatsFormatting.compactCount(totalTokens) + return self.payload.tokenCoverageIsComplete ? value : "≥\(value)" + } + + private func spendHeadline(_ currency: ShareStatsCurrencyPayload) -> String { + guard let estimatedCost = currency.estimatedCost else { return "Unavailable" } + let value = ShareStatsFormatting.currency(estimatedCost, code: currency.currencyCode) + let isPartial = currency.pricedSourceCount < currency.sourceCount + || currency.coveredDayCount < self.payload.days + return isPartial ? "≥\(value)" : value + } + private var currencySummary: String { let hiddenCount = self.payload.currencies.count - min(self.payload.currencies.count, 2) return hiddenCount > 0 @@ -224,7 +241,7 @@ private struct ShareStatsModelRow: View { .font(.system(size: 20, weight: .semibold, design: .rounded)) .lineLimit(1) .minimumScaleFactor(0.82) - Text(self.model.providerName) + Text(self.model.sourceName) .font(.system(size: 16, weight: .medium, design: .rounded)) .foregroundStyle(Color(red: 0.70, green: 0.66, blue: 0.62)) .lineLimit(1) diff --git a/Sources/CodexBar/ShareStatsModelActivityCardView.swift b/Sources/CodexBar/ShareStatsModelActivityCardView.swift new file mode 100644 index 0000000000..072d1457d6 --- /dev/null +++ b/Sources/CodexBar/ShareStatsModelActivityCardView.swift @@ -0,0 +1,552 @@ +import AppKit +import SwiftUI + +struct ShareStatsModelActivityCardView: View { + static let size = CGSize(width: 1200, height: 630) + + let payload: ShareStatsPayload + + static func activityLevel(totalTokens: Int, maximum: Int) -> Int { + guard totalTokens > 0, maximum > 0 else { return 0 } + let scaled = Int(ceil(Double(totalTokens) / Double(maximum) * 5)) + return min(5, max(1, scaled)) + } + + var body: some View { + ZStack { + ShareStatsBrandBackground() + HStack(spacing: 0) { + ShareStatsStory(payload: self.payload) + .frame(width: 750) + .background(ShareStatsBrand.canvas.opacity(0.78)) + ShareStatsActivity(payload: self.payload) + .frame(width: 450) + .background(ShareStatsBrand.surface.opacity(0.82)) + .overlay(alignment: .leading) { + Rectangle() + .fill(Color.white.opacity(0.12)) + .frame(width: 1) + } + } + } + .frame(width: Self.size.width, height: Self.size.height) + .clipped() + .foregroundStyle(ShareStatsBrand.primary) + .environment(\.colorScheme, .dark) + } +} + +private struct ShareStatsStory: View { + let payload: ShareStatsPayload + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + self.header + self.headline + .padding(.top, 44) + self.tokenHero + .padding(.top, 26) + self.spend + .padding(.top, 29) + self.routes + .padding(.top, 32) + Spacer(minLength: 8) + Text("\(self.payload.days) DAYS · AGGREGATED LOCALLY · NO PROMPTS SHARED") + .font(ShareStatsBrand.mono(size: 12, weight: .semibold)) + .tracking(0.4) + .foregroundStyle(ShareStatsBrand.secondary) + } + .padding(.horizontal, 52) + .padding(.top, 38) + .padding(.bottom, 28) + } + + private var header: some View { + HStack(spacing: 10) { + Image(nsImage: ShareStatsBrand.appIcon) + .resizable() + .interpolation(.high) + .frame(width: 30, height: 30) + .clipShape(RoundedRectangle(cornerRadius: 7)) + .overlay { + RoundedRectangle(cornerRadius: 7) + .stroke(Color.white.opacity(0.16), lineWidth: 1) + } + Text("CodexBar") + .font(.system(size: 18, weight: .semibold)) + .tracking(-0.3) + } + } + + private var headline: some View { + HStack(spacing: 0) { + Text("You kept ") + Text("the models") + .foregroundStyle(ShareStatsBrand.aurora) + Text(" busy.") + } + .font(.system(size: 43, weight: .semibold)) + .tracking(-1.8) + } + + private var tokenHero: some View { + HStack(alignment: .lastTextBaseline, spacing: 17) { + Text(self.tokenHeadline) + .font(.system(size: 126, weight: .bold)) + .tracking(-7) + .monospacedDigit() + .lineLimit(1) + .minimumScaleFactor(0.72) + Text(self.payload.tokenCoverageIsComplete ? "TOKENS" : "KNOWN TOKENS") + .font(ShareStatsBrand.mono(size: 15, weight: .bold)) + .tracking(2) + .foregroundStyle(ShareStatsBrand.secondary) + .padding(.bottom, 11) + } + .frame(height: 106, alignment: .bottomLeading) + } + + private var tokenHeadline: String { + guard let totalTokens = self.payload.totalTokens else { return "—" } + let value = ShareStatsFormatting.compactCount(totalTokens) + return self.payload.tokenCoverageIsComplete ? value : "≥\(value)" + } + + private var spend: some View { + HStack(alignment: .firstTextBaseline, spacing: 12) { + Text(self.spendHeadline) + .font(.system(size: 32, weight: .bold)) + .tracking(-1.3) + .foregroundStyle(ShareStatsBrand.teal) + .lineLimit(1) + .minimumScaleFactor(0.72) + Text(self.spendDetail) + .font(.system(size: 14, weight: .regular)) + .foregroundStyle(ShareStatsBrand.secondary) + .lineLimit(1) + .minimumScaleFactor(0.8) + } + } + + private var spendHeadline: String { + let pricedCurrencies = self.payload.currencies.compactMap { currency -> String? in + guard let estimatedCost = currency.estimatedCost else { return nil } + let knownSpend = ShareStatsFormatting.currency(estimatedCost, code: currency.currencyCode) + let isPartial = currency.pricedSourceCount < currency.sourceCount + || currency.coveredDayCount < self.payload.days + return isPartial ? "≥\(knownSpend)" : knownSpend + } + guard !pricedCurrencies.isEmpty else { return "—" } + let shown = pricedCurrencies.prefix(2).joined(separator: " · ") + if pricedCurrencies.count > 2 { + return "\(shown) +\(pricedCurrencies.count - 2)" + } + return shown + } + + private var spendDetail: String { + guard self.pricedSourceCount > 0 else { + return "estimated token spend unavailable · \(self.payload.providers.count) sources tracked" + } + let coverage = self.payload.currencies.contains { + $0.estimatedCost != nil + && ($0.pricedSourceCount < $0.sourceCount || $0.coveredDayCount < self.payload.days) + } ? "known lower bound · " : "" + return "estimated token spend · \(coverage)pricing for \(self.pricedSourceCount) " + + "of \(self.payload.providers.count) sources" + } + + private var pricedSourceCount: Int { + self.payload.providers.count { $0.estimatedCost != nil } + } + + private var routes: some View { + VStack(alignment: .leading, spacing: 0) { + Rectangle() + .fill(ShareStatsBrand.rule) + .frame(height: 1) + HStack(alignment: .firstTextBaseline) { + Text("MODEL ROUTES") + Spacer() + Text(self.routeHeaderDetail) + } + .font(ShareStatsBrand.mono(size: 13, weight: .bold)) + .tracking(0.8) + .foregroundStyle(ShareStatsBrand.secondary) + .padding(.top, 16) + .padding(.bottom, 9) + + if self.payload.topModels.isEmpty { + Text("Model breakdown unavailable") + .font(.system(size: 20, weight: .semibold)) + .foregroundStyle(ShareStatsBrand.secondary) + .frame(height: 40) + } else { + ForEach( + Array(self.payload.topModels.prefix(3).enumerated()), + id: \.offset) + { _, model in + HStack { + Text(model.modelName) + .font(.system(size: 20, weight: .semibold)) + .tracking(-0.3) + Spacer() + Text("via \(model.sourceName)") + .font(ShareStatsBrand.mono(size: 15, weight: .semibold)) + .foregroundStyle(ShareStatsBrand.secondary) + } + .frame(height: 40) + .overlay(alignment: .bottom) { + Rectangle() + .fill(ShareStatsBrand.rule) + .frame(height: 1) + } + } + } + + HStack { + Text(self.routeOverflowDetail) + Spacer() + Text("\(self.payload.providers.count) sources tracked") + } + .font(ShareStatsBrand.mono(size: 12, weight: .semibold)) + .foregroundStyle(ShareStatsBrand.secondary) + .padding(.top, 9) + } + } + + private var routeHeaderDetail: String { + guard !self.payload.topModels.isEmpty else { return "UNAVAILABLE" } + return "\(min(3, self.payload.topModels.count)) OF \(self.payload.topModels.count) ROUTES" + } + + private var routeOverflowDetail: String { + var details: [String] = [] + let overflowCount = max(0, self.payload.topModels.count - 3) + if overflowCount > 0 { + details.append("+\(overflowCount) more route\(overflowCount == 1 ? "" : "s")") + } + let collapsedCount = max(0, self.payload.shareableModelRouteCount - self.payload.topModels.count) + if collapsedCount > 0 { + details.append("\(collapsedCount) grouped") + } + if self.payload.hiddenModelRouteCount > 0 { + details.append("\(self.payload.hiddenModelRouteCount) private") + } + if !self.payload.modelRouteCoverageIsComplete { + details.append("partial history") + } + return details.isEmpty ? "All safe routes shown" : details.joined(separator: " · ") + } +} + +private struct ShareStatsActivity: View { + let payload: ShareStatsPayload + + private var calendar: Calendar { + Calendar.current + } + + private var periodEnd: Date { + self.calendar.startOfDay(for: self.payload.periodEnd) + } + + private var periodStart: Date { + self.calendar.date(byAdding: .day, value: -(self.payload.days - 1), to: self.periodEnd) + ?? self.periodEnd + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + Text( + "\(ShareStatsFormatting.shortRange(from: self.periodStart, through: self.periodEnd))" + + " · \(self.payload.days)-DAY SNAPSHOT") + .font(ShareStatsBrand.mono(size: 13, weight: .bold)) + .tracking(1) + .foregroundStyle(ShareStatsBrand.secondary) + + HStack(alignment: .lastTextBaseline, spacing: 9) { + Text(self.activeDayHeadline) + .font(.system(size: 72, weight: .heavy)) + .tracking(-4.5) + .monospacedDigit() + .foregroundStyle(ShareStatsBrand.activeGradient) + if self.payload.dailySourceCount > 0 { + Text("of \(self.payload.days)") + .font(.system(size: 23, weight: .bold)) + .foregroundStyle(ShareStatsBrand.secondary) + .padding(.bottom, 7) + } + } + .frame(height: 72, alignment: .bottomLeading) + .padding(.top, 18) + + Text(self.payload.dailySourceCount == 0 ? "daily activity unavailable" : self.activeDayLabel) + .font(.system(size: 21, weight: .semibold)) + .padding(.top, 8) + + ShareStatsCalendar( + cells: self.calendarCells, + start: self.periodStart, + end: self.periodEnd, + isPartial: !self.payload.dailyCoverageIsComplete || self.payload.hasUnavailableDailyTotals) + .frame(maxWidth: .infinity) + .padding(.top, 24) + + Spacer(minLength: 10) + + HStack { + Text(self.payload.dailyCoverageIsComplete ? "DAILY TOKEN ACTIVITY" : "KNOWN DAILY ACTIVITY") + Spacer() + Text(self.sourceCoverage) + } + .font(ShareStatsBrand.mono(size: 12, weight: .bold)) + .tracking(0.35) + .foregroundStyle(ShareStatsBrand.secondary) + } + .padding(.horizontal, 38) + .padding(.top, 38) + .padding(.bottom, 29) + } + + private var activeDayCount: Int { + self.payload.dailyTokens.count { ($0.totalTokens ?? 0) > 0 } + } + + private var activeDayHeadline: String { + guard self.payload.dailySourceCount > 0 else { return "—" } + let isLowerBound = !self.payload.dailyCoverageIsComplete || self.payload.hasUnavailableDailyTotals + return isLowerBound ? "≥\(self.activeDayCount)" : "\(self.activeDayCount)" + } + + private var activeDayLabel: String { + !self.payload.dailyCoverageIsComplete || self.payload.hasUnavailableDailyTotals + ? "known active days" + : "days active" + } + + private var sourceCoverage: String { + guard self.payload.dailySourceCount > 0 else { return "NO SOURCE DATA" } + if self.payload.dailyCoverageIsComplete { + return "FULL · \(self.payload.dailySourceCount) OF \(self.payload.providers.count) SOURCES" + } + return "PARTIAL · \(self.payload.dailyFullSourceCount) OF \(self.payload.providers.count) FULL" + } + + private var calendarCells: [ShareStatsCalendarCell] { + var dailyTokens: [Date: ShareStatsDailyPayload] = [:] + for point in self.payload.dailyTokens { + dailyTokens[self.calendar.startOfDay(for: point.day)] = point + } + let maximum = dailyTokens.values.compactMap(\.totalTokens).max() ?? 0 + let leadingCount = max(0, self.calendar.component(.weekday, from: self.periodStart) - 1) + let requiredCount = leadingCount + self.payload.days + let totalCount = Int(ceil(Double(requiredCount) / 7.0)) * 7 + + return (0..= 0, + dayOffset < self.payload.days, + let day = self.calendar.date(byAdding: .day, value: dayOffset, to: self.periodStart) + else { + return ShareStatsCalendarCell(id: index, level: nil, isUnavailable: false) + } + let normalizedDay = self.calendar.startOfDay(for: day) + let tokens: Int? = if let recordedPoint = dailyTokens[normalizedDay] { + recordedPoint.totalTokens + } else { + self.payload.dailyCoverageIsComplete ? 0 : nil + } + return ShareStatsCalendarCell( + id: index, + level: tokens.map { ShareStatsModelActivityCardView.activityLevel(totalTokens: $0, maximum: maximum) }, + isUnavailable: tokens == nil) + } + } +} + +private struct ShareStatsCalendarCell: Identifiable { + let id: Int + let level: Int? + let isUnavailable: Bool +} + +private struct ShareStatsCalendar: View { + let cells: [ShareStatsCalendarCell] + let start: Date + let end: Date + let isPartial: Bool + + private var weeks: [[ShareStatsCalendarCell]] { + stride(from: 0, to: self.cells.count, by: 7).map { startIndex in + Array(self.cells[startIndex.. String { + switch index { + case 1: "Mon" + case 3: "Wed" + case 5: "Fri" + default: "" + } + } +} + +private struct ShareStatsBrandBackground: View { + var body: some View { + ZStack { + ShareStatsBrand.canvas + RadialGradient( + colors: [ShareStatsBrand.orange.opacity(0.42), .clear], + center: .topLeading, + startRadius: 0, + endRadius: 460) + RadialGradient( + colors: [ShareStatsBrand.violet.opacity(0.46), .clear], + center: .topTrailing, + startRadius: 0, + endRadius: 480) + RadialGradient( + colors: [ShareStatsBrand.teal.opacity(0.22), .clear], + center: .bottom, + startRadius: 0, + endRadius: 420) + RadialGradient( + colors: [ShareStatsBrand.pink.opacity(0.28), .clear], + center: .bottomTrailing, + startRadius: 0, + endRadius: 360) + } + } +} + +@MainActor +private enum ShareStatsBrand { + static let appIcon: NSImage = Bundle.module + .url(forResource: "Icon-classic", withExtension: "icns") + .flatMap(NSImage.init(contentsOf:)) + ?? NSApplication.shared.applicationIconImage + + static let canvas = Color(red: 10.0 / 255.0, green: 10.0 / 255.0, blue: 12.0 / 255.0) + static let surface = Color(red: 19.0 / 255.0, green: 20.0 / 255.0, blue: 24.0 / 255.0) + static let primary = Color(red: 240.0 / 255.0, green: 240.0 / 255.0, blue: 243.0 / 255.0) + static let secondary = Color(red: 182.0 / 255.0, green: 189.0 / 255.0, blue: 198.0 / 255.0) + static let orange = Color(red: 255.0 / 255.0, green: 122.0 / 255.0, blue: 26.0 / 255.0) + static let pink = Color(red: 255.0 / 255.0, green: 61.0 / 255.0, blue: 139.0 / 255.0) + static let violet = Color(red: 110.0 / 255.0, green: 90.0 / 255.0, blue: 255.0 / 255.0) + static let teal = Color(red: 22.0 / 255.0, green: 211.0 / 255.0, blue: 180.0 / 255.0) + static let rule = Color.white.opacity(0.18) + + static let aurora = LinearGradient( + colors: [ShareStatsBrand.orange, ShareStatsBrand.pink, ShareStatsBrand.violet, ShareStatsBrand.teal], + startPoint: .leading, + endPoint: .trailing) + + static let activeGradient = LinearGradient( + colors: [ShareStatsBrand.orange, ShareStatsBrand.pink], + startPoint: .topLeading, + endPoint: .bottomTrailing) + + static func mono(size: CGFloat, weight: Font.Weight) -> Font { + .system(size: size, weight: weight, design: .monospaced) + } + + static func activity(level: Int?) -> Color { + guard let level else { return .clear } + switch level { + case 1: return self.teal.opacity(0.18) + case 2: return self.teal.opacity(0.34) + case 3: return self.teal.opacity(0.52) + case 4: return self.teal.opacity(0.74) + case 5: return self.teal.opacity(0.98) + default: return Color.white.opacity(0.055) + } + } +} diff --git a/Sources/CodexBar/ShareStatsPayload.swift b/Sources/CodexBar/ShareStatsPayload.swift index 744f41ee17..6077ec1080 100644 --- a/Sources/CodexBar/ShareStatsPayload.swift +++ b/Sources/CodexBar/ShareStatsPayload.swift @@ -2,6 +2,7 @@ import CodexBarCore import Foundation struct ShareStatsProviderPayload: Sendable, Equatable { + let sourceID: String let provider: UsageProvider let providerName: String let subscriptionName: String? @@ -12,8 +13,10 @@ struct ShareStatsProviderPayload: Sendable, Equatable { } struct ShareStatsModelPayload: Sendable, Equatable { + let sourceID: String let provider: UsageProvider let providerName: String + let sourceName: String let modelName: String let currencyCode: String let totalTokens: Int? @@ -21,8 +24,10 @@ struct ShareStatsModelPayload: Sendable, Equatable { } private struct ShareStatsModelFamilyKey: Hashable { + let sourceID: String let provider: UsageProvider let providerName: String + let sourceName: String let modelName: String let currencyCode: String } @@ -72,8 +77,10 @@ private struct ShareStatsModelFamilyAccumulator { let estimatedCost = self.costIncomplete ? nil : self.estimatedCost guard totalTokens != nil || estimatedCost != nil else { return nil } return ShareStatsModelPayload( + sourceID: self.key.sourceID, provider: self.key.provider, providerName: self.key.providerName, + sourceName: self.key.sourceName, modelName: self.key.modelName, currencyCode: self.key.currencyCode, totalTokens: totalTokens, @@ -85,24 +92,53 @@ struct ShareStatsCurrencyPayload: Sendable, Equatable, Identifiable { let currencyCode: String let estimatedCost: Double? let coveredDayCount: Int + let pricedSourceCount: Int + let sourceCount: Int var id: String { self.currencyCode } } +struct ShareStatsDailyPayload: Sendable, Equatable, Identifiable { + let day: Date + let totalTokens: Int? + + var id: Date { + self.day + } +} + struct ShareStatsPayload: Sendable, Equatable { let days: Int let periodEnd: Date let providers: [ShareStatsProviderPayload] let topModels: [ShareStatsModelPayload] let currencies: [ShareStatsCurrencyPayload] + let dailyTokens: [ShareStatsDailyPayload] + let dailySourceCount: Int + let dailyFullSourceCount: Int + let modelRouteCount: Int + let shareableModelRouteCount: Int + let hiddenModelRouteCount: Int + let modelRouteCoverageIsComplete: Bool let totalTokens: Int? + let tokenSourceCount: Int + + var dailyCoverageIsComplete: Bool { + !self.providers.isEmpty && self.dailyFullSourceCount == self.providers.count + } + + var tokenCoverageIsComplete: Bool { + self.totalTokens != nil && self.tokenSourceCount == self.providers.count + } + + var hasUnavailableDailyTotals: Bool { + self.dailyTokens.contains { $0.totalTokens == nil } + } var hasShareableData: Bool { - !self.providers.isEmpty && self.providers.contains { provider in - provider.totalTokens != nil || provider.estimatedCost != nil - } + !self.providers.isEmpty && self.providers.contains { $0.totalTokens != nil } } } @@ -234,6 +270,11 @@ struct ShareStatsSubscriptionName: Sendable, Equatable { enum ShareStatsSanitizer { static func modelName(_ rawValue: String) -> String? { + if rawValue.trimmingCharacters(in: .whitespacesAndNewlines) + .localizedCaseInsensitiveCompare("Fable") == .orderedSame + { + return "Fable" + } guard let value = self.safeLabel( rawValue, maximumLength: 72, @@ -241,44 +282,157 @@ enum ShareStatsSanitizer { requireModelShape: true) else { return nil } - let normalized = value.lowercased() + var normalized = value.lowercased() + let pathParts = normalized.split(separator: "/", omittingEmptySubsequences: false) + if pathParts.count == 2 { + let publicPublishers: Set = [ + "alibaba", "amazon", "anthropic", "cohere", "deepseek", "fable", "google", + "meta-llama", "microsoft", "minimax", "mistralai", "moonshotai", "openai", + "perplexity", "qwen", "x-ai", "z-ai", + ] + guard publicPublishers.contains(String(pathParts[0])), !pathParts[1].isEmpty else { return nil } + normalized = String(pathParts[1]) + } else if pathParts.count > 1 { + return nil + } let regionalPrefixes = ["us.", "eu.", "apac.", "global."] let familyName = regionalPrefixes.first { normalized.hasPrefix($0) }.map { String(normalized.dropFirst($0.count)) } ?? normalized - let publicModelFamilies: [(prefixes: [String], label: String)] = [ - (["amazon.nova-", "nova-"], "Amazon Nova"), - (["anthropic.claude-", "claude-", "claude "], "Claude"), - (["chatgpt-", "gpt-"], "GPT"), - (["codex-"], "Codex"), - (["command-"], "Command"), - (["dall-e-"], "DALL-E"), - (["deepseek-"], "DeepSeek"), - (["codestral-", "devstral-", "magistral-", "mistral-", "mistral ", "mistral.", "mixtral-"], "Mistral"), - (["gemma-"], "Gemma"), - (["google.gemini-", "gemini-", "gemini "], "Gemini"), - (["glm-"], "GLM"), - (["grok-"], "Grok"), - (["kimi-", "moonshot-"], "Kimi"), - (["meta.llama", "llama-", "llama "], "Llama"), - (["minimax-"], "MiniMax"), - (["o1"], "o1"), - (["o3"], "o3"), - (["o4"], "o4"), - (["phi-"], "Phi"), - (["qwen"], "Qwen"), - (["sonar-"], "Sonar"), - (["text-embedding-"], "OpenAI Embeddings"), - (["tts-"], "OpenAI TTS"), - (["whisper-"], "Whisper"), + guard !familyName.contains("://"), !familyName.contains("\\"), !familyName.contains("/") else { return nil } + + if let suffix = self.suffix(in: familyName, after: ["chatgpt-", "gpt-"]), + let canonical = self.canonicalSuffix( + suffix, + allowedStarts: ["1", "2", "3", "4", "5", "6", "7", "8", "9"]) + { + return "GPT-\(self.prettySuffix(canonical))" + } + if let suffix = self.suffix(in: familyName, after: ["anthropic.claude-", "claude-", "claude "]), + let canonical = self.canonicalSuffix( + suffix, + allowedStarts: ["opus", "sonnet", "haiku", "instant", "1", "2", "3", "4", "5", "6", "7", "8", "9"]) + { + return "Claude \(self.prettySuffix(canonical))" + } + + let families: [(prefixes: [String], label: String, allowedStarts: [String])] = [ + ( + ["amazon.nova-", "nova-"], + "Amazon Nova", + ["lite", "micro", "pro", "premier", "canvas", "reel", "sonic", "1", "2", "3", "4", "5"]), + (["codex-"], "Codex", ["mini", "max", "1", "2", "3", "4", "5", "6", "7", "8", "9"]), + (["command-"], "Command", ["a", "r", "light", "nightly"]), + (["dall-e-"], "DALL-E", ["2", "3"]), + (["deepseek-"], "DeepSeek", ["r", "v", "chat", "coder"]), + (["fable-"], "Fable", ["1", "2", "3", "4", "5", "6", "7", "8", "9"]), + (["gemma-"], "Gemma", ["1", "2", "3", "4", "5", "6", "7", "8", "9"]), + (["google.gemini-", "gemini-", "gemini "], "Gemini", ["1", "2", "3", "4", "5", "6", "7", "8", "9"]), + (["glm-"], "GLM", ["1", "2", "3", "4", "5", "6", "7", "8", "9"]), + (["grok-"], "Grok", ["1", "2", "3", "4", "5", "6", "7", "8", "9"]), + (["kimi-", "moonshot-"], "Kimi", ["k", "1", "2", "3", "4", "5", "6", "7", "8", "9"]), + (["meta.llama", "llama-", "llama "], "Llama", ["1", "2", "3", "4", "5", "6", "7", "8", "9"]), + (["minimax-"], "MiniMax", ["m", "text", "speech", "video", "image", "1", "2", "3"]), + (["phi-"], "Phi", ["1", "2", "3", "4", "5", "6", "7", "8", "9"]), + (["qwen"], "Qwen", ["1", "2", "3", "4", "5", "6", "7", "8", "9"]), + (["sonar-"], "Sonar", ["small", "medium", "large", "pro", "reasoning", "deep"]), ] - guard !normalized.contains("://"), - !normalized.contains("/"), - !normalized.contains("\\") + for family in families { + guard let suffix = self.suffix(in: familyName, after: family.prefixes), + let canonical = self.canonicalSuffix(suffix, allowedStarts: family.allowedStarts) + else { continue } + return "\(family.label) \(self.prettySuffix(canonical))" + } + + for base in ["o1", "o3", "o4"] where familyName.hasPrefix(base) { + let tail = String(familyName.dropFirst(base.count)) + guard tail.isEmpty else { + guard tail.hasPrefix("-"), + let canonical = self.canonicalSuffix( + String(tail.dropFirst()), + allowedStarts: ["mini", "pro", "preview"]) + else { return base.uppercased() } + return "\(base.uppercased()) \(self.prettySuffix(canonical))" + } + return base.uppercased() + } + if let suffix = self.suffix(in: familyName, after: [ + "codestral-", "devstral-", "magistral-", "mistral-", "mistral ", "mistral.", "mixtral-", + ]), let canonical = self.canonicalSuffix( + suffix, + allowedStarts: ["small", "medium", "large", "1", "2", "3", "4", "5", "6", "7", "8", "9"]) + { + return "Mistral \(self.prettySuffix(canonical))" + } + if let suffix = self.suffix(in: familyName, after: ["text-embedding-"]), + let canonical = self.canonicalSuffix( + suffix, + allowedStarts: ["1", "2", "3", "4", "5", "6", "7", "8", "9"]) + { + return "OpenAI Embeddings \(self.prettySuffix(canonical))" + } + if let suffix = self.suffix(in: familyName, after: ["tts-", "whisper-"]), + let canonical = self.canonicalSuffix( + suffix, + allowedStarts: ["1", "2", "3", "4", "5", "6", "7", "8", "9", "large", "turbo"]) + { + return familyName.hasPrefix("tts-") + ? "OpenAI TTS \(self.prettySuffix(canonical))" + : "Whisper \(self.prettySuffix(canonical))" + } + return nil + } + + private static func suffix(in value: String, after prefixes: [String]) -> String? { + guard let prefix = prefixes.first(where: value.hasPrefix) else { return nil } + let suffix = String(value.dropFirst(prefix.count)) + return suffix.isEmpty ? nil : suffix + } + + private static func canonicalSuffix(_ value: String, allowedStarts: [String]) -> String? { + let parts = value + .replacingOccurrences(of: "_", with: "-") + .split(separator: "-") + .map(String.init) + guard let first = parts.first, + allowedStarts.contains(where: { self.isAllowedPublicStart(first, allowedStart: $0) }) else { return nil } - return publicModelFamilies.first { family in - family.prefixes.contains(where: familyName.hasPrefix) - }?.label + + let publicQualifiers: Set = [ + "a", "air", "canvas", "chat", "coder", "deep", "flash", "image", "instruct", + "large", "lite", "max", "medium", "micro", "mini", "nano", "nightly", "plus", + "premier", "preview", "pro", "r", "reasoning", "reel", "small", "sonic", "speech", + "text", "thinking", "turbo", "video", + ] + var canonical = [first] + for part in parts.dropFirst().prefix(4) { + let isVersion = part.range( + of: #"^(?:[vkr]?\d+(?:\.\d+)*(?::\d+)?|\d{8})$"#, + options: .regularExpression) != nil + guard isVersion || publicQualifiers.contains(part) else { break } + canonical.append(part) + } + return canonical.joined(separator: "-") + } + + private static func isAllowedPublicStart(_ value: String, allowedStart: String) -> Bool { + guard value.hasPrefix(allowedStart) else { return false } + let suffix = String(value.dropFirst(allowedStart.count)) + guard !suffix.isEmpty else { return true } + return suffix.range( + of: #"^(?:\d+(?:\.\d+)*|\.\d+(?:\.\d+)*)(?::\d+)?$"#, + options: .regularExpression) != nil + } + + private static func prettySuffix(_ value: String) -> String { + value + .replacingOccurrences(of: "_", with: "-") + .split(separator: "-") + .map { part in + let value = String(part) + return value.count <= 3 ? value.uppercased() : value.capitalized + } + .joined(separator: " ") } private static func safeLabel( @@ -317,11 +471,14 @@ enum ShareStatsBuilder { model: SpendDashboardModel, subscriptionNames: [String: ShareStatsSubscriptionName] = [:]) -> ShareStatsPayload? { + let sourceLabels = self.publicSourceLabels(model: model) let providers = model.groups.flatMap { group in group.providers.map { row in ShareStatsProviderPayload( + sourceID: row.id, provider: row.provider, - providerName: row.displayName, + providerName: sourceLabels[row.id] + ?? ProviderDescriptorRegistry.descriptor(for: row.provider).metadata.displayName, subscriptionName: subscriptionNames[row.id]?.displayName, currencyCode: group.currencyCode, totalTokens: row.totalTokens, @@ -329,28 +486,32 @@ enum ShareStatsBuilder { coveredDayCount: row.coveredDayCount) } } - let sanitizedModels = model.groups.filter { - $0.modelHistoryCompleteness == .complete - }.flatMap { group in - group.models.compactMap { row -> ShareStatsModelPayload? in - let estimatedCost = self.finiteCost(row.totalCost) - guard let modelName = ShareStatsSanitizer.modelName(row.modelName), - row.totalTokens != nil - else { return nil } - return ShareStatsModelPayload( - provider: row.provider, - providerName: row.providerName, - modelName: modelName, - currencyCode: group.currencyCode, - totalTokens: row.totalTokens, - estimatedCost: estimatedCost) - } + let observedModels = model.groups.flatMap { group in + group.tokenModels.map { (currencyCode: group.currencyCode, row: $0) } + } + let sanitizedModels = observedModels.compactMap { entry -> ShareStatsModelPayload? in + let row = entry.row + let estimatedCost = self.finiteCost(row.totalCost) + guard let modelName = ShareStatsSanitizer.modelName(row.modelName), + row.totalTokens != nil + else { return nil } + return ShareStatsModelPayload( + sourceID: row.sourceID, + provider: row.provider, + providerName: row.providerName, + sourceName: sourceLabels[row.sourceID] ?? row.providerName, + modelName: modelName, + currencyCode: entry.currencyCode, + totalTokens: row.totalTokens, + estimatedCost: estimatedCost) } var modelFamilies: [ShareStatsModelFamilyKey: ShareStatsModelFamilyAccumulator] = [:] for row in sanitizedModels { let key = ShareStatsModelFamilyKey( + sourceID: row.sourceID, provider: row.provider, providerName: row.providerName, + sourceName: row.sourceName, modelName: row.modelName, currencyCode: row.currencyCode) if var existing = modelFamilies[key] { @@ -366,35 +527,93 @@ enum ShareStatsBuilder { case (_?, nil): return true case (nil, _?): return false default: - if lhs.providerName != rhs.providerName { - return lhs.providerName < rhs.providerName + if lhs.sourceName != rhs.sourceName { + return lhs.sourceName < rhs.sourceName } return lhs.modelName < rhs.modelName } } - let currencies = model.groups.map { - ShareStatsCurrencyPayload( - currencyCode: $0.currencyCode, - estimatedCost: self.finiteCost($0.totalCost), - coveredDayCount: $0.coveredDayCount) + let currencies = model.groups.map { group in + let knownCosts = group.providers.compactMap { self.finiteCost($0.totalCost) } + return ShareStatsCurrencyPayload( + currencyCode: group.currencyCode, + estimatedCost: knownCosts.isEmpty ? nil : self.combinedKnownCost(knownCosts), + coveredDayCount: group.coveredDayCount, + pricedSourceCount: knownCosts.count, + sourceCount: group.providers.count) + } + let dailyPoints = model.groups.flatMap(\.dailyTokenPoints) + let dailyPointsByDay = Dictionary(grouping: dailyPoints, by: \.day) + let dailyTokens = dailyPointsByDay + .keys + .sorted() + .map { day -> ShareStatsDailyPayload in + let points = dailyPointsByDay[day] ?? [] + let total = self.combinedTotalTokens(points.map { Optional($0.tokens) }) + return ShareStatsDailyPayload(day: day, totalTokens: total) + } + let dailySourceCount = providers.count { $0.totalTokens != nil } + let dailyFullSourceCount = providers.count { + $0.totalTokens != nil && $0.coveredDayCount >= model.requestedDays } - let totalTokens = self.combinedTotalTokens(model.groups.map(\.totalTokens)) - let periodEnd = model.groups.map(\.chartDomain.upperBound).max() ?? Date() + let knownTokenTotals = providers.compactMap(\.totalTokens) + let totalTokens = knownTokenTotals.isEmpty ? nil : self.combinedTotalTokens(knownTokenTotals.map(Optional.some)) + let periodEnd = model.groups.map { group in + let bounds = group.chartDomain + return bounds.lowerBound < bounds.upperBound + ? Calendar.current.date(byAdding: .day, value: -1, to: bounds.upperBound) + ?? bounds.upperBound + : bounds.upperBound + }.max() + ?? Date() let payload = ShareStatsPayload( days: model.requestedDays, periodEnd: periodEnd, providers: providers, topModels: topModels, currencies: currencies, - totalTokens: totalTokens) + dailyTokens: dailyTokens, + dailySourceCount: dailySourceCount, + dailyFullSourceCount: dailyFullSourceCount, + modelRouteCount: observedModels.count, + shareableModelRouteCount: sanitizedModels.count, + hiddenModelRouteCount: observedModels.count - sanitizedModels.count, + modelRouteCoverageIsComplete: model.groups.allSatisfy { + $0.modelTokenHistoryCompleteness == .complete + }, + totalTokens: totalTokens, + tokenSourceCount: knownTokenTotals.count) return payload.hasShareableData ? payload : nil } + private static func publicSourceLabels(model: SpendDashboardModel) -> [String: String] { + let rows = model.groups.flatMap(\.providers) + let rowsByProvider = Dictionary(grouping: rows, by: \.provider) + var labels: [String: String] = [:] + for (provider, providerRows) in rowsByProvider { + let baseName = ProviderDescriptorRegistry.descriptor(for: provider).metadata.displayName + let sortedRows = providerRows.sorted { $0.id < $1.id } + for (index, row) in sortedRows.enumerated() { + labels[row.id] = sortedRows.count == 1 ? baseName : "\(baseName) #\(index + 1)" + } + } + return labels + } + private static func finiteCost(_ value: Double?) -> Double? { guard let value, value.isFinite, value >= 0 else { return nil } return value } + private static func combinedKnownCost(_ values: [Double]) -> Double? { + var total = 0.0 + for value in values { + total += value + guard total.isFinite else { return nil } + } + return total + } + static func combinedTotalTokens(_ values: [Int?]) -> Int? { var total = 0 for value in values { @@ -436,14 +655,44 @@ enum ShareStatsFormatting { return formatter.string(from: date) } - static func text(_ payload: ShareStatsPayload) -> String { + static func shortDay(_ date: Date, calendar: Calendar = .current) -> String { + let formatter = DateFormatter() + formatter.calendar = calendar + formatter.timeZone = calendar.timeZone + formatter.locale = .current + formatter.setLocalizedDateFormatFromTemplate("MMM d") + return formatter.string(from: date) + } + + static func shortRange(from start: Date, through end: Date, calendar: Calendar = .current) -> String { + "\(self.shortDay(start, calendar: calendar)) — \(self.shortDay(end, calendar: calendar))" + } + + static func text( + _ payload: ShareStatsPayload, + style: ShareStatsCardStyle = .defaultStyle) -> String + { + switch style { + case .summary: + self.summaryText(payload) + case .modelActivity: + self.modelActivityText(payload) + } + } + + private static func summaryText(_ payload: ShareStatsPayload) -> String { var lines = ["My AI subscriptions · last \(payload.days) days"] if let tokens = payload.totalTokens { - lines.append("\(self.compactCount(tokens)) tracked tokens") + let qualifier = payload.tokenCoverageIsComplete ? "" : "at least " + lines.append("\(qualifier)\(self.compactCount(tokens)) tracked tokens") } lines.append(contentsOf: payload.currencies.map { currency in - let spend = currency.estimatedCost.map { "\(self.currency($0, code: currency.currencyCode)) estimated" } - ?? "Spend unavailable" + let spend = currency.estimatedCost.map { cost in + let value = self.currency(cost, code: currency.currencyCode) + let isPartial = currency.pricedSourceCount < currency.sourceCount + || currency.coveredDayCount < payload.days + return isPartial ? "at least \(value) estimated" : "\(value) estimated" + } ?? "Spend unavailable" return "\(currency.currencyCode): \(spend) · " + "coverage \(currency.coveredDayCount)/\(payload.days) days" }) @@ -473,10 +722,58 @@ enum ShareStatsFormatting { if let cost = model.estimatedCost { metrics.append("~\(self.currency(cost, code: model.currencyCode)) est") } - return "\(model.modelName) (\(model.providerName)): \(metrics.joined(separator: " · "))" + return "\(model.modelName) (\(model.sourceName)): \(metrics.joined(separator: " · "))" }) } lines.append("Generated locally by CodexBar · Data through \(self.dataThrough(payload.periodEnd))") return lines.joined(separator: "\n") } + + private static func modelActivityText(_ payload: ShareStatsPayload) -> String { + var lines = ["You kept the models busy · last \(payload.days) days"] + if let tokens = payload.totalTokens { + let qualifier = payload.tokenCoverageIsComplete ? "" : "at least " + lines.append("\(qualifier)\(self.compactCount(tokens)) tracked tokens") + } + if !payload.dailyTokens.isEmpty { + let activeDays = payload.dailyTokens.count { ($0.totalTokens ?? 0) > 0 } + let qualifier = payload.dailyCoverageIsComplete && !payload.hasUnavailableDailyTotals ? "" : "at least " + lines.append("\(qualifier)\(activeDays) of \(payload.days) days active") + } + let pricedCurrencies = payload.currencies.compactMap { currency in + currency.estimatedCost.map { cost in + let value = self.currency(cost, code: currency.currencyCode) + let isPartial = currency.pricedSourceCount < currency.sourceCount + || currency.coveredDayCount < payload.days + return isPartial ? "≥\(value)" : value + } + } + let pricedSourceCount = payload.providers.count { $0.estimatedCost != nil } + if !pricedCurrencies.isEmpty { + lines.append( + "Estimated token spend: \(pricedCurrencies.joined(separator: " · "))" + + " · pricing for \(pricedSourceCount) of \(payload.providers.count) sources") + } + if !payload.topModels.isEmpty { + lines.append("Top model routes:") + lines.append(contentsOf: payload.topModels.prefix(3).map { model in + "\(model.modelName) via \(model.sourceName)" + }) + let overflowCount = payload.topModels.count - min(3, payload.topModels.count) + if overflowCount > 0 { + lines.append("+\(overflowCount) more safe route summaries") + } + if payload.hiddenModelRouteCount > 0 { + lines.append("\(payload.hiddenModelRouteCount) private route names omitted") + } + if !payload.modelRouteCoverageIsComplete { + lines.append("Model route history is partial") + } + } + lines.append("\(payload.providers.count) sources tracked") + lines.append( + "Aggregated locally by CodexBar · No prompts shared · " + + "Data through \(self.dataThrough(payload.periodEnd))") + return lines.joined(separator: "\n") + } } diff --git a/Sources/CodexBar/ShareStatsRenderer.swift b/Sources/CodexBar/ShareStatsRenderer.swift index 7d440c8bea..f2a4c495f7 100644 --- a/Sources/CodexBar/ShareStatsRenderer.swift +++ b/Sources/CodexBar/ShareStatsRenderer.swift @@ -1,18 +1,34 @@ import AppKit import SwiftUI +enum ShareStatsCardStyle: String, CaseIterable, Identifiable, Sendable { + case summary + case modelActivity + + static let defaultStyle: Self = .summary + + var id: Self { + self + } +} + @MainActor enum ShareStatsRenderer { - static func pngData(for payload: ShareStatsPayload) -> Data? { - let size = ShareStatsCardView.size - let view = NSHostingView(rootView: ShareStatsCardView(payload: payload)) - view.frame = CGRect(origin: .zero, size: size) + static func pngData( + for payload: ShareStatsPayload, + style: ShareStatsCardStyle = .defaultStyle, + pixelSize: CGSize = ShareStatsCardView.size) -> Data? + { + let logicalSize = ShareStatsCardView.size + guard pixelSize.width > 0, pixelSize.height > 0 else { return nil } + let view = NSHostingView(rootView: self.card(payload: payload, style: style)) + view.frame = CGRect(origin: .zero, size: logicalSize) view.layoutSubtreeIfNeeded() guard let representation = NSBitmapImageRep( bitmapDataPlanes: nil, - pixelsWide: Int(size.width), - pixelsHigh: Int(size.height), + pixelsWide: Int(pixelSize.width.rounded()), + pixelsHigh: Int(pixelSize.height.rounded()), bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, @@ -21,22 +37,37 @@ enum ShareStatsRenderer { bytesPerRow: 0, bitsPerPixel: 0) else { return nil } - representation.size = size + representation.size = logicalSize guard let context = NSGraphicsContext(bitmapImageRep: representation) else { return nil } view.displayIgnoringOpacity(view.bounds, in: context) return representation.representation(using: .png, properties: [:]) } - static func image(for payload: ShareStatsPayload) -> NSImage? { - guard let data = self.pngData(for: payload) else { return nil } + static func image( + for payload: ShareStatsPayload, + style: ShareStatsCardStyle = .defaultStyle) -> NSImage? + { + guard let data = self.pngData(for: payload, style: style) else { return nil } return NSImage(data: data) } + + private static func card(payload: ShareStatsPayload, style: ShareStatsCardStyle) -> AnyView { + switch style { + case .summary: + AnyView(ShareStatsCardView(payload: payload)) + case .modelActivity: + AnyView(ShareStatsModelActivityCardView(payload: payload)) + } + } } @MainActor enum ShareStatsExporter { - static func copyImage(_ payload: ShareStatsPayload) -> Bool { - guard let data = ShareStatsRenderer.pngData(for: payload), + static func copyImage( + _ payload: ShareStatsPayload, + style: ShareStatsCardStyle = .defaultStyle) -> Bool + { + guard let data = ShareStatsRenderer.pngData(for: payload, style: style), let image = NSImage(data: data) else { return false } let pasteboard = NSPasteboard.general let item = NSPasteboardItem() @@ -48,12 +79,18 @@ enum ShareStatsExporter { return pasteboard.writeObjects([item]) } - static func copyText(_ payload: ShareStatsPayload) { - MenuPasteboardCopy.perform(ShareStatsFormatting.text(payload)) + static func copyText( + _ payload: ShareStatsPayload, + style: ShareStatsCardStyle = .defaultStyle) + { + MenuPasteboardCopy.perform(ShareStatsFormatting.text(payload, style: style)) } - static func saveImage(_ payload: ShareStatsPayload) -> Bool { - guard let data = ShareStatsRenderer.pngData(for: payload) else { return false } + static func saveImage( + _ payload: ShareStatsPayload, + style: ShareStatsCardStyle = .defaultStyle) -> Bool + { + guard let data = ShareStatsRenderer.pngData(for: payload, style: style) else { return false } let panel = NSSavePanel() panel.allowedContentTypes = [.png] panel.canCreateDirectories = true @@ -70,6 +107,6 @@ enum ShareStatsExporter { } private static func defaultFilename(_ payload: ShareStatsPayload) -> String { - "codexbar-subscriptions-last-\(payload.days)-days.png" + "codexbar-usage-last-\(payload.days)-days.png" } } diff --git a/Sources/CodexBar/ShareStatsWindowController.swift b/Sources/CodexBar/ShareStatsWindowController.swift index 7a4ffe3e0f..d0eb7f5586 100644 --- a/Sources/CodexBar/ShareStatsWindowController.swift +++ b/Sources/CodexBar/ShareStatsWindowController.swift @@ -22,7 +22,7 @@ final class ShareStatsWindowController: NSWindowController, NSWindowDelegate { init(payload: ShareStatsPayload) { self.payload = payload let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 820, height: 565), + contentRect: NSRect(x: 0, y: 0, width: 820, height: 610), styleMask: [.titled, .closable, .miniaturizable], backing: .buffered, defer: false) @@ -53,32 +53,41 @@ final class ShareStatsWindowController: NSWindowController, NSWindowDelegate { private func installContent() { self.window?.contentViewController = NSHostingController(rootView: ShareStatsPreviewView( payload: self.payload, - copyImage: { [weak self] in + copyImage: { [weak self] style in guard let self else { return false } - return ShareStatsExporter.copyImage(self.payload) + return ShareStatsExporter.copyImage(self.payload, style: style) }, - copyText: { [weak self] in + copyText: { [weak self] style in guard let self else { return } - ShareStatsExporter.copyText(self.payload) + ShareStatsExporter.copyText(self.payload, style: style) }, - saveImage: { [weak self] in + saveImage: { [weak self] style in guard let self else { return false } - return ShareStatsExporter.saveImage(self.payload) + return ShareStatsExporter.saveImage(self.payload, style: style) })) } } private struct ShareStatsPreviewView: View { let payload: ShareStatsPayload - let copyImage: @MainActor () -> Bool - let copyText: @MainActor () -> Void - let saveImage: @MainActor () -> Bool + let copyImage: @MainActor (ShareStatsCardStyle) -> Bool + let copyText: @MainActor (ShareStatsCardStyle) -> Void + let saveImage: @MainActor (ShareStatsCardStyle) -> Bool + @State private var style: ShareStatsCardStyle = .defaultStyle @State private var statusMessage: String? var body: some View { - VStack(spacing: 20) { - ShareStatsScaledPreview(payload: self.payload) + VStack(spacing: 16) { + Picker(L("Share card style"), selection: self.$style) { + Text(L("Summary")).tag(ShareStatsCardStyle.summary) + Text(L("Model activity")).tag(ShareStatsCardStyle.modelActivity) + } + .pickerStyle(.segmented) + .frame(width: 280) + .accessibilityLabel(L("Share card style")) + + ShareStatsScaledPreview(payload: self.payload, style: self.style) .clipShape(RoundedRectangle(cornerRadius: 12)) .overlay { RoundedRectangle(cornerRadius: 12) @@ -88,21 +97,21 @@ private struct ShareStatsPreviewView: View { HStack(spacing: 12) { Button { - self.statusMessage = self.copyImage() ? L("Image copied") : L("Could not copy image") + self.statusMessage = self.copyImage(self.style) ? L("Image copied") : L("Could not copy image") } label: { Label(L("Copy Image"), systemImage: "photo.on.rectangle") } .keyboardShortcut(.defaultAction) Button { - self.copyText() + self.copyText(self.style) self.statusMessage = L("Stats copied") } label: { Label(L("Copy Stats"), systemImage: "doc.on.doc") } Button { - if self.saveImage() { + if self.saveImage(self.style) { self.statusMessage = L("Image saved") } } label: { @@ -119,20 +128,28 @@ private struct ShareStatsPreviewView: View { } } .padding(24) - .frame(minWidth: 780, minHeight: 525) + .frame(minWidth: 780, minHeight: 570) } } private struct ShareStatsScaledPreview: View { let payload: ShareStatsPayload + let style: ShareStatsCardStyle var body: some View { GeometryReader { proxy in let scale = min( proxy.size.width / ShareStatsCardView.size.width, proxy.size.height / ShareStatsCardView.size.height) - ShareStatsCardView(payload: self.payload) - .scaleEffect(scale, anchor: .topLeading) + Group { + switch self.style { + case .summary: + ShareStatsCardView(payload: self.payload) + case .modelActivity: + ShareStatsModelActivityCardView(payload: self.payload) + } + } + .scaleEffect(scale, anchor: .topLeading) } .aspectRatio(ShareStatsCardView.size.width / ShareStatsCardView.size.height, contentMode: .fit) } diff --git a/Sources/CodexBar/SpendDashboardModel.swift b/Sources/CodexBar/SpendDashboardModel.swift index c3ed207f96..7fcf29b1b1 100644 --- a/Sources/CodexBar/SpendDashboardModel.swift +++ b/Sources/CodexBar/SpendDashboardModel.swift @@ -35,15 +35,42 @@ struct SpendDashboardModel: Equatable, Sendable { } struct ModelRow: Identifiable, Equatable, Sendable { + struct ID: Hashable, Sendable { + let sourceID: String + let modelName: String + } + + let sourceID: String let rank: Int let provider: UsageProvider let providerName: String + let sourceName: String let modelName: String let totalTokens: Int? let totalCost: Double? - var id: String { - "\(self.provider.rawValue):\(self.modelName)" + init( + sourceID: String? = nil, + rank: Int, + provider: UsageProvider, + providerName: String, + sourceName: String? = nil, + modelName: String, + totalTokens: Int?, + totalCost: Double?) + { + self.sourceID = sourceID ?? provider.rawValue + self.rank = rank + self.provider = provider + self.providerName = providerName + self.sourceName = sourceName ?? providerName + self.modelName = modelName + self.totalTokens = totalTokens + self.totalCost = totalCost + } + + var id: ID { + ID(sourceID: self.sourceID, modelName: self.modelName) } } @@ -61,6 +88,18 @@ struct SpendDashboardModel: Equatable, Sendable { } } + struct DailyTokenPoint: Identifiable, Equatable, Sendable { + let sourceID: String + let provider: UsageProvider + let providerName: String + let day: Date + let tokens: Int + + var id: String { + "\(self.sourceID):\(Int(self.day.timeIntervalSince1970))" + } + } + enum ModelHistoryCompleteness: Equatable, Sendable { case complete case incomplete @@ -70,12 +109,43 @@ struct SpendDashboardModel: Equatable, Sendable { let currencyCode: String let providers: [ProviderRow] let models: [ModelRow] + let tokenModels: [ModelRow] let dailyPoints: [DailyPoint] + let dailyTokenPoints: [DailyTokenPoint] let totalTokens: Int? let totalCost: Double? let coveredDayCount: Int let chartDomain: ClosedRange let modelHistoryCompleteness: ModelHistoryCompleteness + let modelTokenHistoryCompleteness: ModelHistoryCompleteness + + init( + currencyCode: String, + providers: [ProviderRow], + models: [ModelRow], + tokenModels: [ModelRow]? = nil, + dailyPoints: [DailyPoint], + dailyTokenPoints: [DailyTokenPoint], + totalTokens: Int?, + totalCost: Double?, + coveredDayCount: Int, + chartDomain: ClosedRange, + modelHistoryCompleteness: ModelHistoryCompleteness, + modelTokenHistoryCompleteness: ModelHistoryCompleteness? = nil) + { + self.currencyCode = currencyCode + self.providers = providers + self.models = models + self.tokenModels = tokenModels ?? models + self.dailyPoints = dailyPoints + self.dailyTokenPoints = dailyTokenPoints + self.totalTokens = totalTokens + self.totalCost = totalCost + self.coveredDayCount = coveredDayCount + self.chartDomain = chartDomain + self.modelHistoryCompleteness = modelHistoryCompleteness + self.modelTokenHistoryCompleteness = modelTokenHistoryCompleteness ?? modelHistoryCompleteness + } var id: String { self.currencyCode @@ -126,12 +196,14 @@ struct SpendDashboardModel: Equatable, Sendable { } private struct ModelKey: Hashable { - let provider: UsageProvider + let sourceID: String let modelName: String } private struct ModelAccumulator { + let provider: UsageProvider let providerName: String + let sourceName: String var tokens: Int? var cost: Double? var sawTokens = false @@ -160,6 +232,14 @@ struct SpendDashboardModel: Equatable, Sendable { var overflowed = false } + private struct DailyTokenAccumulator { + let provider: UsageProvider + let providerName: String + var tokens: Int? + var invalid = false + var overflowed = false + } + private static func buildCurrencyGroup( currencyCode: String, inputs: [ProviderInput], @@ -172,25 +252,30 @@ struct SpendDashboardModel: Equatable, Sendable { Self.inputSummary(input: input, bounds: bounds, calendar: calendar) } let providers = Self.providerRows(summaries) - let completeModelSummaries = summaries.filter { summary in + let completeCostModelSummaries = summaries.filter { summary in guard summary.totalCost != nil else { return false } - return Self.modelSummary(summaries: [summary]).completeness == .complete + return Self.costModelSummary(summaries: [summary]).completeness == .complete } - let modelSummary = Self.modelSummary(summaries: completeModelSummaries) - let modelHistoryCompleteness = completeModelSummaries.count == summaries.count + let costModelSummary = Self.costModelSummary(summaries: completeCostModelSummaries) + let modelHistoryCompleteness = completeCostModelSummaries.count == summaries.count ? ModelHistoryCompleteness.complete : ModelHistoryCompleteness.incomplete + let tokenModelSummary = Self.tokenModelSummary(summaries: summaries) let dailyPoints = Self.dailyPoints(summaries: summaries) + let dailyTokenPoints = Self.dailyTokenPoints(summaries: summaries) return CurrencyGroup( currencyCode: currencyCode, providers: providers, - models: modelSummary.rows, + models: costModelSummary.rows, + tokenModels: tokenModelSummary.rows, dailyPoints: dailyPoints, + dailyTokenPoints: dailyTokenPoints, totalTokens: Self.completeIntSum(providers.map(\.totalTokens)), totalCost: Self.completeCostSum(providers.map(\.totalCost)), coveredDayCount: Self.commonCoverageDayCount(summaries: summaries, calendar: calendar), chartDomain: Self.chartDomain(bounds: bounds, calendar: calendar), - modelHistoryCompleteness: modelHistoryCompleteness) + modelHistoryCompleteness: modelHistoryCompleteness, + modelTokenHistoryCompleteness: tokenModelSummary.completeness) } private static func inputSummary( @@ -267,8 +352,10 @@ struct SpendDashboardModel: Equatable, Sendable { coveredDayCount: entry.element.coveredDayCount) } } +} - private static func modelSummary(summaries: [InputSummary]) -> ModelSummary { +extension SpendDashboardModel { + private static func costModelSummary(summaries: [InputSummary]) -> ModelSummary { var aggregates: [ModelKey: ModelAccumulator] = [:] var completeness = ModelHistoryCompleteness.complete for summary in summaries { @@ -285,9 +372,11 @@ struct SpendDashboardModel: Equatable, Sendable { for breakdown in breakdowns { let name = breakdown.modelName.trimmingCharacters(in: .whitespacesAndNewlines) guard !name.isEmpty else { continue } - let key = ModelKey(provider: input.provider, modelName: name) + let key = ModelKey(sourceID: input.id, modelName: name) var aggregate = aggregates[key] ?? ModelAccumulator( + provider: input.provider, providerName: input.modelProviderName, + sourceName: input.displayName, tokens: 0, cost: 0) if hasCompleteTokenHistory, @@ -316,12 +405,78 @@ struct SpendDashboardModel: Equatable, Sendable { }) { completeness = .incomplete } + return ModelSummary( + rows: self.modelRows(aggregates: aggregates), + completeness: completeness) + } - let rows = aggregates.map { key, value in + private static func tokenModelSummary(summaries: [InputSummary]) -> ModelSummary { + var aggregates: [ModelKey: ModelAccumulator] = [:] + var completeness = ModelHistoryCompleteness.complete + for summary in summaries { + let input = summary.input + let hasCompleteTokenHistory = summary.totalTokens != nil && summary.entries.allSatisfy { + Self.hasCompleteModelTokenCoverage($0.entry) + } + guard hasCompleteTokenHistory else { + completeness = .incomplete + continue + } + let hasCompleteCostHistory = summary.totalCost != nil && summary.entries.allSatisfy { + Self.hasCompleteModelCostCoverage($0.entry) + } + for windowEntry in summary.entries { + let entry = windowEntry.entry + let breakdowns = entry.modelBreakdowns ?? [] + for breakdown in breakdowns { + let name = breakdown.modelName.trimmingCharacters(in: .whitespacesAndNewlines) + guard !name.isEmpty else { continue } + let key = ModelKey(sourceID: input.id, modelName: name) + var aggregate = aggregates[key] ?? ModelAccumulator( + provider: input.provider, + providerName: input.modelProviderName, + sourceName: input.displayName, + tokens: 0, + cost: 0) + if let tokens = Self.nonnegative(breakdown.totalTokens) { + aggregate.sawTokens = true + aggregate.tokens = Self.add( + tokens, + to: aggregate.tokens, + overflowed: &aggregate.overflowedTokens) + } else { + aggregate.invalidTokens = true + } + if hasCompleteCostHistory, + let cost = Self.validCost(breakdown.costUSD) + { + aggregate.sawCost = true + aggregate.cost = Self.add(cost, to: aggregate.cost, overflowed: &aggregate.overflowedCost) + } else { + aggregate.invalidCost = true + } + aggregates[key] = aggregate + } + } + } + if aggregates.values.contains(where: { + !$0.sawTokens || $0.invalidTokens || $0.overflowedTokens || $0.tokens == nil + }) { + completeness = .incomplete + } + return ModelSummary( + rows: self.modelRows(aggregates: aggregates), + completeness: completeness) + } + + private static func modelRows(aggregates: [ModelKey: ModelAccumulator]) -> [ModelRow] { + aggregates.map { key, value in ModelRow( + sourceID: key.sourceID, rank: 0, - provider: key.provider, + provider: value.provider, providerName: value.providerName, + sourceName: value.sourceName, modelName: key.modelName, totalTokens: value.sawTokens && !value.invalidTokens && !value.overflowedTokens ? value.tokens : nil, totalCost: value.sawCost && !value.invalidCost && !value.overflowedCost ? value.cost : nil) @@ -341,14 +496,15 @@ struct SpendDashboardModel: Equatable, Sendable { .enumerated() .map { rank, row in ModelRow( + sourceID: row.sourceID, rank: rank + 1, provider: row.provider, providerName: row.providerName, + sourceName: row.sourceName, modelName: row.modelName, totalTokens: row.totalTokens, totalCost: row.totalCost) } - return ModelSummary(rows: rows, completeness: completeness) } private static func hasProvenZeroCost(_ entry: CostUsageDailyReport.Entry) -> Bool { @@ -516,6 +672,45 @@ struct SpendDashboardModel: Equatable, Sendable { } } + private static func dailyTokenPoints(summaries: [InputSummary]) -> [DailyTokenPoint] { + var aggregates: [DailyKey: DailyTokenAccumulator] = [:] + for summary in summaries where summary.totalTokens != nil { + let input = summary.input + for windowEntry in summary.entries { + let key = DailyKey(day: windowEntry.day, sourceID: input.id) + var aggregate = aggregates[key] ?? DailyTokenAccumulator( + provider: input.provider, + providerName: input.displayName, + tokens: 0) + if let tokens = Self.nonnegative(windowEntry.entry.totalTokens) { + aggregate.tokens = Self.add( + tokens, + to: aggregate.tokens, + overflowed: &aggregate.overflowed) + } else { + aggregate.invalid = true + } + aggregates[key] = aggregate + } + } + + return aggregates.compactMap { key, value in + guard !value.invalid, !value.overflowed, let tokens = value.tokens else { return nil } + return DailyTokenPoint( + sourceID: key.sourceID, + provider: value.provider, + providerName: value.providerName, + day: key.day, + tokens: tokens) + } + .sorted { + if $0.day != $1.day { + return $0.day < $1.day + } + return $0.sourceID < $1.sourceID + } + } + private static func bounds(days: Int, now: Date, calendar: Calendar) -> ClosedRange { let end = calendar.startOfDay(for: now) let start = calendar.date(byAdding: .day, value: -(days - 1), to: end) ?? end diff --git a/Tests/CodexBarTests/ShareStatsTests.swift b/Tests/CodexBarTests/ShareStatsTests.swift index adb0a24651..c19bb806c6 100644 --- a/Tests/CodexBarTests/ShareStatsTests.swift +++ b/Tests/CodexBarTests/ShareStatsTests.swift @@ -5,6 +5,15 @@ import Testing @testable import CodexBar struct ShareStatsTests { + private struct ProofSource { + let id: String + let provider: UsageProvider + let name: String + let model: String + let tokens: Int + let cost: Double + } + @Test func `builder preserves native currencies and unavailable spend`() throws { let subscriptionNames = try [ @@ -17,22 +26,41 @@ struct ShareStatsTests { subscriptionNames: subscriptionNames)) #expect(payload.days == 30) - #expect(payload.totalTokens == nil) + #expect(payload.totalTokens == 500) + #expect(payload.tokenSourceCount == 2) + #expect(!payload.tokenCoverageIsComplete) #expect(payload.currencies == [ - ShareStatsCurrencyPayload(currencyCode: "GBP", estimatedCost: 12, coveredDayCount: 10), - ShareStatsCurrencyPayload(currencyCode: "USD", estimatedCost: nil, coveredDayCount: 0), + ShareStatsCurrencyPayload( + currencyCode: "GBP", + estimatedCost: 12, + coveredDayCount: 10, + pricedSourceCount: 1, + sourceCount: 1), + ShareStatsCurrencyPayload( + currencyCode: "USD", + estimatedCost: 4, + coveredDayCount: 0, + pricedSourceCount: 1, + sourceCount: 2), ]) - #expect(payload.providers.map(\.providerName) == ["Claude", "Codex · #1", "Cursor"]) + #expect(payload.providers.map(\.providerName) == ["Claude", "Codex", "Cursor"]) #expect(payload.providers.map(\.subscriptionName) == ["Max", "Pro 20x", "Cursor Pro"]) #expect(payload.providers.last?.estimatedCost == nil) - #expect(payload.topModels.map(\.modelName).prefix(2) == ["Claude", "GPT"]) - - let text = ShareStatsFormatting.text(payload) - #expect(text.contains("GBP: £12.00 estimated · coverage 10/30 days")) - #expect(text.contains("Claude · Max: 300 tokens · ~£12.00 est · 10/30 days")) - #expect(text.contains("USD: Spend unavailable · coverage 0/30 days")) - #expect(text.contains("Cursor · Cursor Pro: Spend unavailable")) - #expect(!text.contains("£12.00 +")) + #expect(payload.topModels.map(\.modelName).prefix(2) == ["Claude Sonnet 4", "GPT-5.4"]) + #expect(payload.dailyTokens == [ShareStatsDailyPayload(day: Self.date, totalTokens: 500)]) + #expect(payload.dailySourceCount == 2) + #expect(!payload.dailyCoverageIsComplete) + + let text = ShareStatsFormatting.text(payload, style: .modelActivity) + #expect(text.contains("You kept the models busy · last 30 days")) + #expect(text.contains("at least 1 of 30 days active")) + #expect(text.contains("Estimated token spend: ≥£12.00 · ≥$4.00 · pricing for 2 of 3 sources")) + #expect(text.contains("Top model routes:")) + #expect(text.contains("Claude Sonnet 4 via Claude")) + #expect(text.contains("3 sources tracked")) + #expect(text.contains("Aggregated locally by CodexBar · No prompts shared")) + #expect(!text.contains("Cursor Pro")) + #expect(!text.contains("Spend unavailable")) } @Test @@ -61,11 +89,15 @@ struct ShareStatsTests { let payload = try #require(ShareStatsBuilder.make( model: model, subscriptionNames: subscriptionNames)) - let text = ShareStatsFormatting.text(payload) + let text = ShareStatsFormatting.text(payload, style: .modelActivity) + let summaryText = ShareStatsFormatting.text(payload, style: .summary) - #expect(payload.topModels.map(\.modelName) == ["Claude", "GPT"]) - #expect(payload.topModels.last?.totalTokens == 400) - #expect(payload.topModels.last?.estimatedCost == 8) + #expect(payload.topModels.map(\.modelName) == ["Claude Sonnet 4", "GPT-5.4"]) + #expect(payload.topModels.last?.totalTokens == 200) + #expect(payload.topModels.last?.estimatedCost == 4) + #expect(payload.modelRouteCount == 11) + #expect(payload.shareableModelRouteCount == 2) + #expect(payload.hiddenModelRouteCount == 9) #expect(payload.providers.map(\.subscriptionName) == ["Max", nil, nil]) #expect(!text.contains("person@example.com")) #expect(!text.contains("/Users/")) @@ -74,6 +106,10 @@ struct ShareStatsTests { #expect(!text.contains("abcdefabcdef")) #expect(!text.contains("intranet")) #expect(!text.contains("acme")) + #expect(!summaryText.contains("person@example.com")) + #expect(!summaryText.contains("/Users/")) + #expect(!summaryText.contains("secret project")) + #expect(!summaryText.contains("acme")) } @Test @@ -110,8 +146,22 @@ struct ShareStatsTests { @Test func `bedrock regional model identifiers map to public families`() { - #expect(ShareStatsSanitizer.modelName("us.amazon.nova-2-lite-v1:0") == "Amazon Nova") - #expect(ShareStatsSanitizer.modelName("global.anthropic.claude-sonnet-4-v1:0") == "Claude") + #expect(ShareStatsSanitizer.modelName("us.amazon.nova-2-lite-v1:0") == "Amazon Nova 2 Lite V1:0") + #expect(ShareStatsSanitizer.modelName("global.anthropic.claude-sonnet-4-v1:0") == "Claude Sonnet 4 V1:0") + #expect(ShareStatsSanitizer.modelName("anthropic/claude-sonnet-4") == "Claude Sonnet 4") + #expect(ShareStatsSanitizer.modelName("openai/gpt-5.4-mini") == "GPT-5.4 Mini") + #expect(ShareStatsSanitizer.modelName("moonshotai/kimi-k2.5") == "Kimi K2.5") + #expect(ShareStatsSanitizer.modelName("Fable") == "Fable") + } + + @Test + func `public model families truncate private suffixes`() { + #expect(ShareStatsSanitizer.modelName("openai/gpt-5.4-acme-secret") == "GPT-5.4") + #expect(ShareStatsSanitizer.modelName("anthropic/claude-sonnet-4-client-x") == "Claude Sonnet 4") + #expect(ShareStatsSanitizer.modelName("z-ai/glm-4.5-orgslug") == "GLM 4.5") + #expect(ShareStatsSanitizer.modelName("openai/gpt-5acmeinternal") == nil) + #expect(ShareStatsSanitizer.modelName("anthropic/claude-sonnetclient") == nil) + #expect(ShareStatsSanitizer.modelName("acme/private-model-v2") == nil) } @Test @@ -128,14 +178,14 @@ struct ShareStatsTests { rank: 2, provider: .codex, providerName: "Codex", - modelName: "gpt-5.4-mini", + modelName: "gpt-5.4", totalTokens: 1, totalCost: Double.greatestFiniteMagnitude), SpendDashboardModel.ModelRow( rank: 3, provider: .codex, providerName: "Codex", - modelName: "gpt-5.4-nano", + modelName: "gpt-5.4", totalTokens: 5, totalCost: 5), ] @@ -153,6 +203,7 @@ struct ShareStatsTests { ], models: rows, dailyPoints: [], + dailyTokenPoints: [], totalTokens: 1, totalCost: nil, coveredDayCount: 0, @@ -169,6 +220,33 @@ struct ShareStatsTests { #expect(ShareStatsBuilder.make(model: SpendDashboardModel(requestedDays: 30, groups: [])) == nil) } + @Test + func `cost only dashboard has no share payload`() { + let group = SpendDashboardModel.CurrencyGroup( + currencyCode: "USD", + providers: [ + SpendDashboardModel.ProviderRow( + id: "codex", + rank: 1, + provider: .codex, + displayName: "Codex", + totalTokens: nil, + totalCost: 4, + coveredDayCount: 7), + ], + models: [], + dailyPoints: [], + dailyTokenPoints: [], + totalTokens: nil, + totalCost: 4, + coveredDayCount: 7, + chartDomain: Self.date...Self.date, + modelHistoryCompleteness: .complete) + + #expect(ShareStatsBuilder.make( + model: SpendDashboardModel(requestedDays: 7, groups: [group])) == nil) + } + @Test func `cost only models do not enter token usage rankings`() throws { let model = SpendDashboardModel(requestedDays: 7, groups: [ @@ -208,6 +286,7 @@ struct ShareStatsTests { totalCost: nil), ], dailyPoints: [], + dailyTokenPoints: [], totalTokens: 10, totalCost: -.infinity, coveredDayCount: 7, @@ -221,12 +300,12 @@ struct ShareStatsTests { #expect(payload.topModels.first?.estimatedCost == nil) #expect(payload.topModels.count == 1) #expect(payload.currencies.first?.estimatedCost == nil) - #expect(!ShareStatsFormatting.text(payload).lowercased().contains("nan")) - #expect(!ShareStatsFormatting.text(payload).lowercased().contains("inf")) + #expect(!ShareStatsFormatting.text(payload, style: .modelActivity).lowercased().contains("nan")) + #expect(!ShareStatsFormatting.text(payload, style: .modelActivity).lowercased().contains("inf")) } @Test - func `partial model history does not enter shared rankings`() throws { + func `complete source models remain visible when group history is partial`() throws { let group = SpendDashboardModel.CurrencyGroup( currencyCode: "USD", providers: [ @@ -249,6 +328,7 @@ struct ShareStatsTests { totalCost: 2), ], dailyPoints: [], + dailyTokenPoints: [], totalTokens: nil, totalCost: nil, coveredDayCount: 7, @@ -258,51 +338,378 @@ struct ShareStatsTests { model: SpendDashboardModel(requestedDays: 7, groups: [group]))) #expect(payload.providers.count == 1) - #expect(payload.topModels.isEmpty) + #expect(payload.topModels.map(\.modelName) == ["GPT-5.4"]) + #expect(!payload.modelRouteCoverageIsComplete) + #expect(ShareStatsFormatting.text(payload, style: .modelActivity) + .contains("Model route history is partial")) } - @Test @MainActor - func `renderer creates social card PNG`() throws { - let payload = try #require(ShareStatsBuilder.make(model: Self.dashboard)) - let data = try #require(ShareStatsRenderer.pngData(for: payload)) + @Test + func `token complete model route survives unavailable pricing through dashboard builder`() throws { + let entry = CostUsageDailyReport.Entry( + date: Self.isoDay(Self.date), + inputTokens: nil, + outputTokens: nil, + totalTokens: 10, + costUSD: nil, + modelsUsed: nil, + modelBreakdowns: [ + .init(modelName: "anthropic/claude-sonnet-4", costUSD: nil, totalTokens: 10), + ]) + let snapshot = CostUsageTokenSnapshot( + sessionTokens: nil, + sessionCostUSD: nil, + last30DaysTokens: 10, + last30DaysCostUSD: nil, + currencyCode: "USD", + historyDays: 30, + daily: [entry], + updatedAt: Self.date) + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(secondsFromGMT: 0) ?? .gmt + let dashboard = SpendDashboardModel.build( + inputs: [ + .init( + id: "openrouter", + provider: .openrouter, + displayName: "OpenRouter", + snapshot: snapshot), + ], + requestedDays: 7, + now: Self.date, + calendar: calendar) + let group = try #require(dashboard.groups.first) + let payload = try #require(ShareStatsBuilder.make(model: dashboard)) + + #expect(group.models.isEmpty) + #expect(group.tokenModels.map(\.modelName) == ["anthropic/claude-sonnet-4"]) + #expect(group.tokenModels.map(\.totalTokens) == [10]) + #expect(group.tokenModels.map(\.totalCost) == [nil]) + #expect(payload.topModels.map(\.modelName) == ["Claude Sonnet 4"]) + #expect(payload.topModels.map(\.sourceName) == ["OpenRouter"]) + #expect(payload.topModels.map(\.estimatedCost) == [nil]) + } + + @Test + func `selected window keeps quiet trailing days`() throws { + let calendar = Calendar(identifier: .gregorian) + let exclusiveEnd = try #require(calendar.date(byAdding: .day, value: 7, to: Self.date)) + let expectedEnd = try #require(calendar.date(byAdding: .day, value: 6, to: Self.date)) + let group = SpendDashboardModel.CurrencyGroup( + currencyCode: "USD", + providers: [ + SpendDashboardModel.ProviderRow( + id: "codex", + rank: 1, + provider: .codex, + displayName: "Codex", + totalTokens: 10, + totalCost: 1, + coveredDayCount: 7), + ], + models: [], + dailyPoints: [], + dailyTokenPoints: [ + SpendDashboardModel.DailyTokenPoint( + sourceID: "codex", + provider: .codex, + providerName: "Codex", + day: Self.date, + tokens: 10), + ], + totalTokens: 10, + totalCost: 1, + coveredDayCount: 7, + chartDomain: Self.date...exclusiveEnd, + modelHistoryCompleteness: .complete) + let payload = try #require(ShareStatsBuilder.make( + model: SpendDashboardModel(requestedDays: 7, groups: [group]))) + + #expect(calendar.startOfDay(for: payload.periodEnd) == calendar.startOfDay(for: expectedEnd)) + #expect(payload.dailyTokens.map(\.day) == [Self.date]) + } + + @Test + func `partial daily source coverage stays explicit`() throws { + let group = SpendDashboardModel.CurrencyGroup( + currencyCode: "USD", + providers: [ + SpendDashboardModel.ProviderRow( + id: "codex", + rank: 1, + provider: .codex, + displayName: "Codex", + totalTokens: 10, + totalCost: 1, + coveredDayCount: 7), + SpendDashboardModel.ProviderRow( + id: "openrouter", + rank: 2, + provider: .openrouter, + displayName: "OpenRouter", + totalTokens: nil, + totalCost: nil, + coveredDayCount: 0), + ], + models: [], + dailyPoints: [], + dailyTokenPoints: [ + SpendDashboardModel.DailyTokenPoint( + sourceID: "codex", + provider: .codex, + providerName: "Codex", + day: Self.date, + tokens: 10), + ], + totalTokens: nil, + totalCost: nil, + coveredDayCount: 0, + chartDomain: Self.date...Self.date, + modelHistoryCompleteness: .incomplete) + let payload = try #require(ShareStatsBuilder.make( + model: SpendDashboardModel(requestedDays: 7, groups: [group]))) + + #expect(payload.dailySourceCount == 1) + #expect(payload.dailyFullSourceCount == 1) + #expect(!payload.dailyCoverageIsComplete) + #expect(ShareStatsFormatting.text(payload, style: .modelActivity) + .contains("at least 1 of 7 days active")) + } + + @Test + func `seven of thirty covered days stay partial`() throws { + let group = SpendDashboardModel.CurrencyGroup( + currencyCode: "USD", + providers: [ + SpendDashboardModel.ProviderRow( + id: "codex", + rank: 1, + provider: .codex, + displayName: "Codex", + totalTokens: 10, + totalCost: 1, + coveredDayCount: 7), + ], + models: [], + dailyPoints: [], + dailyTokenPoints: [ + SpendDashboardModel.DailyTokenPoint( + sourceID: "codex", + provider: .codex, + providerName: "Codex", + day: Self.date, + tokens: 10), + ], + totalTokens: 10, + totalCost: 1, + coveredDayCount: 7, + chartDomain: Self.date...Self.date, + modelHistoryCompleteness: .complete) + let payload = try #require(ShareStatsBuilder.make( + model: SpendDashboardModel(requestedDays: 30, groups: [group]))) + + #expect(payload.totalTokens == 10) + #expect(payload.dailySourceCount == 1) + #expect(payload.dailyFullSourceCount == 0) + #expect(!payload.dailyCoverageIsComplete) + #expect(ShareStatsFormatting.text(payload, style: .modelActivity) + .contains("at least 1 of 30 days active")) + } + + @Test + func `daily token overflow is unavailable rather than zero`() throws { + let group = SpendDashboardModel.CurrencyGroup( + currencyCode: "USD", + providers: [ + SpendDashboardModel.ProviderRow( + id: "codex", + rank: 1, + provider: .codex, + displayName: "Codex", + totalTokens: Int.max, + totalCost: 1, + coveredDayCount: 1), + SpendDashboardModel.ProviderRow( + id: "openrouter", + rank: 2, + provider: .openrouter, + displayName: "OpenRouter", + totalTokens: 1, + totalCost: 1, + coveredDayCount: 1), + ], + models: [], + dailyPoints: [], + dailyTokenPoints: [ + SpendDashboardModel.DailyTokenPoint( + sourceID: "codex", + provider: .codex, + providerName: "Codex", + day: Self.date, + tokens: Int.max), + SpendDashboardModel.DailyTokenPoint( + sourceID: "openrouter", + provider: .openrouter, + providerName: "OpenRouter", + day: Self.date, + tokens: 1), + ], + totalTokens: nil, + totalCost: 2, + coveredDayCount: 1, + chartDomain: Self.date...Self.date, + modelHistoryCompleteness: .complete) + let payload = try #require(ShareStatsBuilder.make( + model: SpendDashboardModel(requestedDays: 1, groups: [group]))) + + #expect(payload.dailyCoverageIsComplete) + #expect(payload.dailyTokens == [ShareStatsDailyPayload(day: Self.date, totalTokens: nil)]) + #expect(payload.hasUnavailableDailyTotals) + #expect(ShareStatsFormatting.text(payload, style: .modelActivity) + .contains("at least 0 of 1 days active")) + } + + @Test(arguments: [1, 4, 8, 20]) @MainActor + func `same model stays distinct across many source instances`(sourceCount: Int) throws { + let providers = (0.. 8) + } + + @Test + func `model route identifiers cannot collide through separators`() { + let first = SpendDashboardModel.ModelRow( + sourceID: "openrouter:team", + rank: 1, + provider: .openrouter, + providerName: "OpenRouter", + modelName: "foo", + totalTokens: 1, + totalCost: 1) + let second = SpendDashboardModel.ModelRow( + sourceID: "openrouter", + rank: 2, + provider: .openrouter, + providerName: "OpenRouter", + modelName: "team:foo", + totalTokens: 1, + totalCost: 1) + + #expect(first.id != second.id) + } + + @Test @MainActor + func `renderer creates nonblank social card PNGs at share sizes`() throws { + let payload = try #require(ShareStatsBuilder.make(model: Self.proofDashboard)) #expect(ShareStatsCardView.size == CGSize(width: 1200, height: 630)) - #expect(data.starts(with: [0x89, 0x50, 0x4E, 0x47])) - let bitmap = try #require(NSBitmapImageRep(data: data)) - #expect(bitmap.pixelsWide == 1200) - #expect(bitmap.pixelsHigh == 630) - var sampledRGB: Set = [] - for y in stride(from: 0, to: bitmap.pixelsHigh, by: 19) { - for x in stride(from: 0, to: bitmap.pixelsWide, by: 23) { - guard let color = bitmap.colorAt(x: x, y: y)?.usingColorSpace(.deviceRGB) else { continue } - let red = UInt32((color.redComponent * 255).rounded()) - let green = UInt32((color.greenComponent * 255).rounded()) - let blue = UInt32((color.blueComponent * 255).rounded()) - sampledRGB.insert((red << 16) | (green << 8) | blue) - if sampledRGB.count > 8 { - break - } - } - if sampledRGB.count > 8 { - break + #expect(payload.providers.count == 4) + #expect(payload.topModels.count == 4) + #expect(payload.dailyCoverageIsComplete) + #expect(payload.tokenCoverageIsComplete) + + let proofDirectory = ProcessInfo.processInfo.environment["CODEXBAR_SHARE_STATS_PROOF_DIR"].map { + URL(fileURLWithPath: $0, isDirectory: true) + } + if let proofDirectory { + try FileManager.default.createDirectory( + at: proofDirectory, + withIntermediateDirectories: true) + } + for (size, filename) in [ + (CGSize(width: 1200, height: 630), "share-stats-1200x630.png"), + (CGSize(width: 600, height: 315), "share-stats-600x315.png"), + (CGSize(width: 300, height: 158), "share-stats-300x158.png"), + ] { + let data = try #require(ShareStatsRenderer.pngData( + for: payload, + style: .modelActivity, + pixelSize: size)) + #expect(data.starts(with: [0x89, 0x50, 0x4E, 0x47])) + let bitmap = try #require(NSBitmapImageRep(data: data)) + #expect(bitmap.pixelsWide == Int(size.width)) + #expect(bitmap.pixelsHigh == Int(size.height)) + #expect(Self.sampledColorCount(bitmap) > 8) + #expect(Self.minimumSampledAlpha(bitmap) > 0.99) + if let proofDirectory { + try data.write(to: proofDirectory.appendingPathComponent(filename), options: .atomic) } } - #expect(sampledRGB.count > 1) + if let proofDirectory { + let accessibleText = ShareStatsFormatting.text(payload, style: .modelActivity) + "\n" + try accessibleText.write( + to: proofDirectory.appendingPathComponent("share-stats.txt"), + atomically: true, + encoding: .utf8) + } } @Test @MainActor - func `provider rows leave room for overflow summary`() { - #expect(ShareStatsCardView.providerDisplayLimit(for: 5) == 5) - #expect(ShareStatsCardView.providerDisplayLimit(for: 6) == 4) - #expect(ShareStatsCardView.providerDisplayLimit(for: 12) == 4) + func `summary card remains the default export while model activity is opt in`() throws { + let payload = try #require(ShareStatsBuilder.make(model: Self.proofDashboard)) + + #expect(ShareStatsCardStyle.defaultStyle == .summary) + #expect(ShareStatsFormatting.text(payload).hasPrefix("My AI subscriptions")) + #expect(ShareStatsFormatting.text(payload, style: .modelActivity).hasPrefix("You kept the models busy")) + + let defaultPNG = try #require(ShareStatsRenderer.pngData(for: payload)) + let summaryPNG = try #require(ShareStatsRenderer.pngData(for: payload, style: .summary)) + let activityPNG = try #require(ShareStatsRenderer.pngData(for: payload, style: .modelActivity)) + #expect(defaultPNG == summaryPNG) + #expect(defaultPNG != activityPNG) } @Test @MainActor - func `model colors use provider identity instead of decorated account name`() throws { - let payload = try #require(ShareStatsBuilder.make(model: Self.dashboard)) - let codexModel = try #require(payload.topModels.first { $0.provider == .codex }) - - #expect(ShareStatsCardView.providerPaletteIndex(for: codexModel, providers: payload.providers) == 1) + func `activity levels preserve zero and scale to five steps`() { + #expect(ShareStatsModelActivityCardView.activityLevel(totalTokens: 0, maximum: 100) == 0) + #expect(ShareStatsModelActivityCardView.activityLevel(totalTokens: 1, maximum: 100) == 1) + #expect(ShareStatsModelActivityCardView.activityLevel(totalTokens: 50, maximum: 100) == 3) + #expect(ShareStatsModelActivityCardView.activityLevel(totalTokens: 100, maximum: 100) == 5) } @Test @@ -311,9 +718,49 @@ struct ShareStatsTests { #expect(ShareStatsBuilder.combinedTotalTokens([10, nil]) == nil) #expect(ShareStatsBuilder.combinedTotalTokens([10, 20]) == 30) } +} +extension ShareStatsTests { private static let date = Date(timeIntervalSince1970: 1_783_382_400) + private static func isoDay(_ date: Date) -> String { + let formatter = DateFormatter() + formatter.calendar = Calendar(identifier: .gregorian) + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = TimeZone(secondsFromGMT: 0) + formatter.dateFormat = "yyyy-MM-dd" + return formatter.string(from: date) + } + + private static func sampledColorCount(_ bitmap: NSBitmapImageRep) -> Int { + var sampledRGB: Set = [] + let xStride = max(1, bitmap.pixelsWide / 50) + let yStride = max(1, bitmap.pixelsHigh / 30) + for y in stride(from: 0, to: bitmap.pixelsHigh, by: yStride) { + for x in stride(from: 0, to: bitmap.pixelsWide, by: xStride) { + guard let color = bitmap.colorAt(x: x, y: y)?.usingColorSpace(.deviceRGB) else { continue } + let red = UInt32((color.redComponent * 255).rounded()) + let green = UInt32((color.greenComponent * 255).rounded()) + let blue = UInt32((color.blueComponent * 255).rounded()) + sampledRGB.insert((red << 16) | (green << 8) | blue) + } + } + return sampledRGB.count + } + + private static func minimumSampledAlpha(_ bitmap: NSBitmapImageRep) -> CGFloat { + var minimum: CGFloat = 1 + let xStride = max(1, bitmap.pixelsWide / 50) + let yStride = max(1, bitmap.pixelsHigh / 30) + for y in stride(from: 0, to: bitmap.pixelsHigh, by: yStride) { + for x in stride(from: 0, to: bitmap.pixelsWide, by: xStride) { + guard let alpha = bitmap.colorAt(x: x, y: y)?.alphaComponent else { continue } + minimum = min(minimum, alpha) + } + } + return minimum + } + private static func subscriptionName( provider: UsageProvider, rawName: String, @@ -344,6 +791,81 @@ struct ShareStatsTests { self.dashboard(models: ["gpt-5.4"]) } + private static var proofDashboard: SpendDashboardModel { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(secondsFromGMT: 0) ?? .gmt + let start = calendar.startOfDay(for: self.date) + let exclusiveEnd = calendar.date(byAdding: .day, value: 30, to: start) ?? start + let sources = [ + ProofSource(id: "codex", provider: .codex, name: "Codex", model: "gpt-5.4", tokens: 8_000_000, cost: 94), + ProofSource( + id: "openrouter", + provider: .openrouter, + name: "OpenRouter", + model: "anthropic/claude-sonnet-4", + tokens: 7_000_000, + cost: 140), + ProofSource( + id: "kimi", + provider: .kimi, + name: "Kimi", + model: "moonshotai/kimi-k2.5", + tokens: 4_000_000, + cost: 22), + ProofSource( + id: "zai", + provider: .zai, + name: "Z.ai", + model: "z-ai/glm-4.5", + tokens: 2_700_000, + cost: 18), + ] + let dailyTokenPoints = (0..<30).flatMap { dayOffset in + sources.enumerated().compactMap { index, source -> SpendDashboardModel.DailyTokenPoint? in + guard (dayOffset + index) % 5 != 0, + let day = calendar.date(byAdding: .day, value: dayOffset, to: start) + else { return nil } + return SpendDashboardModel.DailyTokenPoint( + sourceID: source.id, + provider: source.provider, + providerName: source.name, + day: day, + tokens: (index + 1) * (dayOffset + 3) * 18500) + } + } + let group = SpendDashboardModel.CurrencyGroup( + currencyCode: "USD", + providers: sources.enumerated().map { index, source in + SpendDashboardModel.ProviderRow( + id: source.id, + rank: index + 1, + provider: source.provider, + displayName: source.name, + totalTokens: source.tokens, + totalCost: source.cost, + coveredDayCount: 30) + }, + models: sources.enumerated().map { index, source in + SpendDashboardModel.ModelRow( + sourceID: source.id, + rank: index + 1, + provider: source.provider, + providerName: source.name, + sourceName: source.name, + modelName: source.model, + totalTokens: source.tokens, + totalCost: source.cost) + }, + dailyPoints: [], + dailyTokenPoints: dailyTokenPoints, + totalTokens: sources.reduce(0) { $0 + $1.tokens }, + totalCost: sources.reduce(0) { $0 + $1.cost }, + coveredDayCount: 30, + chartDomain: start...exclusiveEnd, + modelHistoryCompleteness: .complete) + return SpendDashboardModel(requestedDays: 30, groups: [group]) + } + private static func dashboard(models: [String]) -> SpendDashboardModel { SpendDashboardModel(requestedDays: 30, groups: [ SpendDashboardModel.CurrencyGroup( @@ -368,6 +890,14 @@ struct ShareStatsTests { totalCost: 1), ], dailyPoints: [], + dailyTokenPoints: [ + SpendDashboardModel.DailyTokenPoint( + sourceID: "claude", + provider: .claude, + providerName: "Claude", + day: self.date, + tokens: 300), + ], totalTokens: 300, totalCost: 12, coveredDayCount: 10, @@ -403,6 +933,14 @@ struct ShareStatsTests { totalCost: 4) }, dailyPoints: [], + dailyTokenPoints: [ + SpendDashboardModel.DailyTokenPoint( + sourceID: "codex:one", + provider: .codex, + providerName: "Codex · #1", + day: self.date, + tokens: 200), + ], totalTokens: nil, totalCost: nil, coveredDayCount: 0, diff --git a/Tests/CodexBarTests/SpendDashboardModelTests.swift b/Tests/CodexBarTests/SpendDashboardModelTests.swift index 9223e633ca..6d445f1f17 100644 --- a/Tests/CodexBarTests/SpendDashboardModelTests.swift +++ b/Tests/CodexBarTests/SpendDashboardModelTests.swift @@ -371,8 +371,12 @@ struct SpendDashboardModelTests { #expect(group.dailyPoints.map(\.cost) == [5, 4]) #expect(group.dailyPoints.map(\.stackStart) == [0, 5]) #expect(group.dailyPoints.map(\.stackEnd) == [5, 9]) + #expect(group.dailyTokenPoints.map(\.sourceID) == ["a", "b"]) + #expect(group.dailyTokenPoints.map(\.tokens) == [20, 10]) } +} +extension SpendDashboardModelTests { @Test func `invalid costs and arithmetic overflow never become spend`() throws { let invalid = SpendDashboardModel.ProviderInput( diff --git a/docs/screenshots/share-stats-brand-card.png b/docs/screenshots/share-stats-brand-card.png new file mode 100644 index 0000000000..533b6d3e7f Binary files /dev/null and b/docs/screenshots/share-stats-brand-card.png differ diff --git a/docs/screenshots/share-stats-proof/share-stats-1200x630.png b/docs/screenshots/share-stats-proof/share-stats-1200x630.png new file mode 100644 index 0000000000..6692fad6fc Binary files /dev/null and b/docs/screenshots/share-stats-proof/share-stats-1200x630.png differ diff --git a/docs/screenshots/share-stats-proof/share-stats-300x158.png b/docs/screenshots/share-stats-proof/share-stats-300x158.png new file mode 100644 index 0000000000..71449bbfea Binary files /dev/null and b/docs/screenshots/share-stats-proof/share-stats-300x158.png differ diff --git a/docs/screenshots/share-stats-proof/share-stats-600x315.png b/docs/screenshots/share-stats-proof/share-stats-600x315.png new file mode 100644 index 0000000000..1c70484daa Binary files /dev/null and b/docs/screenshots/share-stats-proof/share-stats-600x315.png differ diff --git a/docs/screenshots/share-stats-proof/share-stats.txt b/docs/screenshots/share-stats-proof/share-stats.txt new file mode 100644 index 0000000000..3d930094a5 --- /dev/null +++ b/docs/screenshots/share-stats-proof/share-stats.txt @@ -0,0 +1,11 @@ +You kept the models busy · last 30 days +21.7M tracked tokens +30 of 30 days active +Estimated token spend: $274.00 · pricing for 4 of 4 sources +Top model routes: +GPT-5.4 via Codex +Claude Sonnet 4 via OpenRouter +Kimi K2.5 via Kimi ++1 more safe route summaries +4 sources tracked +Aggregated locally by CodexBar · No prompts shared · Data through Aug 4, 2026