diff --git a/README.md b/README.md index 263c04f21a..de7c235849 100644 --- a/README.md +++ b/README.md @@ -149,7 +149,7 @@ show an incident indicator. - Optional Codex web dashboard enrichments (code review remaining, usage breakdown, credits history). - Inline spend and usage charts for API-backed providers such as OpenAI, Claude Admin API, OpenRouter, LiteLLM, z.ai, MiniMax, Mistral, and AWS Bedrock. - Configurable cost-usage scans for Codex + Claude, plus reused chart UI for supported provider histories. -- A persistent Settings → Usage & Spend view for local 7/30-day estimates, grouped by native currency and limited to providers that expose cost history. +- A persistent Settings → Usage & Spend view for local 7/30/365-day estimates, grouped by native currency, with every tracked subscription/key visible and unsupported cost sources excluded from totals. - Provider status polling with incident badges in the menu and icon overlay. - Merge Icons mode to combine providers into one status item + switcher. - Display controls for provider icons, labels, bars, reset-time style, and highest-usage auto-selection. diff --git a/Sources/CodexBar/PreferencesSpendDashboardPane.swift b/Sources/CodexBar/PreferencesSpendDashboardPane.swift index 83e7f31038..da129959fa 100644 --- a/Sources/CodexBar/PreferencesSpendDashboardPane.swift +++ b/Sources/CodexBar/PreferencesSpendDashboardPane.swift @@ -8,6 +8,7 @@ func spendDashboardDayRangeText(_ days: Int) -> String { switch days { case 7: template = L("7d") case 30: template = L("30d") + case 365: template = L("365d") default: return codexBarLocalizedInteger(days) } return template.replacingOccurrences( @@ -15,6 +16,10 @@ func spendDashboardDayRangeText(_ days: Int) -> String { with: codexBarLocalizedInteger(days)) } +func spendDashboardRequiredHistoryDays(selectedDays: Int, configuredDays: Int) -> Int { + max(1, min(365, max(selectedDays, configuredDays))) +} + func spendDashboardRankText(_ rank: Int) -> String { "#\(codexBarLocalizedInteger(rank))" } @@ -27,6 +32,17 @@ func spendDashboardCoverageText(covered: Int, requested: Int) -> String { "\(L("Coverage")): \(codexBarLocalizedInteger(covered)) / \(codexBarLocalizedInteger(requested))" } +func spendDashboardTrackedSourceStatusText(_ source: SpendDashboardTrackedSource) -> String { + if source.contributesCostHistory { + return source.state == .connected + ? L("Cost history connected") + : L("Cost history pending") + } + return source.state == .connected + ? L("Usage connected · not in cost total") + : L("Configured · not in cost total") +} + enum SpendDashboardModelHistoryPresentation: Equatable { case unavailable case empty @@ -62,6 +78,7 @@ struct SpendDashboardPane: View { VStack(alignment: .leading, spacing: 18) { self.header self.content + self.trackedAccess self.provenance self.shareAction } @@ -69,6 +86,7 @@ struct SpendDashboardPane: View { } .background(FocusResigningBackground()) .onAppear { + self.applySelectedHistoryCoverage() self.controller.refreshDateWindow() self.controller.update(configuration: self.configuration) } @@ -76,6 +94,7 @@ struct SpendDashboardPane: View { self.controller.update(configuration: configuration) } .onDisappear { + self.settings.setSpendDashboardHistoryDaysOverride(nil) self.controller.stop() } .onReceive(NotificationCenter.default.publisher(for: .NSCalendarDayChanged)) { _ in @@ -94,34 +113,12 @@ struct SpendDashboardPane: View { } private var header: some View { - HStack(alignment: .top, spacing: 16) { - VStack(alignment: .leading, spacing: 4) { - Text(L("Usage & Spend")) - .font(.title2.weight(.semibold)) - Text(L("Local estimated cost history across supported providers.")) - .font(.subheadline) - .foregroundStyle(.secondary) - } - Spacer() - Picker(L("Time range"), selection: self.daysBinding) { - Text(spendDashboardDayRangeText(7)).tag(7) - Text(spendDashboardDayRangeText(30)).tag(30) - } - .labelsHidden() - .pickerStyle(.segmented) - .frame(width: 116) - - Button { - self.controller.refresh() - } label: { - if self.controller.isRefreshing { - ProgressView().controlSize(.small) - } else { - Label(L("Refresh"), systemImage: "arrow.clockwise") - } - } - .disabled(self.controller.isRefreshing || !self.settings.costUsageEnabled) - } + SpendDashboardHeader( + selectedDays: self.controller.selectedDays, + isRefreshing: self.controller.isRefreshing, + isCostTrackingEnabled: self.settings.costUsageEnabled, + selectDays: { self.daysBinding.wrappedValue = $0 }, + refresh: { self.controller.refresh() }) } @ViewBuilder @@ -173,6 +170,20 @@ struct SpendDashboardPane: View { } } + @ViewBuilder + private var trackedAccess: some View { + let sources = self.configuration.trackedSources + if !sources.isEmpty { + SpendTrackedAccessPanel( + sources: sources, + description: self.trackedAccessDescription) + } + } + + private var trackedAccessDescription: String { + L("Every configured subscription or key stays visible. Only compatible sources enter cost totals.") + } + private var shareAction: some View { HStack { Spacer() @@ -189,7 +200,8 @@ struct SpendDashboardPane: View { private var sharePayload: ShareStatsPayload? { ShareStatsBuilder.make( model: self.controller.model, - subscriptionNames: self.subscriptionNames) + subscriptionNames: self.subscriptionNames, + trackedSources: self.configuration.trackedSources) } private var subscriptionNames: [String: ShareStatsSubscriptionName] { @@ -222,7 +234,175 @@ struct SpendDashboardPane: View { private var daysBinding: Binding { Binding( get: { self.controller.selectedDays }, - set: { self.controller.selectDays($0) }) + set: { + self.controller.selectDays($0) + self.applySelectedHistoryCoverage() + self.controller.refreshDateWindow() + }) + } + + private func applySelectedHistoryCoverage() { + let requiredDays = spendDashboardRequiredHistoryDays( + selectedDays: self.controller.selectedDays, + configuredDays: self.settings.costUsageHistoryDays) + self.settings.setSpendDashboardHistoryDaysOverride( + requiredDays == self.settings.costUsageHistoryDays ? nil : requiredDays) + } +} + +struct SpendDashboardHeader: View { + let selectedDays: Int + let isRefreshing: Bool + let isCostTrackingEnabled: Bool + let selectDays: (Int) -> Void + let refresh: () -> Void + + var body: some View { + ViewThatFits(in: .horizontal) { + HStack(alignment: .top, spacing: 16) { + self.title + Spacer(minLength: 16) + self.controls + } + VStack(alignment: .leading, spacing: 12) { + self.title + self.controls + } + } + } + + private var title: some View { + VStack(alignment: .leading, spacing: 4) { + Text(L("Usage & Spend")) + .font(.title2.weight(.semibold)) + Text(L("Local estimated cost history across supported providers.")) + .font(.subheadline) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + + private var controls: some View { + HStack(spacing: 12) { + Picker(L("Time range"), selection: self.daysBinding) { + Text(spendDashboardDayRangeText(7)).tag(7) + Text(spendDashboardDayRangeText(30)).tag(30) + Text(spendDashboardDayRangeText(365)).tag(365) + } + .labelsHidden() + .pickerStyle(.segmented) + .frame(width: 174) + + Button(action: self.refresh) { + if self.isRefreshing { + ProgressView().controlSize(.small) + } else { + Label(L("Refresh"), systemImage: "arrow.clockwise") + } + } + .disabled(self.isRefreshing || !self.isCostTrackingEnabled) + } + } + + private var daysBinding: Binding { + Binding(get: { self.selectedDays }, set: { self.selectDays($0) }) + } +} + +struct SpendTrackedAccessPanel: View { + let sources: [SpendDashboardTrackedSource] + let description: String + + private let columns = [ + GridItem(.adaptive(minimum: 245, maximum: 420), spacing: 12), + ] + + var body: some View { + SpendDashboardPanel { + VStack(alignment: .leading, spacing: 16) { + VStack(alignment: .leading, spacing: 4) { + HStack(alignment: .firstTextBaseline, spacing: 12) { + Text(L("Tracked access")) + .font(.headline) + Spacer(minLength: 12) + Text( + "\(codexBarLocalizedInteger(self.sources.count)) " + + L("tracked sources")) + .font(.caption.monospacedDigit().weight(.semibold)) + .foregroundStyle(.secondary) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(.quaternary.opacity(0.7), in: Capsule()) + .accessibilityLabel( + "\(codexBarLocalizedInteger(self.sources.count)) \(L("tracked sources"))") + } + Text(self.description) + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + + LazyVGrid(columns: self.columns, alignment: .leading, spacing: 12) { + ForEach(self.sources) { source in + SpendTrackedSourceRow(source: source) + } + } + } + } + } +} + +private struct SpendTrackedSourceRow: View { + let source: SpendDashboardTrackedSource + + var body: some View { + VStack(alignment: .leading, spacing: 9) { + HStack(spacing: 10) { + SpendProviderIcon(provider: self.source.provider) + + VStack(alignment: .leading, spacing: 2) { + Text(self.source.providerName) + .font(.subheadline.weight(.medium)) + .lineLimit(1) + if let accountName = self.source.accountName { + Text(accountName) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + Spacer(minLength: 4) + } + + Label( + spendDashboardTrackedSourceStatusText(self.source), + systemImage: self.statusSymbol) + .font(.caption.weight(.medium)) + .foregroundStyle(self.statusColor) + .fixedSize(horizontal: false, vertical: true) + } + .padding(12) + .frame(maxWidth: .infinity, minHeight: 72, alignment: .leading) + .background(.background.opacity(0.72), in: RoundedRectangle(cornerRadius: 10, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: 10, style: .continuous) + .strokeBorder(Color(nsColor: .separatorColor).opacity(0.25)) + } + .accessibilityElement(children: .combine) + } + + private var statusSymbol: String { + if self.source.contributesCostHistory { + return self.source.state == .connected ? "checkmark.circle.fill" : "clock.fill" + } + return self.source.state == .connected ? "minus.circle.fill" : "minus.circle" + } + + private var statusColor: Color { + if self.source.contributesCostHistory { + return self.source.state == .connected ? .green : .orange + } + return .secondary } } diff --git a/Sources/CodexBar/ProviderRegistry.swift b/Sources/CodexBar/ProviderRegistry.swift index 56593f7cfb..8d3b936c2e 100644 --- a/Sources/CodexBar/ProviderRegistry.swift +++ b/Sources/CodexBar/ProviderRegistry.swift @@ -84,7 +84,7 @@ struct ProviderRegistry { } } }, - costUsageHistoryDays: settings.costUsageHistoryDays, + costUsageHistoryDays: settings.effectiveCostUsageHistoryDays, persistsCLISessions: true, persistentCLISessionIdleWindow: Self.persistentCLISessionIdleWindow( refreshInterval: Self.nominalRefreshInterval(for: settings.refreshFrequency))) diff --git a/Sources/CodexBar/Resources/ar.lproj/Localizable.strings b/Sources/CodexBar/Resources/ar.lproj/Localizable.strings index e8d9da1b7d..97911e82fd 100644 --- a/Sources/CodexBar/Resources/ar.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ar.lproj/Localizable.strings @@ -7,6 +7,7 @@ " providers" = " providers"; "(System)" = "(النظام)"; "30d" = "30 يومًا"; +"365d" = "365 يومًا"; "7d" = "7 أيام"; "A managed Codex login is already running. Wait for it to finish before adding " = "تسجيل دخول Codex المدار يعمل بالفعل. انتظر حتى ينتهي قبل إضافة "; "API key" = "مفتاح API"; @@ -1268,6 +1269,13 @@ "Model breakdown unavailable" = "تفصيل الإنفاق حسب النموذج غير متاح"; "Local estimated history" = "السجل التقديري المحلي"; "Coverage" = "التغطية"; +"Tracked access" = "الوصول المُتتبَّع"; +"Every configured subscription or key stays visible. Only compatible sources enter cost totals." = "يبقى كل اشتراك أو مفتاح مُهيأ مرئيًا. المصادر المتوافقة فقط تدخل في إجماليات التكلفة."; +"tracked sources" = "المصادر المُتتبَّعة"; +"Cost history connected" = "سجل التكلفة متصل"; +"Cost history pending" = "سجل التكلفة قيد الانتظار"; +"Usage connected · not in cost total" = "الاستخدام متصل · غير مشمول في إجمالي التكلفة"; +"Configured · not in cost total" = "مُهيأ · غير مشمول في إجمالي التكلفة"; "Estimated spend" = "الإنفاق التقديري"; "Tracked tokens" = "الرموز المتتبعة"; "Subscriptions" = "الاشتراكات"; diff --git a/Sources/CodexBar/Resources/ca.lproj/Localizable.strings b/Sources/CodexBar/Resources/ca.lproj/Localizable.strings index 3712280636..9e7e9ef950 100644 --- a/Sources/CodexBar/Resources/ca.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ca.lproj/Localizable.strings @@ -7,6 +7,7 @@ " providers" = " proveïdors"; "(System)" = "(Sistema)"; "30d" = "30 d"; +"365d" = "365 d"; "7d" = "7 d"; "A managed Codex login is already running. Wait for it to finish before adding " = "Ja hi ha un inici de sessió gestionat de Codex en curs. Espereu que acabi abans d'afegir "; "API key" = "Clau d'API"; @@ -1267,6 +1268,13 @@ "Model breakdown unavailable" = "Desglossament per model no disponible"; "Local estimated history" = "Historial local estimat"; "Coverage" = "Cobertura"; +"Tracked access" = "Accés fet un seguiment"; +"Every configured subscription or key stays visible. Only compatible sources enter cost totals." = "Totes les subscripcions o claus configurades resten visibles. Només les fonts compatibles entren als totals de cost."; +"tracked sources" = "fonts amb seguiment"; +"Cost history connected" = "Historial de costos connectat"; +"Cost history pending" = "Historial de costos pendent"; +"Usage connected · not in cost total" = "Ús connectat · no inclòs al total de cost"; +"Configured · not in cost total" = "Configurat · no inclòs al total de cost"; "Estimated spend" = "Despesa estimada"; "Tracked tokens" = "Tokens registrats"; "Subscriptions" = "Subscripcions"; diff --git a/Sources/CodexBar/Resources/de.lproj/Localizable.strings b/Sources/CodexBar/Resources/de.lproj/Localizable.strings index 7de8049dec..88e2c01792 100644 --- a/Sources/CodexBar/Resources/de.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/de.lproj/Localizable.strings @@ -27,6 +27,7 @@ " providers" = "Anbieter"; "(System)" = "(System)"; "30d" = "30d"; +"365d" = "365d"; "7d" = "7d"; "A managed Codex login is already running. Wait for it to finish before adding " = "Eine verwaltete Codex-Anmeldung läuft bereits. Warten Sie, bis der Vorgang abgeschlossen ist, bevor Sie ihn hinzufügen"; "API key" = "API-Schlüssel"; @@ -1265,6 +1266,13 @@ "Model breakdown unavailable" = "Modellaufschlüsselung nicht verfügbar"; "Local estimated history" = "Lokaler Schätzverlauf"; "Coverage" = "Abdeckung"; +"Tracked access" = "Verfolgter Zugriff"; +"Every configured subscription or key stays visible. Only compatible sources enter cost totals." = "Jedes konfigurierte Abonnement oder jeder Schlüssel bleibt sichtbar. Nur kompatible Quellen fließen in die Kostensummen ein."; +"tracked sources" = "verfolgte Quellen"; +"Cost history connected" = "Kostenverlauf verbunden"; +"Cost history pending" = "Kostenverlauf ausstehend"; +"Usage connected · not in cost total" = "Nutzung verbunden · nicht in der Kostensumme"; +"Configured · not in cost total" = "Konfiguriert · nicht in der Kostensumme"; "Estimated spend" = "Geschätzte Ausgaben"; "Tracked tokens" = "Erfasste Token"; "Subscriptions" = "Abonnements"; diff --git a/Sources/CodexBar/Resources/en.lproj/Localizable.strings b/Sources/CodexBar/Resources/en.lproj/Localizable.strings index c9fc3150cd..fe102916c3 100644 --- a/Sources/CodexBar/Resources/en.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/en.lproj/Localizable.strings @@ -7,6 +7,7 @@ " providers" = " providers"; "(System)" = "(System)"; "30d" = "30d"; +"365d" = "365d"; "7d" = "7d"; "A managed Codex login is already running. Wait for it to finish before adding " = "A managed Codex login is already running. Wait for it to finish before adding "; "API key" = "API key"; @@ -1269,6 +1270,13 @@ "Model breakdown unavailable" = "Model breakdown unavailable"; "Local estimated history" = "Local estimated history"; "Coverage" = "Coverage"; +"Tracked access" = "Tracked access"; +"Every configured subscription or key stays visible. Only compatible sources enter cost totals." = "Every configured subscription or key stays visible. Only compatible sources enter cost totals."; +"tracked sources" = "tracked sources"; +"Cost history connected" = "Cost history connected"; +"Cost history pending" = "Cost history pending"; +"Usage connected · not in cost total" = "Usage connected · not in cost total"; +"Configured · not in cost total" = "Configured · not in cost total"; "Estimated spend" = "Estimated spend"; "Tracked tokens" = "Tracked tokens"; "Subscriptions" = "Subscriptions"; diff --git a/Sources/CodexBar/Resources/es.lproj/Localizable.strings b/Sources/CodexBar/Resources/es.lproj/Localizable.strings index 16e2b02585..068f5ccdcd 100644 --- a/Sources/CodexBar/Resources/es.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/es.lproj/Localizable.strings @@ -27,6 +27,7 @@ " providers" = " proveedores"; "(System)" = "(Sistema)"; "30d" = "30 d"; +"365d" = "365 d"; "7d" = "7 d"; "A managed Codex login is already running. Wait for it to finish before adding " = "Ya hay un inicio de sesión gestionado de Codex en curso. Espera a que termine antes de añadir "; "API key" = "Clave de API"; @@ -1263,6 +1264,13 @@ "Model breakdown unavailable" = "Desglose por modelo no disponible"; "Local estimated history" = "Historial local estimado"; "Coverage" = "Cobertura"; +"Tracked access" = "Acceso rastreado"; +"Every configured subscription or key stays visible. Only compatible sources enter cost totals." = "Cada suscripción o clave configurada permanece visible. Solo las fuentes compatibles entran en los totales de costo."; +"tracked sources" = "fuentes rastreadas"; +"Cost history connected" = "Historial de costos conectado"; +"Cost history pending" = "Historial de costos pendiente"; +"Usage connected · not in cost total" = "Uso conectado · no incluido en el total de costo"; +"Configured · not in cost total" = "Configurado · no incluido en el total de costo"; "Estimated spend" = "Gasto estimado"; "Tracked tokens" = "Tokens registrados"; "Subscriptions" = "Suscripciones"; diff --git a/Sources/CodexBar/Resources/fa.lproj/Localizable.strings b/Sources/CodexBar/Resources/fa.lproj/Localizable.strings index 0bc50e5677..288af50819 100644 --- a/Sources/CodexBar/Resources/fa.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/fa.lproj/Localizable.strings @@ -7,6 +7,7 @@ " providers" = " providers"; "(System)" = "(سیستم)"; "30d" = "30 روز"; +"365d" = "365 روز"; "7d" = "7 روز"; "A managed Codex login is already running. Wait for it to finish before adding " = "یک ورود Codex مدیریت شده هم اکنون در حال اجرا است. صبر کنید تا تمام شود و بعد را اضافه کنید"; "API key" = "کلید API"; @@ -1268,6 +1269,13 @@ "Model breakdown unavailable" = "تفکیک مدل در دسترس نیست"; "Local estimated history" = "تاریخچه برآورد محلی"; "Coverage" = "پوشش"; +"Tracked access" = "دسترسی ردیابی‌شده"; +"Every configured subscription or key stays visible. Only compatible sources enter cost totals." = "هر اشتراک یا کلید پیکربندی‌شده قابل مشاهده می‌ماند. فقط منابع سازگار در مجموع هزینه‌ها لحاظ می‌شوند."; +"tracked sources" = "منابع ردیابی‌شده"; +"Cost history connected" = "تاریخچه هزینه متصل است"; +"Cost history pending" = "تاریخچه هزینه در انتظار"; +"Usage connected · not in cost total" = "مصرف متصل · در مجموع هزینه لحاظ نشده"; +"Configured · not in cost total" = "پیکربندی‌شده · در مجموع هزینه لحاظ نشده"; "Estimated spend" = "برآورد هزینه"; "Tracked tokens" = "توکن‌های پیگیری‌شده"; "Subscriptions" = "اشتراک‌ها"; diff --git a/Sources/CodexBar/Resources/fr.lproj/Localizable.strings b/Sources/CodexBar/Resources/fr.lproj/Localizable.strings index 090d0014f8..9aeaceb394 100644 --- a/Sources/CodexBar/Resources/fr.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/fr.lproj/Localizable.strings @@ -27,6 +27,7 @@ " providers" = " fournisseurs"; "(System)" = "(System)"; "30d" = "30d"; +"365d" = "365d"; "7d" = "7d"; "A managed Codex login is already running. Wait for it to finish before adding " = "Une connexion Codex gérée est déjà en cours d'exécution. Attendez qu'il soit terminé avant d'ajouter"; "API key" = "Clé API"; @@ -1264,6 +1265,13 @@ "Model breakdown unavailable" = "Répartition par modèle indisponible"; "Local estimated history" = "Historique local estimé"; "Coverage" = "Couverture"; +"Tracked access" = "Accès suivi"; +"Every configured subscription or key stays visible. Only compatible sources enter cost totals." = "Chaque abonnement ou clé configuré reste visible. Seules les sources compatibles entrent dans les totaux de coût."; +"tracked sources" = "sources suivies"; +"Cost history connected" = "Historique des coûts connecté"; +"Cost history pending" = "Historique des coûts en attente"; +"Usage connected · not in cost total" = "Utilisation connectée · non incluse dans le total des coûts"; +"Configured · not in cost total" = "Configuré · non inclus dans le total des coûts"; "Estimated spend" = "Dépenses estimées"; "Tracked tokens" = "Jetons suivis"; "Subscriptions" = "Abonnements"; diff --git a/Sources/CodexBar/Resources/gl.lproj/Localizable.strings b/Sources/CodexBar/Resources/gl.lproj/Localizable.strings index 48c449014e..648344aa3d 100644 --- a/Sources/CodexBar/Resources/gl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/gl.lproj/Localizable.strings @@ -7,6 +7,7 @@ " providers" = " provedores"; "(System)" = "(Sistema)"; "30d" = "30d"; +"365d" = "365d"; "7d" = "7d"; "A managed Codex login is already running. Wait for it to finish before adding " = "Xa hai un inicio de sesión xestionado de Codex en curso. Agarda a que remate antes de engadir "; "API key" = "Chave de API"; @@ -1264,6 +1265,13 @@ "Model breakdown unavailable" = "Desglose por modelo non dispoñible"; "Local estimated history" = "Historial local estimado"; "Coverage" = "Cobertura"; +"Tracked access" = "Acceso rastrexado"; +"Every configured subscription or key stays visible. Only compatible sources enter cost totals." = "Cada subscrición ou clave configurada permanece visible. Só as fontes compatibles entran nos totais de custo."; +"tracked sources" = "fontes rastrexadas"; +"Cost history connected" = "Historial de custos conectado"; +"Cost history pending" = "Historial de custos pendente"; +"Usage connected · not in cost total" = "Uso conectado · non incluído no total de custo"; +"Configured · not in cost total" = "Configurado · non incluído no total de custo"; "Estimated spend" = "Gasto estimado"; "Tracked tokens" = "Tokens rexistrados"; "Subscriptions" = "Subscricións"; diff --git a/Sources/CodexBar/Resources/id.lproj/Localizable.strings b/Sources/CodexBar/Resources/id.lproj/Localizable.strings index 02a0d2649a..34432cecc5 100644 --- a/Sources/CodexBar/Resources/id.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/id.lproj/Localizable.strings @@ -7,6 +7,7 @@ " providers" = " penyedia"; "(System)" = "(Sistem)"; "30d" = "30 hari"; +"365d" = "365 hari"; "7d" = "7 hari"; "A managed Codex login is already running. Wait for it to finish before adding " = "Login Codex terkelola sudah berjalan. Tunggu hingga selesai sebelum menambahkan "; "API key" = "Kunci API"; @@ -1268,6 +1269,13 @@ "Model breakdown unavailable" = "Rincian per model tidak tersedia"; "Local estimated history" = "Riwayat perkiraan lokal"; "Coverage" = "Cakupan"; +"Tracked access" = "Akses terlacak"; +"Every configured subscription or key stays visible. Only compatible sources enter cost totals." = "Setiap langganan atau kunci yang dikonfigurasi tetap terlihat. Hanya sumber yang kompatibel yang masuk ke total biaya."; +"tracked sources" = "sumber terlacak"; +"Cost history connected" = "Riwayat biaya terhubung"; +"Cost history pending" = "Riwayat biaya tertunda"; +"Usage connected · not in cost total" = "Penggunaan terhubung · tidak termasuk dalam total biaya"; +"Configured · not in cost total" = "Dikonfigurasi · tidak termasuk dalam total biaya"; "Estimated spend" = "Perkiraan pengeluaran"; "Tracked tokens" = "Token yang dilacak"; "Subscriptions" = "Langganan"; diff --git a/Sources/CodexBar/Resources/it.lproj/Localizable.strings b/Sources/CodexBar/Resources/it.lproj/Localizable.strings index 73afb88890..f20741d299 100644 --- a/Sources/CodexBar/Resources/it.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/it.lproj/Localizable.strings @@ -7,6 +7,7 @@ " providers" = " provider"; "(System)" = "(Sistema)"; "30d" = "30 g"; +"365d" = "365 g"; "7d" = "7 g"; "A managed Codex login is already running. Wait for it to finish before adding " = "È già in corso un accesso gestito a Codex. Attendi che termini prima di aggiungere "; "API key" = "Chiave API"; @@ -1268,6 +1269,13 @@ "Model breakdown unavailable" = "Ripartizione per modello non disponibile"; "Local estimated history" = "Cronologia locale stimata"; "Coverage" = "Copertura"; +"Tracked access" = "Accesso tracciato"; +"Every configured subscription or key stays visible. Only compatible sources enter cost totals." = "Ogni abbonamento o chiave configurata rimane visibile. Solo le fonti compatibili entrano nei totali dei costi."; +"tracked sources" = "fonti tracciate"; +"Cost history connected" = "Cronologia costi connessa"; +"Cost history pending" = "Cronologia costi in sospeso"; +"Usage connected · not in cost total" = "Utilizzo connesso · non incluso nel totale dei costi"; +"Configured · not in cost total" = "Configurato · non incluso nel totale dei costi"; "Estimated spend" = "Spesa stimata"; "Tracked tokens" = "Token tracciati"; "Subscriptions" = "Abbonamenti"; diff --git a/Sources/CodexBar/Resources/ja.lproj/Localizable.strings b/Sources/CodexBar/Resources/ja.lproj/Localizable.strings index e720ee88cb..e479bc811e 100644 --- a/Sources/CodexBar/Resources/ja.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ja.lproj/Localizable.strings @@ -27,6 +27,7 @@ " providers" = " 件のプロバイダ"; "(System)" = "(システム)"; "30d" = "30日"; +"365d" = "365日"; "7d" = "7日"; "A managed Codex login is already running. Wait for it to finish before adding " = "管理対象の Codex ログインがすでに実行中です。完了を待ってから追加してください "; "API key" = "API キー"; @@ -1265,6 +1266,13 @@ "Model breakdown unavailable" = "モデル別の内訳を取得できません"; "Local estimated history" = "ローカル推定履歴"; "Coverage" = "対象範囲"; +"Tracked access" = "追跡中のアクセス"; +"Every configured subscription or key stays visible. Only compatible sources enter cost totals." = "設定済みのサブスクリプションやキーはすべて表示されたままになります。互換性のあるソースのみがコスト合計に含まれます。"; +"tracked sources" = "追跡中のソース"; +"Cost history connected" = "コスト履歴が接続済み"; +"Cost history pending" = "コスト履歴が保留中"; +"Usage connected · not in cost total" = "使用状況接続済み · コスト合計に含まれません"; +"Configured · not in cost total" = "設定済み · コスト合計に含まれません"; "Estimated spend" = "推定支出"; "Tracked tokens" = "追跡対象トークン"; "Subscriptions" = "サブスクリプション"; diff --git a/Sources/CodexBar/Resources/ko.lproj/Localizable.strings b/Sources/CodexBar/Resources/ko.lproj/Localizable.strings index b8e1a3929e..192d0c10f2 100644 --- a/Sources/CodexBar/Resources/ko.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ko.lproj/Localizable.strings @@ -27,6 +27,7 @@ " providers" = " 공급자"; "(System)" = "(시스템)"; "30d" = "30일"; +"365d" = "365일"; "7d" = "7일"; "A managed Codex login is already running. Wait for it to finish before adding " = "관리되는 Codex 로그인이 이미 실행 중입니다. 추가하기 전에 완료될 때까지 기다리세요. "; "API key" = "API 키"; @@ -1232,6 +1233,13 @@ "Model breakdown unavailable" = "모델별 내역을 사용할 수 없습니다"; "Local estimated history" = "로컬 예상 내역"; "Coverage" = "포함 범위"; +"Tracked access" = "추적된 접근"; +"Every configured subscription or key stays visible. Only compatible sources enter cost totals." = "구성된 모든 구독 또는 키가 계속 표시됩니다. 호환되는 소스만 비용 합계에 포함됩니다."; +"tracked sources" = "추적된 소스"; +"Cost history connected" = "비용 기록 연결됨"; +"Cost history pending" = "비용 기록 대기 중"; +"Usage connected · not in cost total" = "사용량 연결됨 · 비용 합계에 포함되지 않음"; +"Configured · not in cost total" = "구성됨 · 비용 합계에 포함되지 않음"; "Estimated spend" = "예상 지출"; "Tracked tokens" = "추적된 토큰"; "Subscriptions" = "구독"; diff --git a/Sources/CodexBar/Resources/nl.lproj/Localizable.strings b/Sources/CodexBar/Resources/nl.lproj/Localizable.strings index 9a7cc674f7..8775ac1308 100644 --- a/Sources/CodexBar/Resources/nl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/nl.lproj/Localizable.strings @@ -27,6 +27,7 @@ " providers" = " providers"; "(System)" = "(Systeem)"; "30d" = "30d"; +"365d" = "365d"; "7d" = "7d"; "A managed Codex login is already running. Wait for it to finish before adding " = "Er is al een beheerde Codex-aanmelding actief. Wacht tot het klaar is voordat je het toevoegt"; "API key" = "API-sleutel"; @@ -1264,6 +1265,13 @@ "Model breakdown unavailable" = "Uitsplitsing per model niet beschikbaar"; "Local estimated history" = "Lokaal geschatte geschiedenis"; "Coverage" = "Dekking"; +"Tracked access" = "Bijgehouden toegang"; +"Every configured subscription or key stays visible. Only compatible sources enter cost totals." = "Elk geconfigureerd abonnement of elke sleutel blijft zichtbaar. Alleen compatibele bronnen tellen mee in de kostentotalen."; +"tracked sources" = "bijgehouden bronnen"; +"Cost history connected" = "Kostengeschiedenis verbonden"; +"Cost history pending" = "Kostengeschiedenis in behandeling"; +"Usage connected · not in cost total" = "Gebruik verbonden · niet in het kostentotaal"; +"Configured · not in cost total" = "Geconfigureerd · niet in het kostentotaal"; "Estimated spend" = "Geschatte uitgaven"; "Tracked tokens" = "Bijgehouden tokens"; "Subscriptions" = "Abonnementen"; diff --git a/Sources/CodexBar/Resources/pl.lproj/Localizable.strings b/Sources/CodexBar/Resources/pl.lproj/Localizable.strings index 0481d41a78..dc87c9bc41 100644 --- a/Sources/CodexBar/Resources/pl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/pl.lproj/Localizable.strings @@ -7,6 +7,7 @@ " providers" = " providers"; "(System)" = "(System)"; "30d" = "30d"; +"365d" = "365d"; "7d" = "7d"; "A managed Codex login is already running. Wait for it to finish before adding " = "Trwa już zarządzane logowanie Codex. Poczekaj na jego zakończenie, zanim dodasz "; "API key" = "Klucz API"; @@ -1268,6 +1269,13 @@ "Model breakdown unavailable" = "Podział według modeli jest niedostępny"; "Local estimated history" = "Lokalna historia szacunkowa"; "Coverage" = "Pokrycie"; +"Tracked access" = "Śledzony dostęp"; +"Every configured subscription or key stays visible. Only compatible sources enter cost totals." = "Każda skonfigurowana subskrypcja lub klucz pozostaje widoczna. Tylko kompatybilne źródła wchodzą do sum kosztów."; +"tracked sources" = "śledzone źródła"; +"Cost history connected" = "Historia kosztów połączona"; +"Cost history pending" = "Historia kosztów oczekująca"; +"Usage connected · not in cost total" = "Użycie połączone · nie wliczane do sumy kosztów"; +"Configured · not in cost total" = "Skonfigurowane · nie wliczane do sumy kosztów"; "Estimated spend" = "Szacowane wydatki"; "Tracked tokens" = "Śledzone tokeny"; "Subscriptions" = "Subskrypcje"; diff --git a/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings b/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings index fab4035f02..302e14c61f 100644 --- a/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings @@ -27,6 +27,7 @@ " providers" = " provedores"; "(System)" = "(Sistema)"; "30d" = "30d"; +"365d" = "365d"; "7d" = "7d"; "A managed Codex login is already running. Wait for it to finish before adding " = "Um login gerenciado do Codex já está em andamento. Aguarde terminar antes de adicionar "; "API key" = "Chave de API"; @@ -1265,6 +1266,13 @@ "Model breakdown unavailable" = "Detalhamento por modelo indisponível"; "Local estimated history" = "Histórico local estimado"; "Coverage" = "Cobertura"; +"Tracked access" = "Acesso rastreado"; +"Every configured subscription or key stays visible. Only compatible sources enter cost totals." = "Cada assinatura ou chave configurada permanece visível. Apenas fontes compatíveis entram nos totais de custo."; +"tracked sources" = "fontes rastreadas"; +"Cost history connected" = "Histórico de custos conectado"; +"Cost history pending" = "Histórico de custos pendente"; +"Usage connected · not in cost total" = "Uso conectado · não incluído no total de custo"; +"Configured · not in cost total" = "Configurado · não incluído no total de custo"; "Estimated spend" = "Gastos estimados"; "Tracked tokens" = "Tokens acompanhados"; "Subscriptions" = "Assinaturas"; diff --git a/Sources/CodexBar/Resources/ru.lproj/Localizable.strings b/Sources/CodexBar/Resources/ru.lproj/Localizable.strings index 5da7117d9e..38a9004e1e 100644 --- a/Sources/CodexBar/Resources/ru.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ru.lproj/Localizable.strings @@ -27,6 +27,7 @@ " providers" = " провайдеров"; "(System)" = "(Система)"; "30d" = "30 дн."; +"365d" = "365 дн."; "7d" = "7 дн."; "A managed Codex login is already running. Wait for it to finish before adding " = "Управляемый вход Codex уже активен. Подождите, пока он завершится, прежде чем добавлять "; "API key" = "API-ключ"; @@ -1266,6 +1267,13 @@ "Model breakdown unavailable" = "Разбивка по моделям недоступна"; "Local estimated history" = "Локальная история оценок"; "Coverage" = "Охват"; +"Tracked access" = "Отслеживаемый доступ"; +"Every configured subscription or key stays visible. Only compatible sources enter cost totals." = "Каждая настроенная подписка или ключ остаются видимыми. Только совместимые источники входят в итоговые суммы затрат."; +"tracked sources" = "отслеживаемые источники"; +"Cost history connected" = "История затрат подключена"; +"Cost history pending" = "История затрат ожидается"; +"Usage connected · not in cost total" = "Использование подключено · не входит в сумму затрат"; +"Configured · not in cost total" = "Настроено · не входит в сумму затрат"; "Estimated spend" = "Предполагаемые расходы"; "Tracked tokens" = "Отслеживаемые токены"; "Subscriptions" = "Подписки"; diff --git a/Sources/CodexBar/Resources/sv.lproj/Localizable.strings b/Sources/CodexBar/Resources/sv.lproj/Localizable.strings index a22447abf3..c664a490b1 100644 --- a/Sources/CodexBar/Resources/sv.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/sv.lproj/Localizable.strings @@ -27,6 +27,7 @@ " providers" = " leverantörer"; "(System)" = "(System)"; "30d" = "30 d"; +"365d" = "365 d"; "7d" = "7 d"; "A managed Codex login is already running. Wait for it to finish before adding " = "En hanterad Codex-inloggning körs redan. Vänta tills den är klar innan du lägger till "; "API key" = "API-nyckel"; @@ -1263,6 +1264,13 @@ "Model breakdown unavailable" = "Modellfördelning ej tillgänglig"; "Local estimated history" = "Lokal uppskattad historik"; "Coverage" = "Täckning"; +"Tracked access" = "Spårad åtkomst"; +"Every configured subscription or key stays visible. Only compatible sources enter cost totals." = "Varje konfigurerad prenumeration eller nyckel förblir synlig. Endast kompatibla källor ingår i kostnadssummorna."; +"tracked sources" = "spårade källor"; +"Cost history connected" = "Kostnadshistorik ansluten"; +"Cost history pending" = "Kostnadshistorik väntar"; +"Usage connected · not in cost total" = "Användning ansluten · ingår inte i kostnadssumman"; +"Configured · not in cost total" = "Konfigurerad · ingår inte i kostnadssumman"; "Estimated spend" = "Uppskattade utgifter"; "Tracked tokens" = "Spårade token"; "Subscriptions" = "Abonnemang"; diff --git a/Sources/CodexBar/Resources/th.lproj/Localizable.strings b/Sources/CodexBar/Resources/th.lproj/Localizable.strings index 13159db68d..16289e64b6 100644 --- a/Sources/CodexBar/Resources/th.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/th.lproj/Localizable.strings @@ -7,6 +7,7 @@ " providers" = "ผู้ให้บริการ "; "(System)" = "(ระบบ)"; "30d" = "30 วัน"; +"365d" = "365 วัน"; "7d" = "7 วัน"; "A managed Codex login is already running. Wait for it to finish before adding " = "การเข้าสู่ระบบ Codex ที่มีการจัดการกําลังทํางานอยู่แล้ว รอให้เสร็จก่อนที่จะเพิ่ม "; "API key" = "ปุ่ม API"; @@ -1268,6 +1269,13 @@ "Model breakdown unavailable" = "ไม่มีรายละเอียดแยกตามโมเดล"; "Local estimated history" = "ประวัติโดยประมาณในเครื่อง"; "Coverage" = "ความครอบคลุม"; +"Tracked access" = "การเข้าถึงที่ติดตาม"; +"Every configured subscription or key stays visible. Only compatible sources enter cost totals." = "การสมัครสมาชิกหรือคีย์ที่กำหนดค่าไว้ทุกรายการยังคงมองเห็นได้ เฉพาะแหล่งที่เข้ากันได้เท่านั้นที่นับรวมในยอดรวมต้นทุน"; +"tracked sources" = "แหล่งที่ติดตาม"; +"Cost history connected" = "เชื่อมต่อประวัติต้นทุนแล้ว"; +"Cost history pending" = "ประวัติต้นทุนรอดำเนินการ"; +"Usage connected · not in cost total" = "เชื่อมต่อการใช้งานแล้ว · ไม่รวมในยอดรวมต้นทุน"; +"Configured · not in cost total" = "กำหนดค่าแล้ว · ไม่รวมในยอดรวมต้นทุน"; "Estimated spend" = "ค่าใช้จ่ายโดยประมาณ"; "Tracked tokens" = "โทเค็นที่ติดตาม"; "Subscriptions" = "การสมัครสมาชิก"; diff --git a/Sources/CodexBar/Resources/tr.lproj/Localizable.strings b/Sources/CodexBar/Resources/tr.lproj/Localizable.strings index e7ced159dd..98d90fc9eb 100644 --- a/Sources/CodexBar/Resources/tr.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/tr.lproj/Localizable.strings @@ -7,6 +7,7 @@ " providers" = " sağlayıcı"; "(System)" = "(Sistem)"; "30d" = "30 gün"; +"365d" = "365 gün"; "7d" = "7 gün"; "A managed Codex login is already running. Wait for it to finish before adding " = "Yönetilen bir Codex girişi zaten çalışıyor. Eklemeden önce bitmesini bekleyin "; "API key" = "API anahtarı"; @@ -1266,6 +1267,13 @@ "Model breakdown unavailable" = "Model dökümü kullanılamıyor"; "Local estimated history" = "Yerel tahmini geçmiş"; "Coverage" = "Kapsam"; +"Tracked access" = "İzlenen erişim"; +"Every configured subscription or key stays visible. Only compatible sources enter cost totals." = "Yapılandırılan her abonelik veya anahtar görünür kalır. Yalnızca uyumlu kaynaklar maliyet toplamlarına girer."; +"tracked sources" = "izlenen kaynaklar"; +"Cost history connected" = "Maliyet geçmişi bağlandı"; +"Cost history pending" = "Maliyet geçmişi beklemede"; +"Usage connected · not in cost total" = "Kullanım bağlandı · maliyet toplamına dahil değil"; +"Configured · not in cost total" = "Yapılandırıldı · maliyet toplamına dahil değil"; "Estimated spend" = "Tahmini harcama"; "Tracked tokens" = "İzlenen tokenlar"; "Subscriptions" = "Abonelikler"; diff --git a/Sources/CodexBar/Resources/uk.lproj/Localizable.strings b/Sources/CodexBar/Resources/uk.lproj/Localizable.strings index 28fa543599..8b8659063f 100644 --- a/Sources/CodexBar/Resources/uk.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/uk.lproj/Localizable.strings @@ -27,6 +27,7 @@ " providers" = "провайдерів"; "(System)" = "(Система)"; "30d" = "30д"; +"365d" = "365д"; "7d" = "7д"; "A managed Codex login is already running. Wait for it to finish before adding " = "Керований вхід до Codex вже запущено. Перш ніж додавати, зачекайте, поки він закінчиться"; "API key" = "Ключ API"; @@ -1264,6 +1265,13 @@ "Model breakdown unavailable" = "Розподіл за моделями недоступний"; "Local estimated history" = "Локальна історія оцінок"; "Coverage" = "Охоплення"; +"Tracked access" = "Відстежуваний доступ"; +"Every configured subscription or key stays visible. Only compatible sources enter cost totals." = "Кожна налаштована підписка або ключ залишаються видимими. Лише сумісні джерела входять до підсумкових сум витрат."; +"tracked sources" = "відстежувані джерела"; +"Cost history connected" = "Історію витрат підключено"; +"Cost history pending" = "Історія витрат очікується"; +"Usage connected · not in cost total" = "Використання підключено · не входить до суми витрат"; +"Configured · not in cost total" = "Налаштовано · не входить до суми витрат"; "Estimated spend" = "Орієнтовні витрати"; "Tracked tokens" = "Відстежувані токени"; "Subscriptions" = "Підписки"; diff --git a/Sources/CodexBar/Resources/vi.lproj/Localizable.strings b/Sources/CodexBar/Resources/vi.lproj/Localizable.strings index e6c6fa03b9..fc0958a630 100644 --- a/Sources/CodexBar/Resources/vi.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/vi.lproj/Localizable.strings @@ -27,6 +27,7 @@ " providers" = "nhà cung cấp"; "(System)" = "(Hệ thống)"; "30d" = "30d"; +"365d" = "365d"; "7d" = "7d"; "A managed Codex login is already running. Wait for it to finish before adding " = "Đăng nhập Codex được quản lý đã chạy. Đợi quá trình này hoàn tất trước khi thêm"; "API key" = "API khóa"; @@ -1265,6 +1266,13 @@ "Model breakdown unavailable" = "Phân tích theo mô hình không khả dụng"; "Local estimated history" = "Lịch sử ước tính cục bộ"; "Coverage" = "Phạm vi"; +"Tracked access" = "Truy cập được theo dõi"; +"Every configured subscription or key stays visible. Only compatible sources enter cost totals." = "Mọi gói đăng ký hoặc khóa đã cấu hình đều vẫn hiển thị. Chỉ các nguồn tương thích mới được tính vào tổng chi phí."; +"tracked sources" = "nguồn được theo dõi"; +"Cost history connected" = "Đã kết nối lịch sử chi phí"; +"Cost history pending" = "Lịch sử chi phí đang chờ"; +"Usage connected · not in cost total" = "Đã kết nối mức sử dụng · không tính vào tổng chi phí"; +"Configured · not in cost total" = "Đã cấu hình · không tính vào tổng chi phí"; "Estimated spend" = "Chi tiêu ước tính"; "Tracked tokens" = "Token được theo dõi"; "Subscriptions" = "Gói đăng ký"; diff --git a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings index 3e02a4cc05..16a6816ebf 100644 --- a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings @@ -27,6 +27,7 @@ " providers" = " 提供商"; "(System)" = "(System)"; "30d" = "30 天"; +"365d" = "365 天"; "7d" = "7 天"; "A managed Codex login is already running. Wait for it to finish before adding " = "托管 Codex 登录已在运行。请等待其完成后再添加 "; "API key" = "API 密钥"; @@ -1240,6 +1241,13 @@ "Model breakdown unavailable" = "模型明细不可用"; "Local estimated history" = "本地估算历史"; "Coverage" = "覆盖范围"; +"Tracked access" = "已跟踪的访问"; +"Every configured subscription or key stays visible. Only compatible sources enter cost totals." = "每个已配置的订阅或密钥都保持可见。只有兼容的来源才会计入成本总额。"; +"tracked sources" = "已跟踪的来源"; +"Cost history connected" = "成本历史已连接"; +"Cost history pending" = "成本历史待处理"; +"Usage connected · not in cost total" = "用量已连接 · 不计入成本总额"; +"Configured · not in cost total" = "已配置 · 不计入成本总额"; "Estimated spend" = "估算支出"; "Tracked tokens" = "已跟踪 token"; "Subscriptions" = "订阅"; diff --git a/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings b/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings index 85f686be4a..ed6ad97125 100644 --- a/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings @@ -27,6 +27,7 @@ " providers" = " 提供者"; "(System)" = "(系統)"; "30d" = "30 天"; +"365d" = "365 天"; "7d" = "7 天"; "A managed Codex login is already running. Wait for it to finish before adding " = "託管 Codex 登入已在執行。請等待其完成後再新增 "; "API key" = "API 金鑰"; @@ -1295,6 +1296,13 @@ "Model breakdown unavailable" = "無法取得模型明細"; "Local estimated history" = "本機預估歷史"; "Coverage" = "涵蓋範圍"; +"Tracked access" = "已追蹤的存取"; +"Every configured subscription or key stays visible. Only compatible sources enter cost totals." = "每個已設定的訂閱或密鑰都保持可見。只有相容的來源才會計入成本總額。"; +"tracked sources" = "已追蹤的來源"; +"Cost history connected" = "成本歷史已連接"; +"Cost history pending" = "成本歷史待處理"; +"Usage connected · not in cost total" = "用量已連接 · 不計入成本總額"; +"Configured · not in cost total" = "已設定 · 不計入成本總額"; "Estimated spend" = "預估支出"; "Tracked tokens" = "已追蹤 token"; "Subscriptions" = "訂閱"; diff --git a/Sources/CodexBar/SettingsStore+Defaults.swift b/Sources/CodexBar/SettingsStore+Defaults.swift index 23afa6fa17..05801c401e 100644 --- a/Sources/CodexBar/SettingsStore+Defaults.swift +++ b/Sources/CodexBar/SettingsStore+Defaults.swift @@ -525,6 +525,14 @@ extension SettingsStore { } } + var effectiveCostUsageHistoryDays: Int { + max(self.costUsageHistoryDays, self.spendDashboardHistoryDaysOverride ?? 0) + } + + func setSpendDashboardHistoryDaysOverride(_ days: Int?) { + self.spendDashboardHistoryDaysOverride = days.map { max(1, min(365, $0)) } + } + var costComparisonPeriodsEnabled: Bool { get { self.defaultsState.costComparisonPeriodsEnabled } set { diff --git a/Sources/CodexBar/SettingsStore.swift b/Sources/CodexBar/SettingsStore.swift index b6bb983c6b..f241fc75d5 100644 --- a/Sources/CodexBar/SettingsStore.swift +++ b/Sources/CodexBar/SettingsStore.swift @@ -230,6 +230,7 @@ final class SettingsStore { var providerDetailSettingsRevision: Int = 0 var backgroundWorkSettingsRevision: Int = 0 var costUsageSettingsRevision: UInt64 = 0 + @ObservationIgnored var spendDashboardHistoryDaysOverride: Int? var providerOrder: [UsageProvider] = [] var providerEnablement: [UsageProvider: Bool] = [:] @ObservationIgnored var providerEnablementRevisions: [UsageProvider: UInt64] = [:] diff --git a/Sources/CodexBar/ShareStatsCardView.swift b/Sources/CodexBar/ShareStatsCardView.swift index 1cd70ccfe2..0d19a79bd5 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,17 +107,38 @@ 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 - ? "+\(hiddenCount) more currencies · see subscription rows" - : "\(self.payload.providers.count) subscriptions · native currencies kept separate" + if hiddenCount > 0 { + return "+\(hiddenCount) more currencies · see subscription rows" + } + return "\(self.payload.trackedSourceCount) sources tracked · " + + "\(self.payload.providers.count) with cost history" } private var rankings: some View { HStack(alignment: .top, spacing: 46) { VStack(alignment: .leading, spacing: 6) { - self.sectionHeader("SUBSCRIPTIONS", detail: "\(self.payload.providers.count) CONNECTED") + self.sectionHeader("COST SOURCES", detail: "\(self.payload.providers.count) WITH HISTORY") ForEach( Array(self.payload.providers.prefix(self.providerDisplayLimit).enumerated()), id: \.offset) @@ -224,7 +243,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..7f2c63ff02 --- /dev/null +++ b/Sources/CodexBar/ShareStatsModelActivityCardView.swift @@ -0,0 +1,534 @@ +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)) + } + + static func weekCount(for dayCount: Int) -> Int { + guard dayCount > 0 else { return 0 } + return Int(ceil(Double(dayCount) / 7.0)) + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + ShareStatsActivityHeader(payload: self.payload) + ShareStatsActivityMetrics(payload: self.payload) + .padding(.top, 24) + Rectangle() + .fill(ShareStatsActivityBrand.rule) + .frame(height: 1) + .padding(.top, 22) + ShareStatsRoutes(payload: self.payload) + .padding(.top, 20) + ShareStatsWeekActivity(payload: self.payload) + .padding(.top, 22) + Spacer(minLength: 18) + ShareStatsActivityFooter(payload: self.payload) + } + .padding(.horizontal, 44) + .padding(.top, 32) + .padding(.bottom, 27) + .frame(width: Self.size.width, height: Self.size.height, alignment: .topLeading) + .background(ShareStatsActivityBackground()) + .foregroundStyle(ShareStatsActivityBrand.primary) + .environment(\.colorScheme, .dark) + } +} + +private struct ShareStatsActivityHeader: View { + let payload: ShareStatsPayload + + private var calendar: Calendar { + Calendar.current + } + + private var periodStart: Date { + self.calendar.date( + byAdding: .day, + value: -(self.payload.days - 1), + to: self.calendar.startOfDay(for: self.payload.periodEnd)) ?? self.payload.periodEnd + } + + var body: some View { + HStack(alignment: .center, spacing: 13) { + Image(nsImage: ShareStatsActivityBrand.appIcon) + .resizable() + .interpolation(.high) + .frame(width: 30, height: 30) + .clipShape(RoundedRectangle(cornerRadius: 7)) + .overlay { + RoundedRectangle(cornerRadius: 7) + .stroke(Color.white.opacity(0.14), lineWidth: 1) + } + Text("CodexBar") + .font(.system(size: 21, weight: .semibold)) + .tracking(-0.35) + Rectangle() + .fill(ShareStatsActivityBrand.rule) + .frame(width: 1, height: 24) + .padding(.horizontal, 2) + Text("MODEL ACTIVITY") + .font(ShareStatsActivityBrand.mono(size: 13, weight: .bold)) + .tracking(1.25) + .foregroundStyle(ShareStatsActivityBrand.secondary) + Spacer() + Text( + "\(ShareStatsFormatting.shortRange(from: self.periodStart, through: self.payload.periodEnd))" + + " · \(self.payload.days) DAYS") + .font(ShareStatsActivityBrand.mono(size: 13, weight: .bold)) + .tracking(0.45) + .foregroundStyle(ShareStatsActivityBrand.secondary) + } + .frame(height: 30) + } +} + +private struct ShareStatsActivityMetrics: View { + let payload: ShareStatsPayload + + var body: some View { + HStack(alignment: .top, spacing: 0) { + self.metric( + value: self.tokenHeadline, + label: self.payload.tokenCoverageIsComplete ? "TRACKED TOKENS" : "KNOWN TOKENS", + detail: self.tokenDetail, + color: ShareStatsActivityBrand.primary) + .frame(width: 388, alignment: .leading) + self.separator + self.metric( + value: self.spendHeadline, + label: "ESTIMATED TOKEN SPEND", + detail: self.spendDetail, + color: ShareStatsActivityBrand.coral) + .frame(width: 385, alignment: .leading) + .padding(.leading, 28) + self.separator + self.metric( + value: self.activeDayHeadline, + label: self.activeDayLabel, + detail: self.activityDetail, + color: ShareStatsActivityBrand.teal) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.leading, 28) + } + .frame(height: 104, alignment: .top) + } + + private func metric(value: String, label: String, detail: String, color: Color) -> some View { + VStack(alignment: .leading, spacing: 2) { + Text(value) + .font(.system(size: 47, weight: .semibold)) + .tracking(-2.0) + .monospacedDigit() + .foregroundStyle(color) + .lineLimit(1) + .minimumScaleFactor(0.62) + Text(label) + .font(ShareStatsActivityBrand.mono(size: 12, weight: .bold)) + .tracking(0.75) + .foregroundStyle(ShareStatsActivityBrand.secondary) + Text(detail) + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(ShareStatsActivityBrand.tertiary) + .lineLimit(1) + .minimumScaleFactor(0.75) + } + } + + private var separator: some View { + Rectangle() + .fill(ShareStatsActivityBrand.rule) + .frame(width: 1, height: 95) + } + + 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 tokenDetail: String { + "\(self.payload.tokenSourceCount) of \(self.payload.providers.count) sources reported totals" + } + + 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: " · ") + return pricedCurrencies.count > 2 ? "\(shown) +\(pricedCurrencies.count - 2)" : shown + } + + private var spendDetail: String { + let pricedSourceCount = self.payload.providers.count { $0.estimatedCost != nil } + guard pricedSourceCount > 0 else { return "Pricing unavailable for tracked routes" } + let isPartial = self.payload.currencies.contains { + $0.estimatedCost != nil + && ($0.pricedSourceCount < $0.sourceCount || $0.coveredDayCount < self.payload.days) + } + let coverage = isPartial ? "Known lower bound · " : "" + return "\(coverage)\(pricedSourceCount) of \(self.payload.providers.count) sources priced" + } + + 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 { + let qualifier = !self.payload.dailyCoverageIsComplete || self.payload.hasUnavailableDailyTotals + ? "KNOWN ACTIVE DAYS" + : "DAYS ACTIVE" + return "\(qualifier) · OF \(self.payload.days)" + } + + private var activityDetail: String { + guard self.payload.dailySourceCount > 0 else { return "Daily activity unavailable" } + if self.payload.dailyCoverageIsComplete { + return "\(self.payload.dailySourceCount) of \(self.payload.providers.count) sources with full history" + } + return "\(self.payload.dailyFullSourceCount) of \(self.payload.providers.count) sources with full history" + } +} + +private struct ShareStatsRoutes: View { + let payload: ShareStatsPayload + + private var visibleModels: [ShareStatsModelPayload] { + Array(self.payload.topModels.prefix(3)) + } + + private var maximumTokens: Int { + self.visibleModels.compactMap(\.totalTokens).max() ?? 0 + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + HStack(alignment: .firstTextBaseline) { + Text("TOP MODEL ROUTES") + Spacer() + Text(self.routeHeaderDetail) + } + .font(ShareStatsActivityBrand.mono(size: 12, weight: .bold)) + .tracking(0.75) + .foregroundStyle(ShareStatsActivityBrand.secondary) + .padding(.bottom, 10) + + if self.visibleModels.isEmpty { + Text("Model breakdown unavailable for this local snapshot") + .font(.system(size: 18, weight: .medium)) + .foregroundStyle(ShareStatsActivityBrand.secondary) + .frame(height: 120, alignment: .leading) + } else { + ForEach(Array(self.visibleModels.enumerated()), id: \.offset) { index, model in + ShareStatsRouteRow( + model: model, + maximumTokens: self.maximumTokens, + color: ShareStatsActivityBrand.routeColor(at: index)) + } + } + + HStack(spacing: 8) { + Text(self.routeOverflowDetail) + Spacer() + Text( + "\(self.payload.trackedSourceCount) tracked · " + + "\(self.payload.providers.count) with cost history") + } + .font(ShareStatsActivityBrand.mono(size: 11, weight: .semibold)) + .foregroundStyle(ShareStatsActivityBrand.tertiary) + .padding(.top, 8) + } + } + + private var routeHeaderDetail: String { + guard !self.payload.topModels.isEmpty else { return "UNAVAILABLE" } + return "\(self.visibleModels.count) OF \(self.payload.topModels.count) SHAREABLE ROUTES" + } + + private var routeOverflowDetail: String { + var details: [String] = [] + let overflowCount = max(0, self.payload.topModels.count - self.visibleModels.count) + 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 ShareStatsRouteRow: View { + let model: ShareStatsModelPayload + let maximumTokens: Int + let color: Color + + var body: some View { + VStack(alignment: .leading, spacing: 5) { + HStack(alignment: .firstTextBaseline, spacing: 10) { + Circle() + .fill(self.color) + .frame(width: 8, height: 8) + Text(self.model.modelName) + .font(.system(size: 18, weight: .semibold)) + .tracking(-0.25) + .lineLimit(1) + .minimumScaleFactor(0.8) + Text("via \(self.model.sourceName)") + .font(.system(size: 14, weight: .medium)) + .foregroundStyle(ShareStatsActivityBrand.secondary) + .lineLimit(1) + Spacer(minLength: 12) + Text(self.detail) + .font(ShareStatsActivityBrand.mono(size: 13, weight: .bold)) + .foregroundStyle(ShareStatsActivityBrand.secondary) + .lineLimit(1) + } + GeometryReader { proxy in + Capsule() + .fill(Color.white.opacity(0.065)) + .overlay(alignment: .leading) { + if let totalTokens = self.model.totalTokens, self.maximumTokens > 0 { + Capsule() + .fill(self.color.opacity(0.9)) + .frame( + width: max( + 4, + proxy.size.width * CGFloat(totalTokens) / CGFloat(self.maximumTokens))) + } + } + } + .frame(height: 4) + } + .frame(height: 49) + .overlay(alignment: .bottom) { + Rectangle() + .fill(ShareStatsActivityBrand.rule.opacity(0.65)) + .frame(height: 1) + } + } + + private var detail: String { + if let totalTokens = self.model.totalTokens { + return "\(ShareStatsFormatting.compactCount(totalTokens)) TOKENS" + } + if let estimatedCost = self.model.estimatedCost, estimatedCost.isFinite { + return "~\(ShareStatsFormatting.currency(estimatedCost, code: self.model.currencyCode))" + } + return "USED" + } +} + +private struct ShareStatsWeekActivity: 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 + } + + private var cells: [ShareStatsWeekCell] { + var points: [Date: Int?] = [:] + for point in self.payload.dailyTokens { + points[self.calendar.startOfDay(for: point.day)] = point.totalTokens + } + + let weekCount = ShareStatsModelActivityCardView.weekCount(for: self.payload.days) + let totals = (0.. (total: Int?, isPartial: Bool) in + let firstOffset = weekIndex * 7 + let lastOffset = min(self.payload.days, firstOffset + 7) + var knownTotal = 0 + var hasKnownValue = false + var isPartial = false + var overflowed = false + + for dayOffset in firstOffset.. CGFloat { + guard let level else { return 8 } + return level == 0 ? 8 : 8 + CGFloat(level) * 8 + } +} + +private struct ShareStatsWeekCell: Identifiable { + let id: Int + let level: Int? + let isPartial: Bool +} + +private struct ShareStatsActivityFooter: View { + let payload: ShareStatsPayload + + var body: some View { + HStack(spacing: 8) { + Image(systemName: "lock.shield") + .font(.system(size: 11, weight: .semibold)) + Text("\(self.payload.days) DAYS · AGGREGATED LOCALLY · NO PROMPTS SHARED") + Spacer() + Text("DATA THROUGH \(ShareStatsFormatting.dataThrough(self.payload.periodEnd).uppercased())") + } + .font(ShareStatsActivityBrand.mono(size: 11, weight: .bold)) + .tracking(0.35) + .foregroundStyle(ShareStatsActivityBrand.secondary) + } +} + +private struct ShareStatsActivityBackground: View { + var body: some View { + ZStack { + ShareStatsActivityBrand.canvas + RadialGradient( + colors: [ShareStatsActivityBrand.coral.opacity(0.10), .clear], + center: .topLeading, + startRadius: 0, + endRadius: 520) + LinearGradient( + colors: [Color.white.opacity(0.018), .clear], + startPoint: .top, + endPoint: .bottom) + } + } +} + +@MainActor +private enum ShareStatsActivityBrand { + static let appIcon: NSImage = Bundle.module + .url(forResource: "Icon-classic", withExtension: "icns") + .flatMap(NSImage.init(contentsOf:)) + ?? NSApplication.shared.applicationIconImage + + static let canvas = Color(red: 20.0 / 255.0, green: 18.0 / 255.0, blue: 16.0 / 255.0) + static let primary = Color(red: 246.0 / 255.0, green: 241.0 / 255.0, blue: 234.0 / 255.0) + static let secondary = Color(red: 176.0 / 255.0, green: 169.0 / 255.0, blue: 160.0 / 255.0) + static let tertiary = Color(red: 132.0 / 255.0, green: 126.0 / 255.0, blue: 119.0 / 255.0) + static let coral = Color(red: 239.0 / 255.0, green: 131.0 / 255.0, blue: 94.0 / 255.0) + static let teal = Color(red: 85.0 / 255.0, green: 183.0 / 255.0, blue: 173.0 / 255.0) + static let amber = Color(red: 226.0 / 255.0, green: 181.0 / 255.0, blue: 102.0 / 255.0) + static let rule = Color.white.opacity(0.13) + + static func mono(size: CGFloat, weight: Font.Weight) -> Font { + .system(size: size, weight: weight, design: .monospaced) + } + + static func routeColor(at index: Int) -> Color { + [self.teal, self.coral, self.amber][index % 3] + } + + static func activity(level: Int?) -> Color { + guard let level else { return Color.white.opacity(0.025) } + switch level { + case 1: return self.teal.opacity(0.26) + case 2: return self.teal.opacity(0.42) + case 3: return self.teal.opacity(0.60) + case 4: return self.teal.opacity(0.78) + case 5: return self.teal + default: return Color.white.opacity(0.065) + } + } +} diff --git a/Sources/CodexBar/ShareStatsPayload.swift b/Sources/CodexBar/ShareStatsPayload.swift index 744f41ee17..5eef21fe91 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,23 +92,61 @@ 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 + let trackedSourceCount: Int + + var dailyCoverageIsComplete: Bool { + !self.providers.isEmpty && self.dailyFullSourceCount == self.providers.count + } + + var tokenCoverageIsComplete: Bool { + self.totalTokens != nil + && self.tokenSourceCount == self.providers.count + && self.providers.allSatisfy { $0.coveredDayCount >= self.days } + } + + var hasUnavailableDailyTotals: Bool { + self.dailyTokens.contains { $0.totalTokens == nil } + } + + var hasModelActivityData: Bool { + self.providers.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 || $0.estimatedCost != nil } } } @@ -234,6 +279,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 +291,164 @@ 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: [ + "fable", "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", "codex", "coder", "deep", "fable", "flash", "haiku", + "image", "instant", "instruct", "large", "lite", "luna", "max", "medium", "micro", + "mini", "nano", "nightly", "opus", "plus", "premier", "preview", "pro", "r", + "reasoning", "reel", "small", "sol", "sonic", "sonnet", "spark", "speech", "terra", + "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) + if ["pro", "sol"].contains(value.lowercased()) { + return value.capitalized + } + return value.count <= 3 ? value.uppercased() : value.capitalized + } + .joined(separator: " ") } private static func safeLabel( @@ -315,13 +485,17 @@ enum ShareStatsSanitizer { enum ShareStatsBuilder { static func make( model: SpendDashboardModel, - subscriptionNames: [String: ShareStatsSubscriptionName] = [:]) -> ShareStatsPayload? + subscriptionNames: [String: ShareStatsSubscriptionName] = [:], + trackedSources: [SpendDashboardTrackedSource] = []) -> 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 +503,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 +544,95 @@ 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 + && $0.providers.allSatisfy { $0.coveredDayCount >= model.requestedDays } + }, + totalTokens: totalTokens, + tokenSourceCount: knownTokenTotals.count, + trackedSourceCount: trackedSources.isEmpty ? providers.count : trackedSources.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 +674,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 +741,64 @@ 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 { + let routeLabel = payload.hiddenModelRouteCount == 1 ? "route name" : "route names" + lines.append("\(payload.hiddenModelRouteCount) private \(routeLabel) omitted") + } + if !payload.modelRouteCoverageIsComplete { + lines.append("Model route history is partial") + } + lines.append("\(payload.trackedSourceCount) sources tracked") + if payload.trackedSourceCount > payload.providers.count { + lines.append( + "\(payload.providers.count) with cost history · " + + "\(payload.trackedSourceCount - payload.providers.count) excluded from cost totals") + } + 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..17bc4c6611 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,54 @@ 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? + @State private var statusIsError = false var body: some View { - VStack(spacing: 20) { - ShareStatsScaledPreview(payload: self.payload) + VStack(alignment: .leading, spacing: 18) { + HStack(alignment: .top, spacing: 24) { + VStack(alignment: .leading, spacing: 4) { + Text(L("Share AI Usage")) + .font(.title2.weight(.semibold)) + Text(L("Nothing is uploaded. This image is created on your Mac.")) + .font(.subheadline) + .foregroundStyle(.secondary) + } + Spacer(minLength: 16) + if self.payload.hasModelActivityData { + 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,22 +110,26 @@ private struct ShareStatsPreviewView: View { HStack(spacing: 12) { Button { - self.statusMessage = self.copyImage() ? L("Image copied") : L("Could not copy image") + let didCopy = self.copyImage(self.style) + self.statusMessage = didCopy ? L("Image copied") : L("Could not copy image") + self.statusIsError = !didCopy } label: { Label(L("Copy Image"), systemImage: "photo.on.rectangle") } .keyboardShortcut(.defaultAction) Button { - self.copyText() + self.copyText(self.style) self.statusMessage = L("Stats copied") + self.statusIsError = false } label: { Label(L("Copy Stats"), systemImage: "doc.on.doc") } Button { - if self.saveImage() { + if self.saveImage(self.style) { self.statusMessage = L("Image saved") + self.statusIsError = false } } label: { Label(L("Save..."), systemImage: "square.and.arrow.down") @@ -111,28 +137,41 @@ private struct ShareStatsPreviewView: View { Spacer() - Text(self.statusMessage ?? L("Nothing is uploaded. This image is created on your Mac.")) - .font(.footnote) - .foregroundStyle(.secondary) - .accessibilityLabel(self - .statusMessage ?? L("Nothing is uploaded. This image is created on your Mac.")) + if let statusMessage = self.statusMessage { + Label( + statusMessage, + systemImage: self.statusIsError + ? "exclamationmark.circle.fill" + : "checkmark.circle.fill") + .font(.footnote.weight(.medium)) + .foregroundStyle(self.statusIsError ? Color.red : Color.secondary) + } } } .padding(24) - .frame(minWidth: 780, minHeight: 525) + .frame(minWidth: 780, minHeight: 570) + .background(Color(nsColor: .windowBackgroundColor)) } } 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/SpendDashboardController.swift b/Sources/CodexBar/SpendDashboardController.swift index 6f9ec8c361..f99742a560 100644 --- a/Sources/CodexBar/SpendDashboardController.swift +++ b/Sources/CodexBar/SpendDashboardController.swift @@ -3,11 +3,27 @@ import CryptoKit import Foundation import Observation +struct SpendDashboardTrackedSource: Identifiable, Equatable, Sendable { + enum State: Equatable, Sendable { + case connected + case configured + } + + let id: String + let provider: UsageProvider + let providerName: String + let accountName: String? + let state: State + let supportsCostHistory: Bool + let contributesCostHistory: Bool +} + struct SpendDashboardConfiguration: Equatable, Sendable { let costUsageEnabled: Bool let providerIDs: [String] let codexAccountIdentities: [String] let codexAccountDisplayNames: [String: String] + let trackedSources: [SpendDashboardTrackedSource] let sourceOwnershipFingerprints: [String] let sourceRevisions: [String] @@ -16,6 +32,7 @@ struct SpendDashboardConfiguration: Equatable, Sendable { providerIDs: [String], codexAccountIdentities: [String], codexAccountDisplayNames: [String: String] = [:], + trackedSources: [SpendDashboardTrackedSource] = [], sourceOwnershipFingerprints: [String] = [], sourceRevisions: [String] = []) { @@ -23,6 +40,7 @@ struct SpendDashboardConfiguration: Equatable, Sendable { self.providerIDs = providerIDs self.codexAccountIdentities = codexAccountIdentities self.codexAccountDisplayNames = codexAccountDisplayNames + self.trackedSources = trackedSources self.sourceOwnershipFingerprints = sourceOwnershipFingerprints self.sourceRevisions = sourceRevisions } @@ -64,6 +82,7 @@ struct SpendDashboardLoadRequest: Sendable { let codexRequests: [CodexSpendScanRequest] let now: Date let force: Bool + let historyDays: Int init( configuration: SpendDashboardConfiguration, @@ -72,7 +91,8 @@ struct SpendDashboardLoadRequest: Sendable { confirmedEmptySourceIDs: Set = [], codexRequests: [CodexSpendScanRequest], now: Date, - force: Bool) + force: Bool, + historyDays: Int = 30) { self.configuration = configuration self.capturedInputs = capturedInputs @@ -81,6 +101,7 @@ struct SpendDashboardLoadRequest: Sendable { self.codexRequests = codexRequests self.now = now self.force = force + self.historyDays = max(1, min(365, historyDays)) } } @@ -118,8 +139,6 @@ enum SpendDashboardSource { typealias CodexSnapshotLoader = @Sendable (CodexSpendSnapshotLoadContext) async throws -> CostUsageTokenSnapshot - static let scanDays = 30 - @MainActor static func configuration(settings: SettingsStore, store: UsageStore) -> SpendDashboardConfiguration { let providers = self.costCapableProviders(store: store) @@ -145,6 +164,7 @@ enum SpendDashboardSource { providerIDs: providers.map(\.rawValue), codexAccountIdentities: codexRequests.map { "\($0.id)|\($0.cacheIdentity)" }, codexAccountDisplayNames: self.codexDisplayNamesByID(codexRequests), + trackedSources: self.trackedSources(settings: settings, store: store), sourceOwnershipFingerprints: self.sourceOwnershipFingerprints( providers: providers, settings: settings, @@ -160,6 +180,7 @@ enum SpendDashboardSource { now: Date? = nil, nowProvider: @escaping @Sendable () -> Date = { Date() }) async -> SpendDashboardLoadRequest { + let historyDays = settings.effectiveCostUsageHistoryDays guard settings.costUsageEnabled else { return SpendDashboardLoadRequest( configuration: self.configuration(settings: settings, store: store), @@ -167,7 +188,8 @@ enum SpendDashboardSource { unavailableSourceIDs: [], codexRequests: [], now: now ?? nowProvider(), - force: mode.forcesLoader) + force: mode.forcesLoader, + historyDays: historyDays) } let initialProviders = self.costCapableProviders(store: store) @@ -205,7 +227,8 @@ enum SpendDashboardSource { unavailableSourceIDs: [], codexRequests: [], now: captureNow, - force: mode.forcesLoader) + force: mode.forcesLoader, + historyDays: historyDays) } var inputs: [SpendDashboardModel.ProviderInput] = [] @@ -242,7 +265,8 @@ enum SpendDashboardSource { confirmedEmptySourceIDs: confirmedEmptySourceIDs, codexRequests: codexRequests, now: captureNow, - force: mode.forcesLoader) + force: mode.forcesLoader, + historyDays: historyDays) } static func load(_ request: SpendDashboardLoadRequest) async -> SpendDashboardLoadResult { @@ -274,7 +298,7 @@ enum SpendDashboardSource { cacheRoot: cacheRoot, now: request.now, force: request.force, - historyDays: Self.scanDays, + historyDays: request.historyDays, refreshPricingInBackground: false, includePiSessions: false)) try Task.checkCancellation() @@ -334,6 +358,92 @@ enum SpendDashboardSource { } } + @MainActor + static func trackedSources( + settings: SettingsStore, + store: UsageStore) -> [SpendDashboardTrackedSource] + { + let enabled = Set(store.enabledProvidersForDisplay()) + var sources: [SpendDashboardTrackedSource] = [] + + for provider in UsageProvider.allCases { + let providerName = store.metadata(for: provider).displayName + let supportsCostHistory = ProviderDescriptorRegistry.descriptor(for: provider) + .tokenCost.supportsTokenCost + if provider == .codex { + let accounts = settings.codexVisibleAccountProjection.visibleAccounts + let snapshotsByID = Dictionary(uniqueKeysWithValues: store.codexAccountSnapshots.map { + ($0.id, $0.snapshot != nil) + }) + if !accounts.isEmpty { + sources.append(contentsOf: accounts.map { account in + let connected = snapshotsByID[account.id] == true + || (account.isActive && store.snapshot(for: .codex) != nil) + return SpendDashboardTrackedSource( + id: "codex:\(account.id)", + provider: provider, + providerName: providerName, + accountName: self.trackedAccountName(account.email, providerName: providerName), + state: connected ? .connected : .configured, + supportsCostHistory: supportsCostHistory, + contributesCostHistory: supportsCostHistory && enabled.contains(provider)) + }) + continue + } + } + + let accounts = settings.tokenAccounts(for: provider) + if !accounts.isEmpty { + let activeAccountID = settings.effectiveSelectedTokenAccount(for: provider)?.id + let cachedByID = Dictionary(uniqueKeysWithValues: (store.accountSnapshots[provider] ?? []).map { + ($0.id, $0.snapshot != nil) + }) + sources.append(contentsOf: accounts.map { account in + let isActive = account.id == activeAccountID + let connected = cachedByID[account.id] == true + || (isActive && store.snapshot(for: provider) != nil) + return SpendDashboardTrackedSource( + id: "\(provider.rawValue):account:\(account.id.uuidString.lowercased())", + provider: provider, + providerName: providerName, + accountName: self.trackedAccountName(account.displayName, providerName: providerName), + state: connected ? .connected : .configured, + supportsCostHistory: supportsCostHistory, + contributesCostHistory: supportsCostHistory && isActive && enabled.contains(provider)) + }) + continue + } + + let config = settings.providerConfig(for: provider) + let hasConfiguredCredential = config?.sanitizedAPIKey != nil + || config?.sanitizedSecretKey != nil + || config?.sanitizedCookieHeader != nil + let hasLiveSnapshot = store.snapshot(for: provider) != nil + || store.tokenSnapshotPublicationForCurrentProviderConfig(for: provider) != nil + guard hasConfiguredCredential || hasLiveSnapshot else { continue } + sources.append(SpendDashboardTrackedSource( + id: "\(provider.rawValue):current", + provider: provider, + providerName: providerName, + accountName: nil, + state: hasLiveSnapshot ? .connected : .configured, + supportsCostHistory: supportsCostHistory, + contributesCostHistory: supportsCostHistory && enabled.contains(provider))) + } + + return sources + } + + private static func trackedAccountName(_ rawValue: String?, providerName: String) -> String? { + guard let value = rawValue?.trimmingCharacters(in: .whitespacesAndNewlines), + !value.isEmpty, + value.localizedCaseInsensitiveCompare(providerName) != .orderedSame + else { + return nil + } + return value + } + @MainActor static func codexRequests(settings: SettingsStore, store: UsageStore) -> [CodexSpendScanRequest] { let accounts = settings.codexVisibleAccountProjection.visibleAccounts @@ -1044,6 +1154,9 @@ final class SpendDashboardController { } private static func normalizedDays(_ value: Int) -> Int { - value == 7 ? 7 : 30 + switch value { + case 7, 30, 365: value + default: 30 + } } } diff --git a/Sources/CodexBar/SpendDashboardModel.swift b/Sources/CodexBar/SpendDashboardModel.swift index c3ed207f96..b68e1c8bcb 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 @@ -91,7 +161,7 @@ struct SpendDashboardModel: Equatable, Sendable { now: Date, calendar: Calendar = .current) -> Self { - let days = max(1, min(30, requestedDays)) + let days = max(1, min(365, requestedDays)) let calculationCalendar = Self.gregorianCalendar(timeZone: calendar.timeZone) let classifiedInputs = inputs.compactMap { input -> (currencyCode: String, input: ProviderInput)? in guard let currencyCode = Self.currencyCode(input.snapshot.currencyCode) else { return nil } @@ -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/Sources/CodexBar/UsageStore+TokenAccounts.swift b/Sources/CodexBar/UsageStore+TokenAccounts.swift index e973092515..d12f3dc80a 100644 --- a/Sources/CodexBar/UsageStore+TokenAccounts.swift +++ b/Sources/CodexBar/UsageStore+TokenAccounts.swift @@ -990,7 +990,7 @@ extension UsageStore { generation: publicationGeneration) } }, - costUsageHistoryDays: self.settings.costUsageHistoryDays, + costUsageHistoryDays: self.settings.effectiveCostUsageHistoryDays, persistsCLISessions: true, persistentCLISessionIdleWindow: ProviderRegistry.persistentCLISessionIdleWindow( refreshInterval: self.normalRefreshIntervalForHeuristics())) diff --git a/Sources/CodexBar/UsageStore+TokenCost.swift b/Sources/CodexBar/UsageStore+TokenCost.swift index a55a14e72c..16d577306c 100644 --- a/Sources/CodexBar/UsageStore+TokenCost.swift +++ b/Sources/CodexBar/UsageStore+TokenCost.swift @@ -15,6 +15,7 @@ struct TokenSnapshotPublication: Sendable, Equatable { let snapshot: CostUsageTokenSnapshot? let publicationRevision: UInt64 let providerConfigRevision: UInt64 + let historyDays: Int let scopeSignature: String } @@ -104,8 +105,13 @@ extension UsageStore { for provider: UsageProvider) -> CurrentProviderConfigTokenPublication? { guard let publication = self.tokenSnapshotPublications[provider], - publication.providerConfigRevision == self.settings.providerConfigRevision(for: provider), - publication.scopeSignature == self.tokenSnapshotScopeSignature(for: provider) + publication.providerConfigRevision == self.settings.providerConfigRevision(for: provider) + else { return nil } + let requiredHistoryDays = self.settings.effectiveCostUsageHistoryDays + guard publication.historyDays >= requiredHistoryDays, + publication.scopeSignature == self.tokenSnapshotScopeSignature( + for: provider, + historyDays: publication.historyDays) else { return nil } return CurrentProviderConfigTokenPublication( snapshot: publication.snapshot, @@ -127,21 +133,25 @@ extension UsageStore { } private func publishTokenSnapshotState(_ snapshot: CostUsageTokenSnapshot?, for provider: UsageProvider) { + let historyDays = self.settings.effectiveCostUsageHistoryDays self.tokenSnapshotPublicationRevisions[provider, default: 0] &+= 1 self.tokenSnapshotPublications[provider] = TokenSnapshotPublication( snapshot: snapshot, publicationRevision: self.tokenSnapshotPublicationRevision(for: provider), providerConfigRevision: self.settings.providerConfigRevision(for: provider), - scopeSignature: self.tokenSnapshotScopeSignature(for: provider)) + historyDays: historyDays, + scopeSignature: self.tokenSnapshotScopeSignature(for: provider, historyDays: historyDays)) } func installCachedTokenSnapshot(_ snapshot: CostUsageTokenSnapshot, for provider: UsageProvider) { + let historyDays = self.settings.effectiveCostUsageHistoryDays self.tokenSnapshots[provider] = snapshot self.tokenSnapshotPublications[provider] = TokenSnapshotPublication( snapshot: snapshot, publicationRevision: self.tokenSnapshotPublicationRevision(for: provider), providerConfigRevision: self.settings.providerConfigRevision(for: provider), - scopeSignature: self.tokenSnapshotScopeSignature(for: provider)) + historyDays: historyDays, + scopeSignature: self.tokenSnapshotScopeSignature(for: provider, historyDays: historyDays)) } func clearTokenSnapshot(for provider: UsageProvider) { @@ -204,7 +214,7 @@ extension UsageStore { } let scope = self.tokenCostScope(for: .codex) - let historyDays = self.settings.costUsageHistoryDays + let historyDays = self.settings.effectiveCostUsageHistoryDays let publicationRevision = self.providerPublicationRevision(for: .codex) let providerConfigRevision = self.settings.providerConfigRevision(for: .codex) let costUsageSettingsRevision = self.settings.costUsageSettingsRevision @@ -234,7 +244,7 @@ extension UsageStore { self.settings.isCostUsageEffectivelyEnabled(for: .codex), self.isEnabled(.codex), self.tokenCostScope(for: .codex).signature == scope.signature, - self.settings.costUsageHistoryDays == historyDays, + self.settings.effectiveCostUsageHistoryDays == historyDays, self.tokenSnapshotScopeSignature(for: .codex) == tokenSnapshotScopeSignature, self.tokenSnapshotPublicationRevision(for: .codex) == tokenSnapshotPublicationRevision, self.tokenSnapshotPublicationForCurrentProviderConfig(for: .codex) == nil @@ -296,9 +306,12 @@ extension UsageStore { } } - func tokenSnapshotScopeSignature(for provider: UsageProvider) -> String { + func tokenSnapshotScopeSignature( + for provider: UsageProvider, + historyDays requestedHistoryDays: Int? = nil) -> String + { let scope = self.tokenCostScope(for: provider) - let historyDays = self.settings.costUsageHistoryDays + let historyDays = max(1, min(365, requestedHistoryDays ?? self.settings.effectiveCostUsageHistoryDays)) let base = "\(scope.signature)|historyDays=\(historyDays)" + "|settingsRevision=\(self.settings.costUsageSettingsRevision)" guard provider == .cursor else { @@ -358,7 +371,7 @@ extension UsageStore { self.settings.providerConfigRevision(for: provider) == providerConfigRevision, self.settings.costUsageEnabled, self.isEnabled(provider), - self.settings.costUsageHistoryDays == historyDays + self.settings.effectiveCostUsageHistoryDays == historyDays else { return false } @@ -402,14 +415,14 @@ extension UsageStore { case .openai: snapshot?.openAIAPIUsage?.toCostUsageTokenSnapshot() case .mistral: - snapshot?.mistralUsage?.toCostUsageTokenSnapshot(historyDays: self.settings.costUsageHistoryDays) + snapshot?.mistralUsage?.toCostUsageTokenSnapshot(historyDays: self.settings.effectiveCostUsageHistoryDays) case .opencodego: // Web-only source mode and machines with no readable local database leave // `opencodegoUsage.daily` empty; a non-nil-but-dataless projection would still // surface a Cost row whose history submenu has nothing to render. snapshot?.opencodegoUsage.flatMap { usage in usage.daily.isEmpty ? nil : usage - .toCostUsageTokenSnapshot(historyDays: self.settings.costUsageHistoryDays) + .toCostUsageTokenSnapshot(historyDays: self.settings.effectiveCostUsageHistoryDays) } default: nil diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index 7df515afe8..bdac9efd22 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -1391,7 +1391,7 @@ extension UsageStore { guard !self.tokenRefreshInFlight.contains(provider) else { return } let now = Date() - let historyDays = self.settings.costUsageHistoryDays + let historyDays = self.settings.effectiveCostUsageHistoryDays // Cursor cost reuses the status cookie policy: a Manual source forwards the manual header so // cost and status share the same session; other sources fall back to auto resolution. guard case let .proceed(cursorCookieHeaderOverride) = self.prepareCursorCostCookie(for: provider) else { diff --git a/Tests/CodexBarTests/LocalizationLanguageCatalogTests.swift b/Tests/CodexBarTests/LocalizationLanguageCatalogTests.swift index 27a81f1d63..916144b3a8 100644 --- a/Tests/CodexBarTests/LocalizationLanguageCatalogTests.swift +++ b/Tests/CodexBarTests/LocalizationLanguageCatalogTests.swift @@ -577,6 +577,7 @@ struct LocalizationLanguageCatalogTests { #expect(indonesian["tab_general"] == "Umum") #expect(indonesian["quit_app"] == "Keluar CodexBar") #expect(indonesian["30d"] == "30 hari") + #expect(indonesian["365d"] == "365 hari") #expect(indonesian["On"] == "Aktif") #expect(indonesian["Off"] == "Nonaktif") diff --git a/Tests/CodexBarTests/ShareStatsTests.swift b/Tests/CodexBarTests/ShareStatsTests.swift index adb0a24651..30780f02e4 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 [ @@ -14,25 +23,52 @@ struct ShareStatsTests { ] let payload = try #require(ShareStatsBuilder.make( model: Self.dashboard, - subscriptionNames: subscriptionNames)) + subscriptionNames: subscriptionNames, + trackedSources: [ + Self.trackedSource(id: "codex:one", provider: .codex, contributes: true), + Self.trackedSource(id: "claude", provider: .claude, contributes: true), + Self.trackedSource(id: "cursor", provider: .cursor, contributes: true), + Self.trackedSource(id: "openrouter:one", provider: .openrouter, contributes: false), + ])) #expect(payload.days == 30) - #expect(payload.totalTokens == nil) + #expect(payload.trackedSourceCount == 4) + #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"]) + #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) - #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 +")) + 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("4 sources tracked")) + #expect(text.contains("3 with cost history · 1 excluded from cost totals")) + #expect(text.contains("Aggregated locally by CodexBar · No prompts shared")) + #expect(!text.contains("Cursor Pro")) + #expect(!text.contains("Spend unavailable")) } @Test @@ -61,11 +97,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 +114,52 @@ 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 + func `text reports private routes when every route name is hidden`() throws { + let group = SpendDashboardModel.CurrencyGroup( + currencyCode: "USD", + providers: [ + SpendDashboardModel.ProviderRow( + id: "private", + rank: 1, + provider: .openrouter, + displayName: "OpenRouter", + totalTokens: 10, + totalCost: 1, + coveredDayCount: 30), + ], + models: [ + SpendDashboardModel.ModelRow( + sourceID: "private", + rank: 1, + provider: .openrouter, + providerName: "OpenRouter", + sourceName: "OpenRouter", + modelName: "acme/private-model-v2", + totalTokens: 10, + totalCost: 1), + ], + dailyPoints: [], + dailyTokenPoints: [], + totalTokens: 10, + totalCost: 1, + coveredDayCount: 30, + chartDomain: Self.date...Self.date, + modelHistoryCompleteness: .complete) + let payload = try #require(ShareStatsBuilder.make( + model: SpendDashboardModel(requestedDays: 30, groups: [group]))) + + #expect(payload.topModels.isEmpty) + #expect(payload.hiddenModelRouteCount == 1) + let text = ShareStatsFormatting.text(payload, style: .modelActivity) + #expect(text.contains("1 private route name omitted")) + #expect(!text.contains("acme")) } @Test @@ -110,8 +196,32 @@ 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 `latest priced model variants keep their public identities`() { + #expect(ShareStatsSanitizer.modelName("gpt-5.6-sol") == "GPT-5.6 Sol") + #expect(ShareStatsSanitizer.modelName("gpt-5.6-terra") == "GPT-5.6 Terra") + #expect(ShareStatsSanitizer.modelName("gpt-5.6-luna") == "GPT-5.6 Luna") + #expect(ShareStatsSanitizer.modelName("gpt-5.3-codex-spark") == "GPT-5.3 Codex Spark") + #expect(ShareStatsSanitizer.modelName("claude-fable-5") == "Claude Fable 5") + #expect(ShareStatsSanitizer.modelName("claude-4.5-sonnet") == "Claude 4.5 Sonnet") + } + + @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 +238,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 +263,7 @@ struct ShareStatsTests { ], models: rows, dailyPoints: [], + dailyTokenPoints: [], totalTokens: 1, totalCost: nil, coveredDayCount: 0, @@ -169,6 +280,36 @@ struct ShareStatsTests { #expect(ShareStatsBuilder.make(model: SpendDashboardModel(requestedDays: 30, groups: [])) == nil) } + @Test + func `cost only dashboard keeps the summary share payload`() throws { + 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) + + let payload = try #require(ShareStatsBuilder.make( + model: SpendDashboardModel(requestedDays: 7, groups: [group]))) + #expect(payload.hasShareableData) + #expect(!payload.hasModelActivityData) + #expect(ShareStatsFormatting.text(payload).contains("USD: $4.00 estimated")) + } + @Test func `cost only models do not enter token usage rankings`() throws { let model = SpendDashboardModel(requestedDays: 7, groups: [ @@ -208,6 +349,7 @@ struct ShareStatsTests { totalCost: nil), ], dailyPoints: [], + dailyTokenPoints: [], totalTokens: 10, totalCost: -.infinity, coveredDayCount: 7, @@ -221,12 +363,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 +391,7 @@ struct ShareStatsTests { totalCost: 2), ], dailyPoints: [], + dailyTokenPoints: [], totalTokens: nil, totalCost: nil, coveredDayCount: 7, @@ -258,51 +401,402 @@ 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: [ + SpendDashboardModel.ModelRow( + rank: 1, + provider: .codex, + providerName: "Codex", + modelName: "gpt-5.4", + totalTokens: 10, + totalCost: 1), + ], + 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(!payload.tokenCoverageIsComplete) + #expect(!payload.modelRouteCoverageIsComplete) + let text = ShareStatsFormatting.text(payload, style: .modelActivity) + #expect(text.contains("at least 10 tracked tokens")) + #expect(text.contains("at least 1 of 30 days active")) + #expect(text.contains("Model route history is partial")) + } + + @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, + trackedSources: Self.proofTrackedSources)) #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, + trackedSources: Self.proofTrackedSources)) + + #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 }) + 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) + } - #expect(ShareStatsCardView.providerPaletteIndex(for: codexModel, providers: payload.providers) == 1) + @Test + func `activity strip groups every supported range into complete weeks`() { + #expect(ShareStatsModelActivityCardView.weekCount(for: 0) == 0) + #expect(ShareStatsModelActivityCardView.weekCount(for: 7) == 1) + #expect(ShareStatsModelActivityCardView.weekCount(for: 30) == 5) + #expect(ShareStatsModelActivityCardView.weekCount(for: 365) == 53) } @Test @@ -311,9 +805,167 @@ struct ShareStatsTests { #expect(ShareStatsBuilder.combinedTotalTokens([10, nil]) == nil) #expect(ShareStatsBuilder.combinedTotalTokens([10, 20]) == 30) } +} + +extension ShareStatsTests { + @Test + func `eight token cost providers and model routes are preserved`() throws { + let sources = [ + ProofSource( + id: "codex", + provider: .codex, + name: "Codex", + model: "gpt-5.6-sol", + tokens: 80, + cost: 8), + ProofSource( + id: "claude", + provider: .claude, + name: "Claude", + model: "claude-fable-5", + tokens: 70, + cost: 7), + ProofSource( + id: "cursor", + provider: .cursor, + name: "Cursor", + model: "claude-4.5-sonnet", + tokens: 60, + cost: 6), + ProofSource( + id: "openai", + provider: .openai, + name: "OpenAI", + model: "gpt-5.3-codex-spark", + tokens: 50, + cost: 5), + ProofSource( + id: "mistral", + provider: .mistral, + name: "Mistral", + model: "mistral-large-3", + tokens: 40, + cost: 4), + ProofSource( + id: "opencodego", + provider: .opencodego, + name: "OpenCode Go", + model: "openai/gpt-5.4-mini", + tokens: 30, + cost: 3), + ProofSource( + id: "bedrock", + provider: .bedrock, + name: "Amazon Bedrock", + model: "us.amazon.nova-2-lite-v1:0", + tokens: 20, + cost: 2), + ProofSource( + id: "vertexai", + provider: .vertexai, + name: "Vertex AI", + model: "google/gemini-2.5-pro", + tokens: 10, + cost: 1), + ] + 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: sources.map { source in + SpendDashboardModel.DailyTokenPoint( + sourceID: source.id, + provider: source.provider, + providerName: source.name, + day: Self.date, + tokens: source.tokens) + }, + totalTokens: sources.reduce(0) { $0 + $1.tokens }, + totalCost: sources.reduce(0) { $0 + $1.cost }, + coveredDayCount: 30, + chartDomain: Self.date...Self.date, + modelHistoryCompleteness: .complete) + let payload = try #require(ShareStatsBuilder.make( + model: SpendDashboardModel(requestedDays: 30, groups: [group]))) + + #expect(payload.providers.count == 8) + #expect(payload.topModels.count == 8) + #expect(payload.shareableModelRouteCount == 8) + #expect(payload.hiddenModelRouteCount == 0) + #expect(payload.tokenCoverageIsComplete) + #expect(payload.modelRouteCoverageIsComplete) + #expect(Set(payload.topModels.map(\.modelName)) == [ + "Amazon Nova 2 Lite V1:0", + "Claude 4.5 Sonnet", + "Claude Fable 5", + "Gemini 2.5 Pro", + "GPT-5.3 Codex Spark", + "GPT-5.4 Mini", + "GPT-5.6 Sol", + "Mistral Large 3", + ]) + } 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, @@ -340,10 +992,111 @@ struct ShareStatsTests { return UsageSnapshot(primary: nil, secondary: nil, updatedAt: self.date, identity: identity) } + private static func trackedSource( + id: String, + provider: UsageProvider, + contributes: Bool) -> SpendDashboardTrackedSource + { + SpendDashboardTrackedSource( + id: id, + provider: provider, + providerName: provider.rawValue, + accountName: nil, + state: .connected, + supportsCostHistory: contributes, + contributesCostHistory: contributes) + } + private static var dashboard: SpendDashboardModel { 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: Date(timeIntervalSince1970: 1_781_481_600)) + 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: "claude", + provider: .claude, + name: "Claude", + model: "claude-sonnet-4", + tokens: 7_000_000, + cost: 140), + ProofSource( + id: "cursor", + provider: .cursor, + name: "Cursor", + model: "claude-4.5-sonnet", + tokens: 4_000_000, + cost: 22), + ProofSource( + id: "mistral", + provider: .mistral, + name: "Mistral", + model: "mistral-large-3", + 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 let proofTrackedSources = [ + ShareStatsTests.trackedSource(id: "codex", provider: .codex, contributes: true), + ShareStatsTests.trackedSource(id: "claude", provider: .claude, contributes: true), + ShareStatsTests.trackedSource(id: "cursor", provider: .cursor, contributes: true), + ShareStatsTests.trackedSource(id: "mistral", provider: .mistral, contributes: true), + ShareStatsTests.trackedSource(id: "openrouter", provider: .openrouter, contributes: false), + ShareStatsTests.trackedSource(id: "kimi", provider: .kimi, contributes: false), + ShareStatsTests.trackedSource(id: "gemini", provider: .gemini, contributes: false), + ShareStatsTests.trackedSource(id: "zai", provider: .zai, contributes: false), + ] + private static func dashboard(models: [String]) -> SpendDashboardModel { SpendDashboardModel(requestedDays: 30, groups: [ SpendDashboardModel.CurrencyGroup( @@ -368,6 +1121,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 +1164,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/SpendDashboardControllerTests.swift b/Tests/CodexBarTests/SpendDashboardControllerTests.swift index e1259d5ff9..3278efe114 100644 --- a/Tests/CodexBarTests/SpendDashboardControllerTests.swift +++ b/Tests/CodexBarTests/SpendDashboardControllerTests.swift @@ -6,6 +6,29 @@ import Testing @MainActor @Suite(.serialized) struct SpendDashboardControllerTests { + @Test + func `dashboard selection derives an ephemeral provider history window`() throws { + #expect(spendDashboardRequiredHistoryDays(selectedDays: 365, configuredDays: 30) == 365) + #expect(spendDashboardRequiredHistoryDays(selectedDays: 7, configuredDays: 30) == 30) + #expect(spendDashboardRequiredHistoryDays(selectedDays: 30, configuredDays: 365) == 365) + + let suite = "SpendDashboardControllerTests-history-override" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + defer { defaults.removePersistentDomain(forName: suite) } + defaults.set(30, forKey: "tokenCostUsageHistoryDays") + let settings = testSettingsStore(suiteName: suite) + + settings.setSpendDashboardHistoryDaysOverride(365) + #expect(settings.costUsageHistoryDays == 30) + #expect(settings.effectiveCostUsageHistoryDays == 365) + #expect(defaults.integer(forKey: "tokenCostUsageHistoryDays") == 30) + + settings.setSpendDashboardHistoryDaysOverride(nil) + #expect(settings.costUsageHistoryDays == 30) + #expect(settings.effectiveCostUsageHistoryDays == 30) + } + @Test func `empty codex history loads as successful inactive source`() async { let now = Date(timeIntervalSince1970: 1_784_179_200) @@ -24,7 +47,8 @@ struct SpendDashboardControllerTests { unavailableSourceIDs: [], codexRequests: [account], now: now, - force: false) + force: false, + historyDays: 7) let result = await SpendDashboardSource.load(request, codexSnapshotLoader: { context in await recorder.record(context) @@ -48,7 +72,7 @@ struct SpendDashboardControllerTests { #expect(contexts.first?.cacheRoot.lastPathComponent == "inactive-cache") #expect(contexts.first?.now == now) #expect(contexts.first?.force == false) - #expect(contexts.first?.historyDays == 30) + #expect(contexts.first?.historyDays == 7) #expect(contexts.first?.refreshPricingInBackground == false) #expect(contexts.first?.includePiSessions == false) } @@ -731,28 +755,6 @@ struct SpendDashboardControllerTests { #expect(controller.model.groups.isEmpty) } - @Test - func `range selection persists only supported windows`() throws { - let suite = "SpendDashboardControllerTests-days" - let defaults = try #require(UserDefaults(suiteName: suite)) - defaults.removePersistentDomain(forName: suite) - defer { defaults.removePersistentDomain(forName: suite) } - let controller = SpendDashboardController( - userDefaults: defaults, - requestBuilder: { mode in - Self.request( - configuration: Self.configuration(account: "unused"), - force: mode.forcesLoader) - }) - - #expect(controller.selectedDays == 30) - controller.selectDays(7) - #expect(controller.selectedDays == 7) - #expect(defaults.integer(forKey: "settingsSpendDashboardDays") == 7) - controller.selectDays(9) - #expect(controller.selectedDays == 30) - } - private static let fixtureNow = Date(timeIntervalSince1970: 1_784_179_200) private static func dashboardController( @@ -878,6 +880,34 @@ struct SpendDashboardControllerTests { } } +extension SpendDashboardControllerTests { + @Test + func `range selection persists only supported windows`() throws { + let suite = "SpendDashboardControllerTests-days" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + defer { defaults.removePersistentDomain(forName: suite) } + let controller = SpendDashboardController( + userDefaults: defaults, + requestBuilder: { mode in + Self.request( + configuration: Self.configuration(account: "unused"), + force: mode.forcesLoader) + }) + + #expect(controller.selectedDays == 30) + controller.selectDays(7) + #expect(controller.selectedDays == 7) + #expect(defaults.integer(forKey: "settingsSpendDashboardDays") == 7) + controller.selectDays(30) + #expect(controller.selectedDays == 30) + controller.selectDays(365) + #expect(controller.selectedDays == 365) + controller.selectDays(9) + #expect(controller.selectedDays == 30) + } +} + @MainActor struct SpendDashboardRequestTimeTests { @Test @@ -901,6 +931,19 @@ struct SpendDashboardRequestTimeTests { #expect(request.now == afterMidnight) } + @Test + func `request carries configured history into provider loads`() async { + let (settings, store) = Self.store(suiteName: "SpendDashboardRequestTimeTests-history") + settings.costUsageHistoryDays = 7 + + let request = await SpendDashboardSource.makeRequest( + settings: settings, + store: store, + mode: .captureOnly) + + #expect(request.historyDays == 7) + } + @Test func `explicit request time remains authoritative after refresh`() async throws { let (settings, store) = Self.store(suiteName: "SpendDashboardRequestTimeTests-explicit") diff --git a/Tests/CodexBarTests/SpendDashboardModelTests.swift b/Tests/CodexBarTests/SpendDashboardModelTests.swift index 9223e633ca..984a9c79fd 100644 --- a/Tests/CodexBarTests/SpendDashboardModelTests.swift +++ b/Tests/CodexBarTests/SpendDashboardModelTests.swift @@ -4,27 +4,6 @@ import Testing @testable import CodexBar struct SpendDashboardModelTests { - @Test - func `count labels avoid plural agreement and localize numbers`() { - CodexBarLocalizationOverride.$appLanguage.withValue("en") { - #expect(spendDashboardRefreshFailureText(1) == "Refresh failures: 1") - #expect(spendDashboardRefreshFailureText(2) == "Refresh failures: 2") - #expect(spendDashboardCoverageText(covered: 3, requested: 7) == "Coverage: 3 / 7") - } - CodexBarLocalizationOverride.$appLanguage.withValue("de") { - #expect(spendDashboardRefreshFailureText(1234) == "Fehlgeschlagene Aktualisierungen: 1.234") - #expect(spendDashboardCoverageText(covered: 3, requested: 30) == "Abdeckung: 3 / 30") - } - CodexBarLocalizationOverride.$appLanguage.withValue("fa") { - #expect(codexBarLocalizedInteger(12) == "۱۲") - #expect(spendDashboardDayRangeText(7) == "۷ روز") - #expect(spendDashboardDayRangeText(30) == "۳۰ روز") - #expect(spendDashboardRankText(1234) == "#۱٬۲۳۴") - #expect(spendDashboardRefreshFailureText(2) == "\(L("Refresh failures")): ۲") - #expect(spendDashboardCoverageText(covered: 3, requested: 30) == "پوشش: ۳ / ۳۰") - } - } - @Test func `Codex account indices use app locale numerals`() throws { let home = FileManager.default.temporaryDirectory @@ -371,8 +350,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( @@ -753,7 +736,6 @@ struct SpendDashboardModelTests { #expect(!request.authFileWasReadable) #expect(request.displayName == "Codex · #2") #expect(request.cacheIdentity.count == 64) - #expect(SpendDashboardSource.scanDays == 30) #expect(SpendDashboardSource.codexRequest( account: account, homePath: "relative/path", @@ -869,3 +851,27 @@ struct SpendDashboardModelTests { return calendar } } + +extension SpendDashboardModelTests { + @Test + func `count labels avoid plural agreement and localize numbers`() { + CodexBarLocalizationOverride.$appLanguage.withValue("en") { + #expect(spendDashboardRefreshFailureText(1) == "Refresh failures: 1") + #expect(spendDashboardRefreshFailureText(2) == "Refresh failures: 2") + #expect(spendDashboardCoverageText(covered: 3, requested: 7) == "Coverage: 3 / 7") + } + CodexBarLocalizationOverride.$appLanguage.withValue("de") { + #expect(spendDashboardRefreshFailureText(1234) == "Fehlgeschlagene Aktualisierungen: 1.234") + #expect(spendDashboardCoverageText(covered: 3, requested: 30) == "Abdeckung: 3 / 30") + } + CodexBarLocalizationOverride.$appLanguage.withValue("fa") { + #expect(codexBarLocalizedInteger(12) == "۱۲") + #expect(spendDashboardDayRangeText(7) == "۷ روز") + #expect(spendDashboardDayRangeText(30) == "۳۰ روز") + #expect(spendDashboardDayRangeText(365) == "۳۶۵ روز") + #expect(spendDashboardRankText(1234) == "#۱٬۲۳۴") + #expect(spendDashboardRefreshFailureText(2) == "\(L("Refresh failures")): ۲") + #expect(spendDashboardCoverageText(covered: 3, requested: 30) == "پوشش: ۳ / ۳۰") + } + } +} diff --git a/Tests/CodexBarTests/SpendDashboardTrackedSourceTests.swift b/Tests/CodexBarTests/SpendDashboardTrackedSourceTests.swift new file mode 100644 index 0000000000..dfe982ef87 --- /dev/null +++ b/Tests/CodexBarTests/SpendDashboardTrackedSourceTests.swift @@ -0,0 +1,170 @@ +import AppKit +import CodexBarCore +import SwiftUI +import Testing +@testable import CodexBar + +struct SpendDashboardTrackedSourceTests { + @Test + @MainActor + func `tracked access includes every saved provider credential without inventing cost coverage`() { + let settings = testSettingsStore(suiteName: "SpendDashboardTrackedSourceTests-credentials") + let supportedProviders = UsageProvider.allCases.filter { + TokenAccountSupportCatalog.support(for: $0) != nil + } + for provider in supportedProviders { + settings.addTokenAccount(provider: provider, label: "\(provider.rawValue) account", token: "fixture") + } + settings.addTokenAccount(provider: .openrouter, label: "second account", token: "fixture-2") + + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + startupBehavior: .testing, + environmentBase: [:]) + let sources = SpendDashboardSource.trackedSources(settings: settings, store: store) + let credentialSources = sources.filter { $0.id.contains(":account:") } + + #expect(Set(credentialSources.map(\.provider)) == Set(supportedProviders)) + #expect(credentialSources.count == supportedProviders.count + 1) + #expect(Set(credentialSources.map(\.id)).count == credentialSources.count) + + let openRouterSources = credentialSources.filter { $0.provider == .openrouter } + #expect(openRouterSources.count == 2) + #expect(openRouterSources.allSatisfy { $0.state == .configured }) + #expect(openRouterSources.allSatisfy { !$0.supportsCostHistory }) + #expect(openRouterSources.allSatisfy { !$0.contributesCostHistory }) + } + + @Test + func `tracked access copy distinguishes missing cost history from zero spend`() { + let source = SpendDashboardTrackedSource( + id: "openrouter:account:test", + provider: .openrouter, + providerName: "OpenRouter", + accountName: "Work", + state: .connected, + supportsCostHistory: false, + contributesCostHistory: false) + + #expect(spendDashboardTrackedSourceStatusText(source) == "Usage connected · not in cost total") + } + + @Test + @MainActor + func `tracked access renders without clipping at wide and narrow settings widths`() throws { + let proofDirectory = ProcessInfo.processInfo.environment["CODEXBAR_SPEND_DASHBOARD_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: 760, height: 440), "spend-dashboard-tracked-access-wide.png"), + (CGSize(width: 430, height: 720), "spend-dashboard-tracked-access-narrow.png"), + ] { + let view = VStack(alignment: .leading, spacing: 18) { + SpendDashboardHeader( + selectedDays: 365, + isRefreshing: false, + isCostTrackingEnabled: true, + selectDays: { _ in }, + refresh: {}) + SpendTrackedAccessPanel( + sources: Self.proofSources, + description: "Every configured subscription or key stays visible. " + + "Only compatible sources enter cost totals.") + } + .padding(24) + .frame(width: size.width, height: size.height, alignment: .topLeading) + .background(Color(nsColor: .windowBackgroundColor)) + + let data = try #require(Self.pngData(for: view, size: size)) + let bitmap = try #require(NSBitmapImageRep(data: data)) + #expect(bitmap.pixelsWide == Int(size.width)) + #expect(bitmap.pixelsHigh == Int(size.height)) + if let proofDirectory { + try data.write(to: proofDirectory.appendingPathComponent(filename), options: .atomic) + } + } + } + + private static let proofSources = [ + SpendDashboardTrackedSource( + id: "codex:account:personal", + provider: .codex, + providerName: "Codex", + accountName: "Personal", + state: .connected, + supportsCostHistory: true, + contributesCostHistory: true), + SpendDashboardTrackedSource( + id: "claude:account:team", + provider: .claude, + providerName: "Claude", + accountName: "Team", + state: .connected, + supportsCostHistory: true, + contributesCostHistory: true), + SpendDashboardTrackedSource( + id: "openrouter:account:research", + provider: .openrouter, + providerName: "OpenRouter", + accountName: "Research", + state: .connected, + supportsCostHistory: false, + contributesCostHistory: false), + SpendDashboardTrackedSource( + id: "cursor:account:work", + provider: .cursor, + providerName: "Cursor", + accountName: "Work", + state: .configured, + supportsCostHistory: true, + contributesCostHistory: false), + SpendDashboardTrackedSource( + id: "gemini:account:studio", + provider: .gemini, + providerName: "Gemini", + accountName: "Studio", + state: .connected, + supportsCostHistory: false, + contributesCostHistory: false), + SpendDashboardTrackedSource( + id: "mistral:account:api", + provider: .mistral, + providerName: "Mistral", + accountName: "API", + state: .configured, + supportsCostHistory: true, + contributesCostHistory: false), + ] + + @MainActor + private static func pngData(for rootView: some View, size: CGSize) -> Data? { + let view = NSHostingView(rootView: rootView) + view.frame = CGRect(origin: .zero, size: size) + view.layoutSubtreeIfNeeded() + + guard let representation = NSBitmapImageRep( + bitmapDataPlanes: nil, + pixelsWide: Int(size.width), + pixelsHigh: Int(size.height), + bitsPerSample: 8, + samplesPerPixel: 4, + hasAlpha: true, + isPlanar: false, + colorSpaceName: .deviceRGB, + bytesPerRow: 0, + bitsPerPixel: 0) + else { return nil } + representation.size = size + guard let context = NSGraphicsContext(bitmapImageRep: representation) else { return nil } + view.displayIgnoringOpacity(view.bounds, in: context) + return representation.representation(using: .png, properties: [:]) + } +} diff --git a/Tests/CodexBarTests/UsageStoreCoverageTests.swift b/Tests/CodexBarTests/UsageStoreCoverageTests.swift index 85af8b03c9..d326ae1246 100644 --- a/Tests/CodexBarTests/UsageStoreCoverageTests.swift +++ b/Tests/CodexBarTests/UsageStoreCoverageTests.swift @@ -176,6 +176,32 @@ struct UsageStoreCoverageTests { fetchedCredentialScopeFingerprint: fingerprint)) } + @Test + func `dashboard history override keeps sparse provider coverage current when it closes`() throws { + let settings = Self.makeSettingsStore(suite: "UsageStoreCoverageTests-dashboard-history") + settings.costUsageEnabled = true + settings.costUsageHistoryDays = 30 + settings.setSpendDashboardHistoryDaysOverride(365) + let metadata = try #require(ProviderRegistry.shared.metadata[.claude]) + settings.setProviderEnabled(provider: .claude, metadata: metadata, enabled: true) + let store = Self.makeUsageStore(settings: settings) + let snapshot = CostUsageTokenSnapshot( + sessionTokens: nil, + sessionCostUSD: nil, + last30DaysTokens: 10, + last30DaysCostUSD: 1, + historyDays: 1, + daily: [], + updatedAt: Date()) + + store.publishTokenSnapshot(snapshot, for: .claude) + settings.setSpendDashboardHistoryDaysOverride(nil) + + #expect(settings.costUsageHistoryDays == 30) + #expect(store.tokenSnapshotForCurrentProviderConfig(for: .claude)?.snapshot == snapshot) + #expect(store.tokenSnapshotForCurrentProviderConfig(for: .claude)?.snapshot.historyDays == 1) + } + @Test func `source label adds open AI web`() { let settings = Self.makeSettingsStore(suite: "UsageStoreCoverageTests-source") diff --git a/docs/codex.md b/docs/codex.md index f0a3dab926..6eb897607b 100644 --- a/docs/codex.md +++ b/docs/codex.md @@ -173,9 +173,10 @@ Example: ### Usage & Spend account rows -Settings → Usage & Spend performs a separate fixed 30-day scan for every visible Codex account. Each request freezes -the account source, exact Codex home, authentication fingerprint, and cache identity before scanning. A missing or -invalid home is omitted; it never falls back to ambient `~/.codex` or to the global Codex token snapshot. +Settings → Usage & Spend performs a separate scan at the selected 7-, 30-, or 365-day window for every visible Codex +account. Each request freezes the account source, exact Codex home, authentication fingerprint, and cache identity +before scanning. A missing or invalid home is omitted; it never falls back to ambient `~/.codex` or to the global +Codex token snapshot. These account rows intentionally exclude pi and OMP sessions because their history is machine-local rather than owned by one Codex account. The normal Codex cost menu and CLI scan continue to include supported pi-compatible history. The diff --git a/docs/providers.md b/docs/providers.md index 3779d86208..edb4eee40a 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -21,9 +21,11 @@ headers, source selection, provider ordering, and token accounts are stored in ` ## Usage & Spend settings -Settings → Usage & Spend combines local 7- or 30-day estimated history only for enabled descriptors that advertise -token-cost support: Codex, Claude, Vertex AI, OpenAI, Mistral, and AWS Bedrock. Providers without a cost-history -contract are omitted instead of appearing as empty subscriptions. +Settings → Usage & Spend combines local 7-, 30-, or 365-day estimated history for enabled descriptors that advertise +token-cost support: Codex, Claude, Vertex AI, OpenAI, Mistral, AWS Bedrock, Cursor, and OpenCode Go. Its tracked-access +section separately lists every saved subscription/key plus live authenticated provider sources. Sources without a +cost-history contract stay visible there and are explicitly excluded from cost totals instead of appearing as zero +spend. Each native currency has its own total, subscription/model ranking, and daily chart. CodexBar never adds or ranks amounts across currencies. Coverage text reports how many days of the selected local calendar window are covered by diff --git a/docs/screenshots/share-stats-brand-card.png b/docs/screenshots/share-stats-brand-card.png new file mode 100644 index 0000000000..c8e1e3af86 Binary files /dev/null and b/docs/screenshots/share-stats-brand-card.png differ diff --git a/docs/screenshots/share-stats-proof/README.md b/docs/screenshots/share-stats-proof/README.md new file mode 100644 index 0000000000..2e2d643a55 --- /dev/null +++ b/docs/screenshots/share-stats-proof/README.md @@ -0,0 +1,18 @@ +# Share stats proof + +The PNGs in this directory are rendered by the production SwiftUI share-card +renderer. `share-preview-window.png` captures the real preview window with the +opt-in Model activity style selected. + +All values, provider names, and model routes come from a synthetic aggregate +fixture. No account identifiers, prompts, keys, or live usage data are included. +The four cost-history contributors use providers supported by the dashboard +contract: Codex, Claude, Cursor, and Mistral. Four additional fixture sources are +tracked but excluded from cost totals. + +With a full Xcode toolchain, regenerate the card sizes with: + +```sh +CODEXBAR_SHARE_STATS_PROOF_DIR="$PWD/docs/screenshots/share-stats-proof" \ + swift test --filter ShareStatsTests +``` diff --git a/docs/screenshots/share-stats-proof/share-preview-window.png b/docs/screenshots/share-stats-proof/share-preview-window.png new file mode 100644 index 0000000000..9967d44a9c Binary files /dev/null and b/docs/screenshots/share-stats-proof/share-preview-window.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..c8e1e3af86 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..77a262a1ef 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..5801420a4d 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..0ad99df719 --- /dev/null +++ b/docs/screenshots/share-stats-proof/share-stats.txt @@ -0,0 +1,12 @@ +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 Claude +Claude 4.5 Sonnet via Cursor ++1 more safe route summaries +8 sources tracked +4 with cost history · 4 excluded from cost totals +Aggregated locally by CodexBar · No prompts shared · Data through Jul 13, 2026 diff --git a/docs/screenshots/spend-dashboard-proof/README.md b/docs/screenshots/spend-dashboard-proof/README.md new file mode 100644 index 0000000000..73d5bd0223 --- /dev/null +++ b/docs/screenshots/spend-dashboard-proof/README.md @@ -0,0 +1,17 @@ +# Spend dashboard proof + +These PNGs are rendered by the production `SpendDashboardHeader` and +`SpendTrackedAccessPanel` SwiftUI components at wide and narrow settings widths. +The provider names and account labels are synthetic fixtures; no account data, +keys, or usage values are included. + +Regenerate them with a full Xcode toolchain: + +```sh +CODEXBAR_SPEND_DASHBOARD_PROOF_DIR="$PWD/docs/screenshots/spend-dashboard-proof" \ + swift test --filter SpendDashboardTrackedSourceTests +``` + +The narrow render verifies that the range controls wrap below the title and the +tracked-source grid collapses to one column. The wide render uses two columns. +Both states keep cost-history inclusion and exclusion explicit. diff --git a/docs/screenshots/spend-dashboard-proof/spend-dashboard-tracked-access-narrow.png b/docs/screenshots/spend-dashboard-proof/spend-dashboard-tracked-access-narrow.png new file mode 100644 index 0000000000..faf07c98a4 Binary files /dev/null and b/docs/screenshots/spend-dashboard-proof/spend-dashboard-tracked-access-narrow.png differ diff --git a/docs/screenshots/spend-dashboard-proof/spend-dashboard-tracked-access-wide.png b/docs/screenshots/spend-dashboard-proof/spend-dashboard-tracked-access-wide.png new file mode 100644 index 0000000000..6d2cd907d7 Binary files /dev/null and b/docs/screenshots/spend-dashboard-proof/spend-dashboard-tracked-access-wide.png differ