From 5d6ca31a913b7a9f6e730805e47d1a5017c1139f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 1 Jul 2026 09:56:55 +0100 Subject: [PATCH 1/3] feat: add cost comparison periods --- CHANGELOG.md | 3 + .../CodexBar/MenuCardHeightFingerprint.swift | 1 + Sources/CodexBar/MenuCardView+Costs.swift | 19 +++++ .../CodexBar/MenuCardView+ModelHelpers.swift | 3 +- .../CodexBar/MenuCardView+ModelInput.swift | 3 + Sources/CodexBar/MenuCardView.swift | 28 +++++++ Sources/CodexBar/PreferencesGeneralPane.swift | 5 ++ .../Resources/ar.lproj/Localizable.strings | 2 + .../Resources/en.lproj/Localizable.strings | 2 + .../Resources/fa.lproj/Localizable.strings | 2 + .../Resources/id.lproj/Localizable.strings | 2 + .../Resources/it.lproj/Localizable.strings | 2 + .../Resources/pl.lproj/Localizable.strings | 2 + .../Resources/th.lproj/Localizable.strings | 2 + .../Resources/tr.lproj/Localizable.strings | 2 + Sources/CodexBar/SettingsStore+Defaults.swift | 8 ++ .../SettingsStore+MenuObservation.swift | 1 + Sources/CodexBar/SettingsStore.swift | 3 + Sources/CodexBar/SettingsStoreState.swift | 1 + .../StatusItemController+CostMenuCard.swift | 13 +-- .../StatusItemController+MenuCardModel.swift | 1 + Sources/CodexBarCore/CostUsageModels.swift | 67 ++++++++++++++++ .../CostUsageWindowSummaryTests.swift | 80 +++++++++++++++++++ .../MenuCardCostComparisonTests.swift | 65 +++++++++++++++ .../PreferencesPaneSmokeTests.swift | 2 + Tests/CodexBarTests/SettingsStoreTests.swift | 23 ++++++ docs/cost-window-comparisons.md | 35 ++++++++ 27 files changed, 370 insertions(+), 7 deletions(-) create mode 100644 Tests/CodexBarTests/CostUsageWindowSummaryTests.swift create mode 100644 Tests/CodexBarTests/MenuCardCostComparisonTests.swift create mode 100644 docs/cost-window-comparisons.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 2fb80f8934..2fc64c1174 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## Unreleased +### Added +- Cost history: optionally show shorter 7, 30, and 90-day comparisons derived from the selected local history window (#1500). Thanks @jtl06! + ### Fixed - Claude web usage: bound stale requests so Auto can reach CLI fallback instead of hanging indefinitely. - Claude history: keep OAuth utilization separate across account switches while preserving continuity through token refreshes. diff --git a/Sources/CodexBar/MenuCardHeightFingerprint.swift b/Sources/CodexBar/MenuCardHeightFingerprint.swift index 9e1962b85c..ab80c68186 100644 --- a/Sources/CodexBar/MenuCardHeightFingerprint.swift +++ b/Sources/CodexBar/MenuCardHeightFingerprint.swift @@ -112,6 +112,7 @@ extension UsageMenuCardView.Model.TokenUsageSection { MenuCardHeightFingerprint.join([ MenuCardHeightFingerprint.field("session", self.sessionLine), MenuCardHeightFingerprint.field("month", self.monthLine), + MenuCardHeightFingerprint.field("comparisons", self.comparisonLines.joined(separator: "|")), MenuCardHeightFingerprint.field("hint", self.hintLine), MenuCardHeightFingerprint.field("error", self.errorLine), MenuCardHeightFingerprint.field("errorCopy", self.errorCopyText), diff --git a/Sources/CodexBar/MenuCardView+Costs.swift b/Sources/CodexBar/MenuCardView+Costs.swift index 239cddd37a..4732587af0 100644 --- a/Sources/CodexBar/MenuCardView+Costs.swift +++ b/Sources/CodexBar/MenuCardView+Costs.swift @@ -92,6 +92,7 @@ extension UsageMenuCardView.Model { static func tokenUsageSection( provider: UsageProvider, enabled: Bool, + comparisonPeriodsEnabled: Bool, snapshot: CostUsageTokenSnapshot?, error: String?) -> TokenUsageSection? { @@ -134,11 +135,29 @@ extension UsageMenuCardView.Model { return TokenUsageSection( sessionLine: sessionLine, monthLine: monthLine, + comparisonLines: comparisonPeriodsEnabled + ? snapshot.comparisonSummaries().map { + Self.costWindowLine(summary: $0, currencyCode: snapshot.currencyCode) + } + : [], hintLine: Self.tokenUsageHint(provider: provider), errorLine: err, errorCopyText: (error?.isEmpty ?? true) ? nil : error) } + private static func costWindowLine(summary: CostUsageWindowSummary, currencyCode: String) -> String { + let label = Self.costHistoryWindowLabel(days: summary.days) + let cost = summary.totalCostUSD.map { + UsageFormatter.currencyString($0, currencyCode: currencyCode) + } ?? "—" + guard let totalTokens = summary.totalTokens else { return "\(label): \(cost)" } + return String( + format: L("%@: %@ · %@ tokens"), + label, + cost, + UsageFormatter.tokenCountString(totalTokens)) + } + static func tokenUsageHint(provider: UsageProvider) -> String? { switch provider { case .codex: diff --git a/Sources/CodexBar/MenuCardView+ModelHelpers.swift b/Sources/CodexBar/MenuCardView+ModelHelpers.swift index c251ce0396..54aa4aaceb 100644 --- a/Sources/CodexBar/MenuCardView+ModelHelpers.swift +++ b/Sources/CodexBar/MenuCardView+ModelHelpers.swift @@ -201,7 +201,8 @@ extension UsageMenuCardView.Model { true case let (current?, candidate?): current.hintLine == candidate.hintLine && - current.errorLine == candidate.errorLine + current.errorLine == candidate.errorLine && + current.comparisonLines.count == candidate.comparisonLines.count default: false } diff --git a/Sources/CodexBar/MenuCardView+ModelInput.swift b/Sources/CodexBar/MenuCardView+ModelInput.swift index 2f1e880742..f638b0bd7b 100644 --- a/Sources/CodexBar/MenuCardView+ModelInput.swift +++ b/Sources/CodexBar/MenuCardView+ModelInput.swift @@ -21,6 +21,7 @@ extension UsageMenuCardView.Model { let tokenCostUsageEnabled: Bool let tokenCostInlineDashboardEnabled: Bool let tokenCostMenuSectionEnabled: Bool + let costComparisonPeriodsEnabled: Bool let showOptionalCreditsAndExtraUsage: Bool let copilotBudgetExtrasEnabled: Bool let sourceLabel: String? @@ -51,6 +52,7 @@ extension UsageMenuCardView.Model { tokenCostUsageEnabled: Bool, tokenCostInlineDashboardEnabled: Bool? = nil, tokenCostMenuSectionEnabled: Bool? = nil, + costComparisonPeriodsEnabled: Bool = false, showOptionalCreditsAndExtraUsage: Bool, copilotBudgetExtrasEnabled: Bool = false, sourceLabel: String? = nil, @@ -80,6 +82,7 @@ extension UsageMenuCardView.Model { self.tokenCostUsageEnabled = tokenCostUsageEnabled self.tokenCostInlineDashboardEnabled = tokenCostInlineDashboardEnabled ?? tokenCostUsageEnabled self.tokenCostMenuSectionEnabled = tokenCostMenuSectionEnabled ?? tokenCostUsageEnabled + self.costComparisonPeriodsEnabled = costComparisonPeriodsEnabled self.showOptionalCreditsAndExtraUsage = showOptionalCreditsAndExtraUsage self.copilotBudgetExtrasEnabled = copilotBudgetExtrasEnabled self.sourceLabel = sourceLabel diff --git a/Sources/CodexBar/MenuCardView.swift b/Sources/CodexBar/MenuCardView.swift index 532b508f16..b8581c608b 100644 --- a/Sources/CodexBar/MenuCardView.swift +++ b/Sources/CodexBar/MenuCardView.swift @@ -83,9 +83,26 @@ struct UsageMenuCardView: View { struct TokenUsageSection { let sessionLine: String let monthLine: String + let comparisonLines: [String] let hintLine: String? let errorLine: String? let errorCopyText: String? + + init( + sessionLine: String, + monthLine: String, + comparisonLines: [String] = [], + hintLine: String?, + errorLine: String?, + errorCopyText: String?) + { + self.sessionLine = sessionLine + self.monthLine = monthLine + self.comparisonLines = comparisonLines + self.hintLine = hintLine + self.errorLine = errorLine + self.errorCopyText = errorCopyText + } } struct ProviderCostSection { @@ -200,6 +217,11 @@ struct UsageMenuCardView: View { Text(tokenUsage.monthLine) .font(.footnote) .lineLimit(1) + ForEach(tokenUsage.comparisonLines, id: \.self) { line in + Text(line) + .font(.footnote) + .lineLimit(1) + } if let hint = tokenUsage.hintLine, !hint.isEmpty { Text(hint) .font(.footnote) @@ -720,6 +742,11 @@ struct UsageMenuCardCostSectionView: View { Text(tokenUsage.monthLine) .font(.caption) .lineLimit(1) + ForEach(tokenUsage.comparisonLines, id: \.self) { line in + Text(line) + .font(.caption) + .lineLimit(1) + } if let hint = tokenUsage.hintLine, !hint.isEmpty { Text(hint) .font(.footnote) @@ -831,6 +858,7 @@ extension UsageMenuCardView.Model { let tokenUsage = Self.tokenUsageSection( provider: input.provider, enabled: input.tokenCostMenuSectionEnabled, + comparisonPeriodsEnabled: input.costComparisonPeriodsEnabled, snapshot: tokenUsageSnapshot, error: input.tokenError) let subtitle = Self.subtitle( diff --git a/Sources/CodexBar/PreferencesGeneralPane.swift b/Sources/CodexBar/PreferencesGeneralPane.swift index 917c1eabb9..6291027918 100644 --- a/Sources/CodexBar/PreferencesGeneralPane.swift +++ b/Sources/CodexBar/PreferencesGeneralPane.swift @@ -170,6 +170,11 @@ struct GeneralPane: View { CostHistoryDaysEditor(settings: self.settings) + PreferenceToggleRow( + title: L("cost_comparison_periods_title"), + subtitle: L("cost_comparison_periods_subtitle"), + binding: self.$settings.costComparisonPeriodsEnabled) + Text(L("cost_auto_refresh_info")) .font(.footnote) .foregroundStyle(.tertiary) diff --git a/Sources/CodexBar/Resources/ar.lproj/Localizable.strings b/Sources/CodexBar/Resources/ar.lproj/Localizable.strings index bce54e5b77..ce3ff0983e 100644 --- a/Sources/CodexBar/Resources/ar.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ar.lproj/Localizable.strings @@ -427,6 +427,8 @@ "cost_history_window_title" = "نافذة التاريخ"; "cost_history_window_help" = "يحدد عدد أيام سجلات الاستخدام المحلية التي تظهر في القائمة."; "cost_history_days_title" = "نافذة التاريخ: %d أيام"; +"cost_comparison_periods_title" = "إظهار فترات مقارنة أقصر"; +"cost_comparison_periods_subtitle" = "أضف إجماليات 7 و30 و90 يومًا عندما تقع ضمن نافذة التاريخ المحددة. تعيد هذه الإجماليات استخدام الفحص المحلي نفسه."; "cost_auto_refresh_info" = "تحديث تلقائي: كل ساعة · وقت الاستراحة: 10m"; "refresh_cadence_title" = "وتيرة التحديث"; "refresh_cadence_subtitle" = "كم مرة CodexBar استطلاعات في الخلفية."; diff --git a/Sources/CodexBar/Resources/en.lproj/Localizable.strings b/Sources/CodexBar/Resources/en.lproj/Localizable.strings index 32142e332d..86d3ad0076 100644 --- a/Sources/CodexBar/Resources/en.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/en.lproj/Localizable.strings @@ -427,6 +427,8 @@ "cost_history_window_title" = "History window"; "cost_history_window_help" = "Sets how many days of local usage logs appear in the menu."; "cost_history_days_title" = "History window: %d days"; +"cost_comparison_periods_title" = "Show shorter comparison periods"; +"cost_comparison_periods_subtitle" = "Add 7, 30, and 90-day totals when they fit inside the selected history window. These totals reuse the same local scan."; "cost_auto_refresh_info" = "Auto-refresh: hourly · Timeout: 10m"; "refresh_cadence_title" = "Refresh cadence"; "refresh_cadence_subtitle" = "How often CodexBar polls providers in the background."; diff --git a/Sources/CodexBar/Resources/fa.lproj/Localizable.strings b/Sources/CodexBar/Resources/fa.lproj/Localizable.strings index 31fe59ee28..a94de0048b 100644 --- a/Sources/CodexBar/Resources/fa.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/fa.lproj/Localizable.strings @@ -427,6 +427,8 @@ "cost_history_window_title" = "پنجره تاریخچه"; "cost_history_window_help" = "تعیین می‌کند چند روز از گزارش‌های استفاده محلی در منو نشان داده شود."; "cost_history_days_title" = "پنجره تاریخچه: %d روز"; +"cost_comparison_periods_title" = "نمایش دوره‌های مقایسه کوتاه‌تر"; +"cost_comparison_periods_subtitle" = "وقتی در پنجره تاریخچه انتخاب‌شده جا می‌گیرند، مجموع‌های ۷، ۳۰ و ۹۰ روزه را اضافه کنید. این مجموع‌ها از همان اسکن محلی استفاده می‌کنند."; "cost_auto_refresh_info" = "تازه سازی خودکار: ساعتی · زمان استراحت: 10m"; "refresh_cadence_title" = "کادانس تازه سازی"; "refresh_cadence_subtitle" = "چند وقت یکبار CodexBar ارائه دهندگان نظرسنجی در پس زمینه انجام می دهند."; diff --git a/Sources/CodexBar/Resources/id.lproj/Localizable.strings b/Sources/CodexBar/Resources/id.lproj/Localizable.strings index 611fade335..19c46d69a6 100644 --- a/Sources/CodexBar/Resources/id.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/id.lproj/Localizable.strings @@ -429,6 +429,8 @@ "cost_history_window_title" = "Jendela riwayat"; "cost_history_window_help" = "Menentukan berapa hari log penggunaan lokal yang ditampilkan di menu."; "cost_history_days_title" = "Jendela riwayat: %d hari"; +"cost_comparison_periods_title" = "Tampilkan periode perbandingan yang lebih singkat"; +"cost_comparison_periods_subtitle" = "Tambahkan total 7, 30, dan 90 hari jika termasuk dalam rentang riwayat yang dipilih. Total ini menggunakan kembali pemindaian lokal yang sama."; "cost_auto_refresh_info" = "Penyegaran otomatis: per jam · Batas waktu: 10m"; "refresh_cadence_title" = "Frekuensi penyegaran"; "refresh_cadence_subtitle" = "Seberapa sering CodexBar memeriksa penyedia di latar belakang."; diff --git a/Sources/CodexBar/Resources/it.lproj/Localizable.strings b/Sources/CodexBar/Resources/it.lproj/Localizable.strings index 3cf3387e1d..9e2e35d6d4 100644 --- a/Sources/CodexBar/Resources/it.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/it.lproj/Localizable.strings @@ -429,6 +429,8 @@ "cost_history_window_title" = "Finestra storica"; "cost_history_window_help" = "Imposta quanti giorni di log di utilizzo locali mostrare nel menu."; "cost_history_days_title" = "Finestra storica: %d giorni"; +"cost_comparison_periods_title" = "Mostra periodi di confronto più brevi"; +"cost_comparison_periods_subtitle" = "Aggiungi i totali di 7, 30 e 90 giorni quando rientrano nell'intervallo di cronologia selezionato. Questi totali riutilizzano la stessa scansione locale."; "cost_auto_refresh_info" = "Aggiornamento automatico: ogni ora · Timeout: 10 min"; "refresh_cadence_title" = "Frequenza aggiornamento"; "refresh_cadence_subtitle" = "Con quale frequenza CodexBar interroga i provider in background."; diff --git a/Sources/CodexBar/Resources/pl.lproj/Localizable.strings b/Sources/CodexBar/Resources/pl.lproj/Localizable.strings index 397c781098..8c1d98e9dd 100644 --- a/Sources/CodexBar/Resources/pl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/pl.lproj/Localizable.strings @@ -429,6 +429,8 @@ "cost_history_window_title" = "Zakres historii"; "cost_history_window_help" = "Ustawia, ile dni lokalnych dzienników użycia pokazać w menu."; "cost_history_days_title" = "Zakres historii: %d dni"; +"cost_comparison_periods_title" = "Pokaż krótsze okresy porównawcze"; +"cost_comparison_periods_subtitle" = "Dodaj sumy z 7, 30 i 90 dni, gdy mieszczą się w wybranym zakresie historii. Sumy te wykorzystują to samo skanowanie lokalne."; "cost_auto_refresh_info" = "Auto-odświeżanie: co godzinę · Limit czasu: 10 min"; "refresh_cadence_title" = "Częstotliwość odświeżania"; "refresh_cadence_subtitle" = "Jak często CodexBar odpyta dostawców w tle."; diff --git a/Sources/CodexBar/Resources/th.lproj/Localizable.strings b/Sources/CodexBar/Resources/th.lproj/Localizable.strings index bddab10f87..ec087c4937 100644 --- a/Sources/CodexBar/Resources/th.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/th.lproj/Localizable.strings @@ -427,6 +427,8 @@ "cost_history_window_title" = "กรอบเวลาประวัติ"; "cost_history_window_help" = "กำหนดจำนวนวันของบันทึกการใช้งานในเครื่องที่จะแสดงในเมนู"; "cost_history_days_title" = "กรอบเวลาประวัติ: %d วัน"; +"cost_comparison_periods_title" = "แสดงช่วงเปรียบเทียบที่สั้นกว่า"; +"cost_comparison_periods_subtitle" = "เพิ่มยอดรวม 7, 30 และ 90 วันเมื่ออยู่ภายในกรอบเวลาประวัติที่เลือก โดยใช้การสแกนในเครื่องเดียวกัน"; "cost_auto_refresh_info" = "รีเฟรชอัตโนมัติ: รายชั่วโมง · หมดเวลา: 10m"; "refresh_cadence_title" = "จังหวะการรีเฟรช"; "refresh_cadence_subtitle" = "ความถี่ในการ CodexBar ผู้ให้บริการโพลในเบื้องหลัง"; diff --git a/Sources/CodexBar/Resources/tr.lproj/Localizable.strings b/Sources/CodexBar/Resources/tr.lproj/Localizable.strings index 602073b9c9..bccee34ceb 100644 --- a/Sources/CodexBar/Resources/tr.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/tr.lproj/Localizable.strings @@ -427,6 +427,8 @@ "cost_history_window_title" = "Geçmiş penceresi"; "cost_history_window_help" = "Menüde kaç günlük yerel kullanım günlüğünün gösterileceğini belirler."; "cost_history_days_title" = "Geçmiş penceresi: %d gün"; +"cost_comparison_periods_title" = "Daha kısa karşılaştırma dönemlerini göster"; +"cost_comparison_periods_subtitle" = "Seçilen geçmiş aralığına sığdığında 7, 30 ve 90 günlük toplamları ekler. Bu toplamlar aynı yerel taramayı yeniden kullanır."; "cost_auto_refresh_info" = "Otomatik yenileme: saatlik · Zaman aşımı: 10 dk"; "refresh_cadence_title" = "Yenileme sıklığı"; "refresh_cadence_subtitle" = "CodexBar'ın arka planda sağlayıcıları ne sıklıkla sorgulayacağı."; diff --git a/Sources/CodexBar/SettingsStore+Defaults.swift b/Sources/CodexBar/SettingsStore+Defaults.swift index 16f35a1ff4..23b9c609fb 100644 --- a/Sources/CodexBar/SettingsStore+Defaults.swift +++ b/Sources/CodexBar/SettingsStore+Defaults.swift @@ -321,6 +321,14 @@ extension SettingsStore { } } + var costComparisonPeriodsEnabled: Bool { + get { self.defaultsState.costComparisonPeriodsEnabled } + set { + self.defaultsState.costComparisonPeriodsEnabled = newValue + self.userDefaults.set(newValue, forKey: "costComparisonPeriodsEnabled") + } + } + var costSummaryDisplayStyleRaw: String { get { self.defaultsState.costSummaryDisplayStyleRaw } set { diff --git a/Sources/CodexBar/SettingsStore+MenuObservation.swift b/Sources/CodexBar/SettingsStore+MenuObservation.swift index ce0ea85e3c..021051234e 100644 --- a/Sources/CodexBar/SettingsStore+MenuObservation.swift +++ b/Sources/CodexBar/SettingsStore+MenuObservation.swift @@ -34,6 +34,7 @@ extension SettingsStore { _ = self.copilotIconSecondaryWindowIDRaw _ = self.costUsageEnabled _ = self.costUsageHistoryDays + _ = self.costComparisonPeriodsEnabled _ = self.costSummaryDisplayStyle _ = self.appLanguage _ = self.hidePersonalInfo diff --git a/Sources/CodexBar/SettingsStore.swift b/Sources/CodexBar/SettingsStore.swift index 33d35e3e47..f130c2fcca 100644 --- a/Sources/CodexBar/SettingsStore.swift +++ b/Sources/CodexBar/SettingsStore.swift @@ -411,6 +411,8 @@ extension SettingsStore { let costUsageEnabled = userDefaults.object(forKey: "tokenCostUsageEnabled") as? Bool ?? false let rawCostUsageHistoryDays = userDefaults.object(forKey: "tokenCostUsageHistoryDays") as? Int ?? 30 let costUsageHistoryDays = max(1, min(365, rawCostUsageHistoryDays)) + let costComparisonPeriodsEnabled = userDefaults.object( + forKey: "costComparisonPeriodsEnabled") as? Bool ?? false let costSummaryDisplayStyleRaw = Self.loadCostSummaryDisplayStyleRaw( userDefaults: userDefaults, costUsageEnabled: costUsageEnabled) @@ -488,6 +490,7 @@ extension SettingsStore { copilotIconSecondaryWindowIDRaw: copilotIconSecondaryWindowIDRaw, costUsageEnabled: costUsageEnabled, costUsageHistoryDays: costUsageHistoryDays, + costComparisonPeriodsEnabled: costComparisonPeriodsEnabled, costSummaryDisplayStyleRaw: costSummaryDisplayStyleRaw, hidePersonalInfo: hidePersonalInfo, randomBlinkEnabled: randomBlinkEnabled, diff --git a/Sources/CodexBar/SettingsStoreState.swift b/Sources/CodexBar/SettingsStoreState.swift index 23484b9241..5d31e4b3e2 100644 --- a/Sources/CodexBar/SettingsStoreState.swift +++ b/Sources/CodexBar/SettingsStoreState.swift @@ -34,6 +34,7 @@ struct SettingsDefaultsState { var copilotIconSecondaryWindowIDRaw: String var costUsageEnabled: Bool var costUsageHistoryDays: Int + var costComparisonPeriodsEnabled: Bool var costSummaryDisplayStyleRaw: String var hidePersonalInfo: Bool var randomBlinkEnabled: Bool diff --git a/Sources/CodexBar/StatusItemController+CostMenuCard.swift b/Sources/CodexBar/StatusItemController+CostMenuCard.swift index 05424c8d50..88cdb1fd23 100644 --- a/Sources/CodexBar/StatusItemController+CostMenuCard.swift +++ b/Sources/CodexBar/StatusItemController+CostMenuCard.swift @@ -88,14 +88,14 @@ extension StatusItemController { } static func costMenuTooltipLines(tokenUsage: UsageMenuCardView.Model.TokenUsageSection?) -> [String] { - [ + let lines = [ tokenUsage?.sessionLine, tokenUsage?.monthLine, - tokenUsage?.hintLine, - tokenUsage?.errorLine, ] .compactMap(\.self) - .filter { !$0.isEmpty } + + (tokenUsage?.comparisonLines ?? []) + + [tokenUsage?.hintLine, tokenUsage?.errorLine].compactMap(\.self) + return lines.filter { !$0.isEmpty } } static func costMenuVisibleDetailLines( @@ -103,12 +103,13 @@ extension StatusItemController { hasSubmenu: Bool) -> [String] { guard !hasSubmenu else { return [] } - let primaryLines = [ + let primaryLines = ([ tokenUsage?.sessionLine, tokenUsage?.monthLine, - tokenUsage?.errorLine, ] .compactMap(\.self) + + (tokenUsage?.comparisonLines ?? []) + + [tokenUsage?.errorLine].compactMap(\.self)) .filter { !$0.isEmpty } guard primaryLines.isEmpty else { return primaryLines } return [tokenUsage?.hintLine] diff --git a/Sources/CodexBar/StatusItemController+MenuCardModel.swift b/Sources/CodexBar/StatusItemController+MenuCardModel.swift index aff7e04926..052eac0ca2 100644 --- a/Sources/CodexBar/StatusItemController+MenuCardModel.swift +++ b/Sources/CodexBar/StatusItemController+MenuCardModel.swift @@ -111,6 +111,7 @@ extension StatusItemController { tokenCostInlineDashboardEnabled: self.settings.costSummaryShowsInlineDashboard(for: target), tokenCostMenuSectionEnabled: !UsageStore.tokenCostRequiresProviderSnapshot(target) && self.settings.costSummaryShowsSubmenu(for: target), + costComparisonPeriodsEnabled: self.settings.costComparisonPeriodsEnabled, showOptionalCreditsAndExtraUsage: self.settings.showOptionalCreditsAndExtraUsage, copilotBudgetExtrasEnabled: self.settings.copilotBudgetExtrasEnabled, sourceLabel: sourceLabel, diff --git a/Sources/CodexBarCore/CostUsageModels.swift b/Sources/CodexBarCore/CostUsageModels.swift index d2a6a4d2c7..92dce402b7 100644 --- a/Sources/CodexBarCore/CostUsageModels.swift +++ b/Sources/CodexBarCore/CostUsageModels.swift @@ -1,5 +1,27 @@ import Foundation +public struct CostUsageWindowSummary: Sendable, Equatable { + public let days: Int + public let totalTokens: Int? + public let totalCostUSD: Double? + public let totalRequests: Int? + public let entryCount: Int + + public init( + days: Int, + totalTokens: Int?, + totalCostUSD: Double?, + totalRequests: Int?, + entryCount: Int) + { + self.days = days + self.totalTokens = totalTokens + self.totalCostUSD = totalCostUSD + self.totalRequests = totalRequests + self.entryCount = entryCount + } +} + public struct CostUsageTokenSnapshot: Sendable, Equatable { public let sessionTokens: Int? public let sessionCostUSD: Double? @@ -45,6 +67,37 @@ public struct CostUsageTokenSnapshot: Sendable, Equatable { Self.entry(in: self.daily, forLocalDayContaining: self.updatedAt, calendar: calendar) } + public func summary(forLastDays requestedDays: Int, calendar: Calendar = .current) -> CostUsageWindowSummary { + let days = max(1, requestedDays) + let today = calendar.startOfDay(for: self.updatedAt) + let start = calendar.date(byAdding: .day, value: -(days - 1), to: today) ?? today + let startKey = CostUsageLocalDay.key(from: start, calendar: calendar) + let endKey = CostUsageLocalDay.key(from: today, calendar: calendar) + let entries = self.daily.filter { entry in + guard let dayKey = Self.localDayKey(for: entry.date, calendar: calendar) else { return false } + return dayKey >= startKey && dayKey <= endKey + } + let costs = entries.compactMap(\.costUSD) + let tokens = entries.compactMap(\.totalTokens) + let requests = entries.compactMap(\.requestCount) + return CostUsageWindowSummary( + days: days, + totalTokens: tokens.isEmpty ? nil : tokens.reduce(0, +), + totalCostUSD: costs.isEmpty ? nil : costs.reduce(0, +), + totalRequests: requests.isEmpty ? nil : requests.reduce(0, +), + entryCount: entries.count) + } + + public func comparisonSummaries( + periods: [Int] = [7, 30, 90], + calendar: Calendar = .current) -> [CostUsageWindowSummary] + { + Array(Set(periods.map { max(1, $0) })) + .filter { $0 < self.historyDays } + .sorted() + .map { self.summary(forLastDays: $0, calendar: calendar) } + } + public static func latestEntry(in entries: [CostUsageDailyReport.Entry]) -> CostUsageDailyReport.Entry? { entries.compactMap { entry -> (entry: CostUsageDailyReport.Entry, date: Date)? in guard let date = CostUsageDateParser.parse(entry.date) else { return nil } @@ -75,6 +128,20 @@ public struct CostUsageTokenSnapshot: Sendable, Equatable { return CostUsageLocalDay.key(from: parsed, calendar: calendar) == dayKey } } + + private static func localDayKey(for rawDate: String, calendar: Calendar) -> String? { + let trimmed = rawDate.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.count >= 10 { + let prefix = String(trimmed.prefix(10)) + if prefix.count == 10, prefix[prefix.index(prefix.startIndex, offsetBy: 4)] == "-", + prefix[prefix.index(prefix.startIndex, offsetBy: 7)] == "-" + { + return prefix + } + } + guard let parsed = CostUsageDateParser.parse(trimmed) else { return nil } + return CostUsageLocalDay.key(from: parsed, calendar: calendar) + } } public struct CostUsageDailyReport: Sendable, Decodable { diff --git a/Tests/CodexBarTests/CostUsageWindowSummaryTests.swift b/Tests/CodexBarTests/CostUsageWindowSummaryTests.swift new file mode 100644 index 0000000000..94b640ef10 --- /dev/null +++ b/Tests/CodexBarTests/CostUsageWindowSummaryTests.swift @@ -0,0 +1,80 @@ +import CodexBarCore +import Foundation +import Testing + +struct CostUsageWindowSummaryTests { + @Test + func `summaries use calendar windows instead of the last nonempty rows`() { + let snapshot = Self.snapshot(historyDays: 90) + let summary = snapshot.summary(forLastDays: 7, calendar: Self.utcCalendar) + + #expect(summary.days == 7) + #expect(summary.entryCount == 2) + #expect(summary.totalCostUSD == 9) + #expect(summary.totalTokens == 900) + #expect(summary.totalRequests == 9) + } + + @Test + func `comparison periods are unique sorted and bounded by scanned history`() { + let snapshot = Self.snapshot(historyDays: 90) + + #expect(snapshot.comparisonSummaries(periods: [30, 7, 90, 7], calendar: Self.utcCalendar).map(\.days) == [ + 7, + 30, + ]) + } + + @Test + func `summary preserves unavailable totals as nil`() { + let snapshot = CostUsageTokenSnapshot( + sessionTokens: nil, + sessionCostUSD: nil, + last30DaysTokens: nil, + last30DaysCostUSD: nil, + historyDays: 30, + daily: [Self.entry(day: "2026-07-01", cost: nil, tokens: nil, requests: nil)], + updatedAt: Self.now) + + let summary = snapshot.summary(forLastDays: 7, calendar: Self.utcCalendar) + #expect(summary.totalCostUSD == nil) + #expect(summary.totalTokens == nil) + #expect(summary.totalRequests == nil) + } + + private static func snapshot(historyDays: Int) -> CostUsageTokenSnapshot { + CostUsageTokenSnapshot( + sessionTokens: 500, + sessionCostUSD: 5, + last30DaysTokens: 1000, + last30DaysCostUSD: 10, + historyDays: historyDays, + daily: [ + self.entry(day: "2026-06-01", cost: 1, tokens: 100, requests: 1), + self.entry(day: "2026-06-25", cost: 4, tokens: 400, requests: 4), + self.entry(day: "2026-07-01", cost: 5, tokens: 500, requests: 5), + ], + updatedAt: self.now) + } + + private static func entry(day: String, cost: Double?, tokens: Int?, requests: Int?) + -> CostUsageDailyReport.Entry + { + CostUsageDailyReport.Entry( + date: day, + inputTokens: nil, + outputTokens: nil, + totalTokens: tokens, + requestCount: requests, + costUSD: cost, + modelsUsed: nil, + modelBreakdowns: nil) + } + + private static let now = Date(timeIntervalSince1970: 1_782_864_000) // 2026-07-01 00:00:00 UTC + private static var utcCalendar: Calendar { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(secondsFromGMT: 0)! + return calendar + } +} diff --git a/Tests/CodexBarTests/MenuCardCostComparisonTests.swift b/Tests/CodexBarTests/MenuCardCostComparisonTests.swift new file mode 100644 index 0000000000..635f9e59e7 --- /dev/null +++ b/Tests/CodexBarTests/MenuCardCostComparisonTests.swift @@ -0,0 +1,65 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +struct MenuCardCostComparisonTests { + @Test + func `cost section adds shorter periods from the same history snapshot`() throws { + let snapshot = CostUsageTokenSnapshot( + sessionTokens: 400, + sessionCostUSD: 4, + last30DaysTokens: 1000, + last30DaysCostUSD: 10, + historyDays: 90, + daily: [ + Self.entry(day: "2026-06-01", cost: 1, tokens: 100), + Self.entry(day: "2026-06-25", cost: 2, tokens: 200), + Self.entry(day: "2026-07-01", cost: 4, tokens: 400), + ], + updatedAt: Date(timeIntervalSince1970: 1_782_864_000)) + + let section = try #require(UsageMenuCardView.Model.tokenUsageSection( + provider: .claude, + enabled: true, + comparisonPeriodsEnabled: true, + snapshot: snapshot, + error: nil)) + + #expect(section.comparisonLines == [ + "Last 7 days: $6.00 · 600 tokens", + "Last 30 days: $6.00 · 600 tokens", + ]) + } + + @Test + func `comparison periods remain opt in`() throws { + let snapshot = CostUsageTokenSnapshot( + sessionTokens: 1, + sessionCostUSD: 1, + last30DaysTokens: 1, + last30DaysCostUSD: 1, + historyDays: 90, + daily: [], + updatedAt: Date()) + + let section = try #require(UsageMenuCardView.Model.tokenUsageSection( + provider: .claude, + enabled: true, + comparisonPeriodsEnabled: false, + snapshot: snapshot, + error: nil)) + #expect(section.comparisonLines.isEmpty) + } + + private static func entry(day: String, cost: Double, tokens: Int) -> CostUsageDailyReport.Entry { + CostUsageDailyReport.Entry( + date: day, + inputTokens: nil, + outputTokens: nil, + totalTokens: tokens, + costUSD: cost, + modelsUsed: nil, + modelBreakdowns: nil) + } +} diff --git a/Tests/CodexBarTests/PreferencesPaneSmokeTests.swift b/Tests/CodexBarTests/PreferencesPaneSmokeTests.swift index 3245c08b58..653e9d1e02 100644 --- a/Tests/CodexBarTests/PreferencesPaneSmokeTests.swift +++ b/Tests/CodexBarTests/PreferencesPaneSmokeTests.swift @@ -29,6 +29,8 @@ struct PreferencesPaneSmokeTests { settings.multiAccountMenuLayout = .stacked settings.hidePersonalInfo = true settings.resetTimesShowAbsolute = true + settings.costUsageEnabled = true + settings.costComparisonPeriodsEnabled = true settings.debugDisableKeychainAccess = true settings.claudeOAuthKeychainPromptMode = .always settings.refreshFrequency = .manual diff --git a/Tests/CodexBarTests/SettingsStoreTests.swift b/Tests/CodexBarTests/SettingsStoreTests.swift index fabc19b333..59b4fd416d 100644 --- a/Tests/CodexBarTests/SettingsStoreTests.swift +++ b/Tests/CodexBarTests/SettingsStoreTests.swift @@ -1376,6 +1376,29 @@ struct SettingsStoreTests { #expect(store.isProviderEnabled(provider: .alibaba, metadata: metadata)) } + @Test + func `cost comparison periods default off and persist`() throws { + let suite = "SettingsStoreTests-cost-comparison-periods" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + let configStore = testConfigStore(suiteName: suite) + let storeA = SettingsStore( + userDefaults: defaults, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + + #expect(!storeA.costComparisonPeriodsEnabled) + storeA.costComparisonPeriodsEnabled = true + + let storeB = SettingsStore( + userDefaults: defaults, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + #expect(storeB.costComparisonPeriodsEnabled) + } + @Test func `cost summary display style defaults to both and persists`() throws { let suite = "SettingsStoreTests-cost-summary-display-style" diff --git a/docs/cost-window-comparisons.md b/docs/cost-window-comparisons.md new file mode 100644 index 0000000000..3351fa0e77 --- /dev/null +++ b/docs/cost-window-comparisons.md @@ -0,0 +1,35 @@ +# Cost window comparison decision + +Issues: [#1500](https://github.com/steipete/CodexBar/issues/1500), [#1708](https://github.com/steipete/CodexBar/issues/1708) + +## Proposed product shape + +Keep the existing history-window setting as the maximum local scan window. Add an opt-in **Show shorter comparison periods** preference, defaulting off. When enabled, the cost card adds fixed 7, 30, and 90-day totals that are shorter than the selected history window. + +Examples: + +- 30-day history: Today, Last 30 days, Last 7 days. +- 90-day history: Today, Last 90 days, Last 7 days, Last 30 days. +- 365-day history: Today, Last 365 days, Last 7 days, Last 30 days, Last 90 days. + +The implementation derives every comparison from the already-loaded daily report. It does not widen scans, add network requests, retain new data, or change provider source selection. Missing calendar days remain zero-usage days rather than making “last 7 days” mean “last 7 non-empty rows.” + +## Why this does not claim lifetime cost + +“All available local logs” and “lifetime since install” are different contracts. Local Codex, Claude, and Pi logs may be moved, pruned, excluded, or created before CodexBar was installed. The existing plan-utilization history is also capped and has no token or cost ledger. A 365-day total therefore cannot honestly be labeled a lifetime bill. + +A true #1708 implementation needs separate approval for an append-only local ledger with: + +- an explicit collection start date and completeness state; +- provider/account ownership and reset behavior; +- migration, retention, export, and deletion controls; +- privacy documentation and bounded storage tests; +- UI wording that separates observed utilization snapshots from estimated local-log cost. + +Recommendation: ship the opt-in comparison rows for #1500 independently. Keep #1708 open until the ledger/data-retention contract is approved; label any future scan-only total **Available local logs**, never **Lifetime**. + +## Maintainer choice + +1. **Recommended:** merge with the preference default off. Existing UI and scan cost stay unchanged until a user opts in. +2. Default the preference on. More useful immediately, but adds vertical menu density for existing users. +3. Keep the model only and revisit the presentation. This preserves tested calendar-window aggregation without shipping a new setting. From 5860d4f3e85af24459274bdba8e90c7863a95880 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 4 Jul 2026 13:43:42 +0100 Subject: [PATCH 2/3] fix: finish cost comparison settings port --- Sources/CodexBar/PreferencesDisplayPane.swift | 6 ++++++ Sources/CodexBar/Resources/ca.lproj/Localizable.strings | 2 ++ Sources/CodexBar/Resources/de.lproj/Localizable.strings | 2 ++ Sources/CodexBar/Resources/es.lproj/Localizable.strings | 2 ++ Sources/CodexBar/Resources/fr.lproj/Localizable.strings | 2 ++ Sources/CodexBar/Resources/gl.lproj/Localizable.strings | 2 ++ Sources/CodexBar/Resources/ja.lproj/Localizable.strings | 2 ++ Sources/CodexBar/Resources/ko.lproj/Localizable.strings | 2 ++ Sources/CodexBar/Resources/nl.lproj/Localizable.strings | 2 ++ Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings | 2 ++ Sources/CodexBar/Resources/ru.lproj/Localizable.strings | 2 ++ Sources/CodexBar/Resources/sv.lproj/Localizable.strings | 2 ++ Sources/CodexBar/Resources/uk.lproj/Localizable.strings | 2 ++ Sources/CodexBar/Resources/vi.lproj/Localizable.strings | 2 ++ .../CodexBar/Resources/zh-Hans.lproj/Localizable.strings | 2 ++ .../CodexBar/Resources/zh-Hant.lproj/Localizable.strings | 2 ++ 16 files changed, 36 insertions(+) diff --git a/Sources/CodexBar/PreferencesDisplayPane.swift b/Sources/CodexBar/PreferencesDisplayPane.swift index 38a2b466f8..11546bed0f 100644 --- a/Sources/CodexBar/PreferencesDisplayPane.swift +++ b/Sources/CodexBar/PreferencesDisplayPane.swift @@ -241,6 +241,12 @@ struct CostSummarySettingsSection: View { } CostHistoryDaysEditor(settings: self.settings) + + Toggle(isOn: self.$settings.costComparisonPeriodsEnabled) { + SettingsRowLabel( + L("cost_comparison_periods_title"), + subtitle: L("cost_comparison_periods_subtitle")) + } } } header: { Text(L("section_cost_summary")) diff --git a/Sources/CodexBar/Resources/ca.lproj/Localizable.strings b/Sources/CodexBar/Resources/ca.lproj/Localizable.strings index c4a19dfeec..54770860a1 100644 --- a/Sources/CodexBar/Resources/ca.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ca.lproj/Localizable.strings @@ -427,6 +427,8 @@ "cost_history_window_help" = "Defineix quants dies de registres d'ús locals apareixen al menú."; "cost_history_days_title" = "Finestra d'historial: %d dies"; "cost_auto_refresh_info" = "Actualització automàtica: cada hora · Temps d'espera: 10 min"; +"cost_comparison_periods_title" = "Mostra períodes de comparació més curts"; +"cost_comparison_periods_subtitle" = "Afegeix totals de 7, 30 i 90 dies quan càpiguen dins l'interval d'historial seleccionat. Aquests totals reutilitzen la mateixa exploració local."; "refresh_cadence_title" = "Freqüència d'actualització"; "refresh_cadence_subtitle" = "Amb quina freqüència el CodexBar consulta els proveïdors en segon pla."; "manual_refresh_hint" = "L'actualització automàtica està desactivada; feu servir l'ordre Actualitza del menú."; diff --git a/Sources/CodexBar/Resources/de.lproj/Localizable.strings b/Sources/CodexBar/Resources/de.lproj/Localizable.strings index b8d9c1fc13..1750dbb461 100644 --- a/Sources/CodexBar/Resources/de.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/de.lproj/Localizable.strings @@ -429,6 +429,8 @@ "cost_history_window_help" = "Legt fest, wie viele Tage lokaler Nutzungsprotokolle im Menü erscheinen."; "cost_history_days_title" = "Verlaufsfenster: %d Tage"; "cost_auto_refresh_info" = "Auto-Aktualisierung: stündlich · Timeout: 10 Min."; +"cost_comparison_periods_title" = "Kürzere Vergleichszeiträume anzeigen"; +"cost_comparison_periods_subtitle" = "Fügt Summen für 7, 30 und 90 Tage hinzu, wenn sie in den ausgewählten Verlaufszeitraum passen. Diese Summen verwenden denselben lokalen Scan."; "refresh_cadence_title" = "Aktualisierungsintervall"; "refresh_cadence_subtitle" = "Wie oft CodexBar Anbieter im Hintergrund abfragt."; "manual_refresh_hint" = "Auto-Aktualisierung ist aus; nutze im Menü den Befehl „Aktualisieren“."; diff --git a/Sources/CodexBar/Resources/es.lproj/Localizable.strings b/Sources/CodexBar/Resources/es.lproj/Localizable.strings index 0b8ff4a511..6df1177e20 100644 --- a/Sources/CodexBar/Resources/es.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/es.lproj/Localizable.strings @@ -432,6 +432,8 @@ "cost_history_window_help" = "Define cuántos días de registros de uso locales aparecen en el menú."; "cost_history_days_title" = "Ventana de historial: %d días"; "cost_auto_refresh_info" = "Actualización automática: cada hora · Tiempo de espera: 10 m"; +"cost_comparison_periods_title" = "Mostrar períodos de comparación más cortos"; +"cost_comparison_periods_subtitle" = "Añade totales de 7, 30 y 90 días cuando quepan en el intervalo de historial seleccionado. Estos totales reutilizan el mismo análisis local."; "refresh_cadence_title" = "Frecuencia de actualización"; "refresh_cadence_subtitle" = "Con qué frecuencia CodexBar consulta a los proveedores en segundo plano."; "manual_refresh_hint" = "La actualización automática está desactivada; usa el comando Actualizar del menú."; diff --git a/Sources/CodexBar/Resources/fr.lproj/Localizable.strings b/Sources/CodexBar/Resources/fr.lproj/Localizable.strings index b62e4e8e00..5dfecb3073 100644 --- a/Sources/CodexBar/Resources/fr.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/fr.lproj/Localizable.strings @@ -431,6 +431,8 @@ "cost_history_window_help" = "Définit le nombre de jours de journaux d'utilisation locaux affichés dans le menu."; "cost_history_days_title" = "Fenêtre d'historique : %d jours"; "cost_auto_refresh_info" = "Actualisation automatique : toutes les heures · Délai d'expiration : 10 min"; +"cost_comparison_periods_title" = "Afficher des périodes de comparaison plus courtes"; +"cost_comparison_periods_subtitle" = "Ajoute les totaux sur 7, 30 et 90 jours lorsqu'ils tiennent dans la période d'historique sélectionnée. Ces totaux réutilisent la même analyse locale."; "refresh_cadence_title" = "Fréquence d'actualisation"; "refresh_cadence_subtitle" = "Définit la fréquence à laquelle CodexBar interroge les fournisseurs en arrière-plan."; "manual_refresh_hint" = "L'actualisation automatique est désactivée ; utilisez la commande Actualiser du menu."; diff --git a/Sources/CodexBar/Resources/gl.lproj/Localizable.strings b/Sources/CodexBar/Resources/gl.lproj/Localizable.strings index 3604c7e51f..c716d497ee 100644 --- a/Sources/CodexBar/Resources/gl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/gl.lproj/Localizable.strings @@ -428,6 +428,8 @@ "cost_history_window_help" = "Define cantos días de rexistros de uso locais aparecen no menú."; "cost_history_days_title" = "Xanela de historial: %d días"; "cost_auto_refresh_info" = "Actualización automática: cada hora · Tempo de espera: 10 m"; +"cost_comparison_periods_title" = "Mostrar períodos de comparación máis curtos"; +"cost_comparison_periods_subtitle" = "Engade totais de 7, 30 e 90 días cando caiban na xanela de historial seleccionada. Estes totais reutilizan a mesma análise local."; "refresh_cadence_title" = "Frecuencia de actualización"; "refresh_cadence_subtitle" = "Con que frecuencia CodexBar consulta os provedores en segundo plano."; "manual_refresh_hint" = "A actualización automática está desactivada; usa a orde Actualizar do menú."; diff --git a/Sources/CodexBar/Resources/ja.lproj/Localizable.strings b/Sources/CodexBar/Resources/ja.lproj/Localizable.strings index 984a0e7b6a..44b6d8e5ea 100644 --- a/Sources/CodexBar/Resources/ja.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ja.lproj/Localizable.strings @@ -428,6 +428,8 @@ "cost_history_window_help" = "メニューに表示するローカル使用ログの日数を設定します。"; "cost_history_days_title" = "履歴期間: %d 日"; "cost_auto_refresh_info" = "自動更新: 1 時間ごと · タイムアウト: 10 分"; +"cost_comparison_periods_title" = "短い比較期間を表示"; +"cost_comparison_periods_subtitle" = "選択した履歴期間に収まる場合、7日、30日、90日の合計を追加します。これらの合計には同じローカルスキャンを再利用します。"; "refresh_cadence_title" = "更新間隔"; "refresh_cadence_subtitle" = "CodexBar がバックグラウンドでプロバイダをポーリングする頻度です。"; "manual_refresh_hint" = "自動更新はオフです。メニューの「更新」コマンドを使用してください。"; diff --git a/Sources/CodexBar/Resources/ko.lproj/Localizable.strings b/Sources/CodexBar/Resources/ko.lproj/Localizable.strings index 61f34a0835..1e603e1cd6 100644 --- a/Sources/CodexBar/Resources/ko.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ko.lproj/Localizable.strings @@ -426,6 +426,8 @@ "cost_history_window_help" = "메뉴에 표시할 로컬 사용량 로그 일수를 설정합니다."; "cost_history_days_title" = "기록 범위: %d일"; "cost_auto_refresh_info" = "자동 새로 고침: 매시간 · 시간 초과: 10분"; +"cost_comparison_periods_title" = "더 짧은 비교 기간 표시"; +"cost_comparison_periods_subtitle" = "선택한 기록 범위에 포함되는 경우 7일, 30일, 90일 합계를 추가합니다. 이 합계에는 동일한 로컬 스캔을 재사용합니다."; "refresh_cadence_title" = "새로 고침 주기"; "refresh_cadence_subtitle" = "CodexBar가 백그라운드에서 공급자를 폴링하는 빈도입니다."; "manual_refresh_hint" = "자동 새로 고침이 꺼져 있습니다. 메뉴의 새로 고침 명령을 사용하세요."; diff --git a/Sources/CodexBar/Resources/nl.lproj/Localizable.strings b/Sources/CodexBar/Resources/nl.lproj/Localizable.strings index cbaada05b8..fb53ca9c9e 100644 --- a/Sources/CodexBar/Resources/nl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/nl.lproj/Localizable.strings @@ -431,6 +431,8 @@ "cost_history_window_help" = "Stelt in hoeveel dagen lokale gebruikslogboeken in het menu verschijnen."; "cost_history_days_title" = "Geschiedenisvenster: %d dagen"; "cost_auto_refresh_info" = "Automatisch vernieuwen: elk uur · Time-out: 10m"; +"cost_comparison_periods_title" = "Kortere vergelijkingsperioden tonen"; +"cost_comparison_periods_subtitle" = "Voegt totalen voor 7, 30 en 90 dagen toe wanneer ze binnen het geselecteerde geschiedenisvenster vallen. Deze totalen gebruiken dezelfde lokale scan."; "refresh_cadence_title" = "Cadans vernieuwen"; "refresh_cadence_subtitle" = "Hoe vaak CodexBar providers op de achtergrond ondervraagt."; "manual_refresh_hint" = "Automatisch vernieuwen is uitgeschakeld; gebruik de opdracht Vernieuwen van het menu."; diff --git a/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings b/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings index 7283aa36aa..73c4a73438 100644 --- a/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings @@ -428,6 +428,8 @@ "cost_history_window_help" = "Define quantos dias de logs de uso locais aparecem no menu."; "cost_history_days_title" = "Janela do histórico: %d dias"; "cost_auto_refresh_info" = "Atualização automática: a cada hora · Timeout: 10 min"; +"cost_comparison_periods_title" = "Mostrar períodos de comparação mais curtos"; +"cost_comparison_periods_subtitle" = "Adiciona totais de 7, 30 e 90 dias quando couberem na janela de histórico selecionada. Esses totais reutilizam a mesma varredura local."; "refresh_cadence_title" = "Cadência de atualização"; "refresh_cadence_subtitle" = "Frequência com que o CodexBar consulta provedores em segundo plano."; "manual_refresh_hint" = "A atualização automática está desativada; use Atualizar no menu."; diff --git a/Sources/CodexBar/Resources/ru.lproj/Localizable.strings b/Sources/CodexBar/Resources/ru.lproj/Localizable.strings index d1d99a914b..62afcfdc6c 100644 --- a/Sources/CodexBar/Resources/ru.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ru.lproj/Localizable.strings @@ -432,6 +432,8 @@ "cost_history_window_help" = "Задаёт, за сколько дней показывать локальные журналы использования в меню."; "cost_history_days_title" = "Окно истории: %d дней"; "cost_auto_refresh_info" = "Автоматическое обновление: каждый час · Тайм-аут: 10 минут"; +"cost_comparison_periods_title" = "Показывать более короткие периоды сравнения"; +"cost_comparison_periods_subtitle" = "Добавляет итоги за 7, 30 и 90 дней, если они входят в выбранный период истории. Для этих итогов используется то же локальное сканирование."; "refresh_cadence_title" = "Частота обновления"; "refresh_cadence_subtitle" = "Как часто CodexBar опрашивает провайдеров в фоновом режиме."; "manual_refresh_hint" = "Автообновление отключено; используйте команду меню «Обновить»."; diff --git a/Sources/CodexBar/Resources/sv.lproj/Localizable.strings b/Sources/CodexBar/Resources/sv.lproj/Localizable.strings index f819599ff9..03428d64ac 100644 --- a/Sources/CodexBar/Resources/sv.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/sv.lproj/Localizable.strings @@ -431,6 +431,8 @@ "cost_history_window_help" = "Anger hur många dagar med lokala användningsloggar som visas i menyn."; "cost_history_days_title" = "Historikfönster: %d dagar"; "cost_auto_refresh_info" = "Automatisk uppdatering: varje timme · Timeout: 10 min"; +"cost_comparison_periods_title" = "Visa kortare jämförelseperioder"; +"cost_comparison_periods_subtitle" = "Lägger till summor för 7, 30 och 90 dagar när de ryms i det valda historikfönstret. Summorna återanvänder samma lokala genomsökning."; "refresh_cadence_title" = "Uppdateringsintervall"; "refresh_cadence_subtitle" = "Hur ofta CodexBar kontrollerar leverantörer i bakgrunden."; "manual_refresh_hint" = "Automatisk uppdatering är avstängd. Använd Uppdatera i menyn."; diff --git a/Sources/CodexBar/Resources/uk.lproj/Localizable.strings b/Sources/CodexBar/Resources/uk.lproj/Localizable.strings index 74a46b6524..e0bf19ce91 100644 --- a/Sources/CodexBar/Resources/uk.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/uk.lproj/Localizable.strings @@ -431,6 +431,8 @@ "cost_history_window_help" = "Визначає, скільки днів локальних журналів використання показувати в меню."; "cost_history_days_title" = "Вікно історії: %d днів"; "cost_auto_refresh_info" = "Автоматичне оновлення: щогодини · Час очікування: 10 хв"; +"cost_comparison_periods_title" = "Показувати коротші періоди порівняння"; +"cost_comparison_periods_subtitle" = "Додає підсумки за 7, 30 і 90 днів, якщо вони входять у вибране вікно історії. Для цих підсумків використовується те саме локальне сканування."; "refresh_cadence_title" = "Оновити каденцію"; "refresh_cadence_subtitle" = "Як часто CodexBar опитує постачальників у фоновому режимі."; "manual_refresh_hint" = "Автооновлення вимкнено; скористайтеся командою меню «Оновити»."; diff --git a/Sources/CodexBar/Resources/vi.lproj/Localizable.strings b/Sources/CodexBar/Resources/vi.lproj/Localizable.strings index fa3a84b6f7..ecc7afed71 100644 --- a/Sources/CodexBar/Resources/vi.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/vi.lproj/Localizable.strings @@ -427,6 +427,8 @@ "cost_history_window_help" = "Đặt số ngày nhật ký sử dụng cục bộ xuất hiện trong menu."; "cost_history_days_title" = "Cửa sổ lịch sử: %d ngày"; "cost_auto_refresh_info" = "Tự động làm mới: hàng giờ · Thời gian chờ: 10 phút"; +"cost_comparison_periods_title" = "Hiển thị các khoảng so sánh ngắn hơn"; +"cost_comparison_periods_subtitle" = "Thêm tổng 7, 30 và 90 ngày khi nằm trong cửa sổ lịch sử đã chọn. Các tổng này dùng lại cùng một lần quét cục bộ."; "refresh_cadence_title" = "Nhịp làm mới"; "refresh_cadence_subtitle" = "Tần suất CodexBar thăm dò ý kiến ​​các nhà cung cấp trong nền."; "manual_refresh_hint" = "Tính năng tự động làm mới bị tắt; sử dụng lệnh Làm mới của menu."; diff --git a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings index e506486577..777c2f83b3 100644 --- a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings @@ -435,6 +435,8 @@ "cost_history_window_help" = "设置菜单中显示多少天的本地使用日志。"; "cost_history_days_title" = "历史窗口:%d 天"; "cost_auto_refresh_info" = "自动刷新:每小时 · 超时:10 分钟"; +"cost_comparison_periods_title" = "显示更短的对比周期"; +"cost_comparison_periods_subtitle" = "当 7 天、30 天和 90 天处于所选历史窗口内时,添加相应汇总。这些汇总复用同一次本地扫描。"; "refresh_cadence_title" = "刷新频率"; "refresh_cadence_subtitle" = "CodexBar 在后台轮询提供商的频率。"; "manual_refresh_hint" = "自动刷新已关闭;请使用菜单中的“刷新”命令。"; diff --git a/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings b/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings index f42532589c..dc257068f7 100644 --- a/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings @@ -436,6 +436,8 @@ "cost_history_window_help" = "設定選單中顯示多少天的本機使用記錄。"; "cost_history_days_title" = "歷史時段:%d 天"; "cost_auto_refresh_info" = "自動重新整理:每小時 · 逾時:10 分鐘"; +"cost_comparison_periods_title" = "顯示較短的比較期間"; +"cost_comparison_periods_subtitle" = "當 7 天、30 天和 90 天落在所選歷史時段內時,加入相應總計。這些總計會重複使用同一次本機掃描。"; "refresh_cadence_title" = "重新整理頻率"; "refresh_cadence_subtitle" = "CodexBar 在背景輪詢提供者的頻率。"; "manual_refresh_hint" = "自動重新整理已關閉;請使用選單中的「重新整理」指令。"; From 51dbc1a59c3b6a499fd0b53d93cb5c28bda2dfdf Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 4 Jul 2026 14:12:29 +0100 Subject: [PATCH 3/3] fix: show cost comparisons in inline dashboard --- .../InlineUsageDashboardContent.swift | 18 ++++++-- Sources/CodexBar/MenuCardView+Costs.swift | 2 +- .../MenuCardCostComparisonTests.swift | 44 +++++++++++++++++++ 3 files changed, 60 insertions(+), 4 deletions(-) diff --git a/Sources/CodexBar/InlineUsageDashboardContent.swift b/Sources/CodexBar/InlineUsageDashboardContent.swift index 136a0c8f65..0644d39444 100644 --- a/Sources/CodexBar/InlineUsageDashboardContent.swift +++ b/Sources/CodexBar/InlineUsageDashboardContent.swift @@ -184,7 +184,10 @@ extension UsageMenuCardView.Model { let tokenSnapshot = primaryCostHistorySnapshot(input: input), !tokenSnapshot.daily.isEmpty { - return self.costHistoryInlineDashboard(provider: input.provider, snapshot: tokenSnapshot) + return self.costHistoryInlineDashboard( + provider: input.provider, + snapshot: tokenSnapshot, + comparisonPeriodsEnabled: input.costComparisonPeriodsEnabled) } if input.provider == .claude, let usage = input.snapshot?.claudeAdminAPIUsage @@ -231,7 +234,10 @@ extension UsageMenuCardView.Model { let tokenSnapshot = input.tokenSnapshot, !tokenSnapshot.daily.isEmpty { - return Self.costHistoryInlineDashboard(provider: input.provider, snapshot: tokenSnapshot) + return Self.costHistoryInlineDashboard( + provider: input.provider, + snapshot: tokenSnapshot, + comparisonPeriodsEnabled: input.costComparisonPeriodsEnabled) } return nil } @@ -319,7 +325,8 @@ extension UsageMenuCardView.Model { private static func costHistoryInlineDashboard( provider: UsageProvider, - snapshot: CostUsageTokenSnapshot) -> InlineUsageDashboardModel + snapshot: CostUsageTokenSnapshot, + comparisonPeriodsEnabled: Bool) -> InlineUsageDashboardModel { let historyDays = max(1, min(365, snapshot.historyDays)) let historyTitle = snapshot.historyLabel @@ -354,6 +361,11 @@ extension UsageMenuCardView.Model { let usesLatestPrimary = provider == .bedrock || provider == .mistral let primaryCostUSD = usesLatestPrimary ? latest?.costUSD : snapshot.sessionCostUSD var details: [String] = [] + if comparisonPeriodsEnabled { + details.append(contentsOf: snapshot.comparisonSummaries().map { + Self.costWindowLine(summary: $0, currencyCode: snapshot.currencyCode) + }) + } if let topModel = Self.topCostModel(from: snapshot.daily) { details.append("\(L("Top model")): \(Self.shortModelName(topModel))") } diff --git a/Sources/CodexBar/MenuCardView+Costs.swift b/Sources/CodexBar/MenuCardView+Costs.swift index c469b83000..51aa6c25e9 100644 --- a/Sources/CodexBar/MenuCardView+Costs.swift +++ b/Sources/CodexBar/MenuCardView+Costs.swift @@ -154,7 +154,7 @@ extension UsageMenuCardView.Model { errorCopyText: (error?.isEmpty ?? true) ? nil : error) } - private static func costWindowLine(summary: CostUsageWindowSummary, currencyCode: String) -> String { + static func costWindowLine(summary: CostUsageWindowSummary, currencyCode: String) -> String { let label = Self.costHistoryWindowLabel(days: summary.days) let cost = summary.totalCostUSD.map { UsageFormatter.currencyString($0, currencyCode: currencyCode) diff --git a/Tests/CodexBarTests/MenuCardCostComparisonTests.swift b/Tests/CodexBarTests/MenuCardCostComparisonTests.swift index 635f9e59e7..5e0ae3c9fc 100644 --- a/Tests/CodexBarTests/MenuCardCostComparisonTests.swift +++ b/Tests/CodexBarTests/MenuCardCostComparisonTests.swift @@ -52,6 +52,50 @@ struct MenuCardCostComparisonTests { #expect(section.comparisonLines.isEmpty) } + @Test + func `inline dashboard shows enabled comparison periods`() throws { + let now = Date(timeIntervalSince1970: 1_783_123_200) + let snapshot = CostUsageTokenSnapshot( + sessionTokens: 400, + sessionCostUSD: 4, + last30DaysTokens: 1000, + last30DaysCostUSD: 10, + historyDays: 90, + daily: [ + Self.entry(day: "2026-06-01", cost: 1, tokens: 100), + Self.entry(day: "2026-06-25", cost: 2, tokens: 200), + Self.entry(day: "2026-07-01", cost: 4, tokens: 400), + ], + updatedAt: now) + let metadata = try #require(ProviderDefaults.metadata[.codex]) + + let model = UsageMenuCardView.Model.make(.init( + provider: .codex, + metadata: metadata, + snapshot: nil, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: snapshot, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: true, + costComparisonPeriodsEnabled: true, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.inlineUsageDashboard?.detailLines.prefix(2) == [ + "Last 7 days: $4.00 · 400 tokens", + "Last 30 days: $6.00 · 600 tokens", + ]) + } + private static func entry(day: String, cost: Double, tokens: Int) -> CostUsageDailyReport.Entry { CostUsageDailyReport.Entry( date: day,