diff --git a/CHANGELOG.md b/CHANGELOG.md index 5657416a0a..b4c5e4a1c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added - Codex: show every available reset-credit expiry in menus and provider settings, including non-expiring credits, and summarize credits nearing expiry. Thanks @brahimhamichan! +- Cost history: optionally show shorter 7, 30, and 90-day comparisons from the selected local history window (#1500). Thanks @jtl06! - Codex cost history: group local usage and costs by project and worktree in menus and CLI output. Thanks @clemenspeters! - Sakana AI: show best-effort pay-as-you-go credit balance and recent usage without delaying subscription quota refreshes. Thanks @ss251! - Kimi: show monthly subscription usage alongside weekly and five-hour limits with a short total budget for the optional membership request. Thanks @zhiyue! 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/MenuCardHeightFingerprint.swift b/Sources/CodexBar/MenuCardHeightFingerprint.swift index 54d0592e24..0bef57eb32 100644 --- a/Sources/CodexBar/MenuCardHeightFingerprint.swift +++ b/Sources/CodexBar/MenuCardHeightFingerprint.swift @@ -111,6 +111,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 4da95b52b9..51aa6c25e9 100644 --- a/Sources/CodexBar/MenuCardView+Costs.swift +++ b/Sources/CodexBar/MenuCardView+Costs.swift @@ -101,6 +101,7 @@ extension UsageMenuCardView.Model { static func tokenUsageSection( provider: UsageProvider, enabled: Bool, + comparisonPeriodsEnabled: Bool, snapshot: CostUsageTokenSnapshot?, error: String?) -> TokenUsageSection? { @@ -143,11 +144,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) } + 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 910cceeb42..01406e2222 100644 --- a/Sources/CodexBar/MenuCardView+ModelHelpers.swift +++ b/Sources/CodexBar/MenuCardView+ModelHelpers.swift @@ -200,7 +200,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 f31333e59c..e5b2dc1846 100644 --- a/Sources/CodexBar/MenuCardView+ModelInput.swift +++ b/Sources/CodexBar/MenuCardView+ModelInput.swift @@ -22,6 +22,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? @@ -53,6 +54,7 @@ extension UsageMenuCardView.Model { tokenCostUsageEnabled: Bool, tokenCostInlineDashboardEnabled: Bool? = nil, tokenCostMenuSectionEnabled: Bool? = nil, + costComparisonPeriodsEnabled: Bool = false, showOptionalCreditsAndExtraUsage: Bool, copilotBudgetExtrasEnabled: Bool = false, sourceLabel: String? = nil, @@ -83,6 +85,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 9cadc51e12..7bcdeb6667 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 { @@ -199,6 +216,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) @@ -717,6 +739,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) @@ -832,6 +859,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/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/ar.lproj/Localizable.strings b/Sources/CodexBar/Resources/ar.lproj/Localizable.strings index ea594812bc..788e792f41 100644 --- a/Sources/CodexBar/Resources/ar.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ar.lproj/Localizable.strings @@ -431,6 +431,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/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/en.lproj/Localizable.strings b/Sources/CodexBar/Resources/en.lproj/Localizable.strings index ea2f3d119d..4b5b472f22 100644 --- a/Sources/CodexBar/Resources/en.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/en.lproj/Localizable.strings @@ -431,6 +431,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/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/fa.lproj/Localizable.strings b/Sources/CodexBar/Resources/fa.lproj/Localizable.strings index 065e9a94ca..e62f320240 100644 --- a/Sources/CodexBar/Resources/fa.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/fa.lproj/Localizable.strings @@ -431,6 +431,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/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/id.lproj/Localizable.strings b/Sources/CodexBar/Resources/id.lproj/Localizable.strings index 0596adeb7c..368a6480d3 100644 --- a/Sources/CodexBar/Resources/id.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/id.lproj/Localizable.strings @@ -433,6 +433,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 647f20d0b3..32ca01b05b 100644 --- a/Sources/CodexBar/Resources/it.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/it.lproj/Localizable.strings @@ -433,6 +433,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/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/pl.lproj/Localizable.strings b/Sources/CodexBar/Resources/pl.lproj/Localizable.strings index a6cbcf0549..09c348868d 100644 --- a/Sources/CodexBar/Resources/pl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/pl.lproj/Localizable.strings @@ -433,6 +433,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/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/th.lproj/Localizable.strings b/Sources/CodexBar/Resources/th.lproj/Localizable.strings index b64368d757..f537c2d1b9 100644 --- a/Sources/CodexBar/Resources/th.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/th.lproj/Localizable.strings @@ -431,6 +431,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 ff36b8a6b1..308b2740a1 100644 --- a/Sources/CodexBar/Resources/tr.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/tr.lproj/Localizable.strings @@ -431,6 +431,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/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" = "自動重新整理已關閉;請使用選單中的「重新整理」指令。"; diff --git a/Sources/CodexBar/SettingsStore+Defaults.swift b/Sources/CodexBar/SettingsStore+Defaults.swift index 612fb0f309..a550174def 100644 --- a/Sources/CodexBar/SettingsStore+Defaults.swift +++ b/Sources/CodexBar/SettingsStore+Defaults.swift @@ -339,6 +339,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 a22f411e31..fca59dfb84 100644 --- a/Sources/CodexBar/SettingsStore+MenuObservation.swift +++ b/Sources/CodexBar/SettingsStore+MenuObservation.swift @@ -35,6 +35,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 9432677d77..e13e568616 100644 --- a/Sources/CodexBar/SettingsStore.swift +++ b/Sources/CodexBar/SettingsStore.swift @@ -416,6 +416,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) @@ -494,6 +496,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 e72d6d794a..567f5f3309 100644 --- a/Sources/CodexBar/SettingsStoreState.swift +++ b/Sources/CodexBar/SettingsStoreState.swift @@ -36,6 +36,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 3d8572132b..df7a0734a9 100644 --- a/Sources/CodexBar/StatusItemController+MenuCardModel.swift +++ b/Sources/CodexBar/StatusItemController+MenuCardModel.swift @@ -112,6 +112,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 797faf4915..bee1022d83 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? @@ -48,6 +70,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 } @@ -78,6 +131,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 CostUsageProjectBreakdown: Sendable, Equatable { 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..5e0ae3c9fc --- /dev/null +++ b/Tests/CodexBarTests/MenuCardCostComparisonTests.swift @@ -0,0 +1,109 @@ +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) + } + + @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, + 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 19b9568c36..7256275cd0 100644 --- a/Tests/CodexBarTests/PreferencesPaneSmokeTests.swift +++ b/Tests/CodexBarTests/PreferencesPaneSmokeTests.swift @@ -30,6 +30,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 31db735e16..6ddc25d59a 100644 --- a/Tests/CodexBarTests/SettingsStoreTests.swift +++ b/Tests/CodexBarTests/SettingsStoreTests.swift @@ -1448,6 +1448,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.