diff --git a/Sources/CodexBar/PreferencesSpendDashboardPane.swift b/Sources/CodexBar/PreferencesSpendDashboardPane.swift index 5a5a546b7b..50723e99d0 100644 --- a/Sources/CodexBar/PreferencesSpendDashboardPane.swift +++ b/Sources/CodexBar/PreferencesSpendDashboardPane.swift @@ -418,18 +418,12 @@ private struct SpendCurrencySection: View { Text(self.group.currencyCode) .font(.headline) Spacer() - Text(self.group.totalCost.map { - UsageFormatter.currencyString($0, currencyCode: self.group.currencyCode) - } ?? L("Spend unavailable")) + Text(spendDashboardGroupCostText(self.group)) .font(.title3.weight(.semibold)) .monospacedDigit() } - Text( - "\(L("Local estimated history")) · " + - spendDashboardCoverageText( - covered: self.group.coveredDayCount, - requested: self.requestedDays)) + Text(spendDashboardHistoryCaption(self.group, requestedDays: self.requestedDays)) .font(.caption) .foregroundStyle(.secondary) @@ -437,9 +431,7 @@ private struct SpendCurrencySection: View { HStack(spacing: 24) { SpendSummaryValue( title: L("Estimated spend"), - value: self.group.totalCost.map { - UsageFormatter.currencyString($0, currencyCode: self.group.currencyCode) - } ?? "—") + value: self.group.totalCost == nil ? "—" : spendDashboardGroupCostText(self.group)) SpendSummaryValue( title: L("Tracked tokens"), value: self.group.totalTokens.map(UsageFormatter.tokenCountString) ?? "—") @@ -530,7 +522,7 @@ private struct SpendModelPanel: View { .padding(.vertical, 10) case .partial, .complete: if presentation == .partial { - Label(L("Model breakdown unavailable"), systemImage: "exclamationmark.triangle") + Label(L("Partial model breakdown"), systemImage: "exclamationmark.triangle") .font(.caption) .foregroundStyle(.secondary) .padding(.bottom, 6) @@ -772,3 +764,28 @@ private struct SpendDashboardPanel: View { } } } + +func spendDashboardGroupCostText(_ group: SpendDashboardModel.CurrencyGroup) -> String { + guard let cost = group.totalCost else { return L("Spend unavailable") } + let formatted = UsageFormatter.currencyString(cost, currencyCode: group.currencyCode) + return group.hasPartialCost ? "~\(formatted)" : formatted +} + +func spendDashboardPartialSubscriptionsText(_ group: SpendDashboardModel.CurrencyGroup) -> String { + L("%d of %d subscriptions have spend", group.pricedProviderCount, group.providers.count) +} + +func spendDashboardHistoryCaption( + _ group: SpendDashboardModel.CurrencyGroup, + requestedDays: Int) -> String +{ + var parts: [String] = [] + if group.hasPartialCost { + parts.append(L("Partial estimate")) + parts.append(spendDashboardPartialSubscriptionsText(group)) + } else { + parts.append(L("Local estimated history")) + } + parts.append(spendDashboardCoverageText(covered: group.coveredDayCount, requested: requestedDays)) + return parts.joined(separator: " · ") +} diff --git a/Sources/CodexBar/Resources/ar.lproj/Localizable.strings b/Sources/CodexBar/Resources/ar.lproj/Localizable.strings index 339fc419b6..39df0a39c3 100644 --- a/Sources/CodexBar/Resources/ar.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ar.lproj/Localizable.strings @@ -1320,6 +1320,9 @@ "Native currencies stay separate; Codex account rows exclude Pi session history." = "تبقى العملات الأصلية منفصلة؛ تستبعد صفوف حساب Codex سجل جلسات Pi."; "Spend unavailable" = "الإنفاق غير متاح"; "Model breakdown unavailable" = "تفصيل الإنفاق حسب النموذج غير متاح"; +"Partial estimate" = "تقدير جزئي"; +"Partial model breakdown" = "تفاصيل النماذج الجزئية"; +"%d of %d subscriptions have spend" = "%d من %d اشتراكات لديها إنفاق"; "Local estimated history" = "السجل التقديري المحلي"; "Coverage" = "التغطية"; "Estimated spend" = "الإنفاق التقديري"; diff --git a/Sources/CodexBar/Resources/ca.lproj/Localizable.strings b/Sources/CodexBar/Resources/ca.lproj/Localizable.strings index dddf9304de..ebc83442fd 100644 --- a/Sources/CodexBar/Resources/ca.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ca.lproj/Localizable.strings @@ -1319,6 +1319,9 @@ "Native currencies stay separate; Codex account rows exclude Pi session history." = "Les monedes originals es mantenen separades; les files de comptes de Codex exclouen l’historial de sessions de Pi."; "Spend unavailable" = "Despesa no disponible"; "Model breakdown unavailable" = "Desglossament per model no disponible"; +"Partial estimate" = "Estimació parcial"; +"Partial model breakdown" = "Desglossament de models parcial"; +"%d of %d subscriptions have spend" = "%d de %d subscripcions tenen despesa"; "Local estimated history" = "Historial local estimat"; "Coverage" = "Cobertura"; "Estimated spend" = "Despesa estimada"; diff --git a/Sources/CodexBar/Resources/de.lproj/Localizable.strings b/Sources/CodexBar/Resources/de.lproj/Localizable.strings index 0a35ca519e..071bc11baa 100644 --- a/Sources/CodexBar/Resources/de.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/de.lproj/Localizable.strings @@ -1317,6 +1317,9 @@ "Native currencies stay separate; Codex account rows exclude Pi session history." = "Originalwährungen bleiben getrennt; Codex-Kontozeilen schließen den Pi-Sitzungsverlauf aus."; "Spend unavailable" = "Ausgaben nicht verfügbar"; "Model breakdown unavailable" = "Modellaufschlüsselung nicht verfügbar"; +"Partial estimate" = "Teilschätzung"; +"Partial model breakdown" = "Unvollständige Modellaufschlüsselung"; +"%d of %d subscriptions have spend" = "%d von %d Abonnements haben Ausgaben"; "Local estimated history" = "Lokaler Schätzverlauf"; "Coverage" = "Abdeckung"; "Estimated spend" = "Geschätzte Ausgaben"; diff --git a/Sources/CodexBar/Resources/en.lproj/Localizable.strings b/Sources/CodexBar/Resources/en.lproj/Localizable.strings index 35461d956d..2514dbc25f 100644 --- a/Sources/CodexBar/Resources/en.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/en.lproj/Localizable.strings @@ -1300,6 +1300,9 @@ "Native currencies stay separate; Codex account rows exclude Pi session history." = "Native currencies stay separate; Codex account rows exclude Pi session history."; "Spend unavailable" = "Spend unavailable"; "Model breakdown unavailable" = "Model breakdown unavailable"; +"Partial estimate" = "Partial estimate"; +"Partial model breakdown" = "Partial model breakdown"; +"%d of %d subscriptions have spend" = "%d of %d subscriptions have spend"; "Local estimated history" = "Local estimated history"; "Coverage" = "Coverage"; "Estimated spend" = "Estimated spend"; diff --git a/Sources/CodexBar/Resources/es.lproj/Localizable.strings b/Sources/CodexBar/Resources/es.lproj/Localizable.strings index 427852bc03..fb7a095b57 100644 --- a/Sources/CodexBar/Resources/es.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/es.lproj/Localizable.strings @@ -1315,6 +1315,9 @@ "Native currencies stay separate; Codex account rows exclude Pi session history." = "Las divisas originales se mantienen separadas; las filas de cuentas de Codex excluyen el historial de sesiones de Pi."; "Spend unavailable" = "Gasto no disponible"; "Model breakdown unavailable" = "Desglose por modelo no disponible"; +"Partial estimate" = "Estimación parcial"; +"Partial model breakdown" = "Desglose de modelos parcial"; +"%d of %d subscriptions have spend" = "%d de %d suscripciones tienen gasto"; "Local estimated history" = "Historial local estimado"; "Coverage" = "Cobertura"; "Estimated spend" = "Gasto estimado"; diff --git a/Sources/CodexBar/Resources/fa.lproj/Localizable.strings b/Sources/CodexBar/Resources/fa.lproj/Localizable.strings index b4aefbc3a5..c91fe1096c 100644 --- a/Sources/CodexBar/Resources/fa.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/fa.lproj/Localizable.strings @@ -1320,6 +1320,9 @@ "Native currencies stay separate; Codex account rows exclude Pi session history." = "ارزهای اصلی جدا نگه داشته می‌شوند؛ ردیف‌های حساب Codex تاریخچه نشست‌های Pi را دربر نمی‌گیرند."; "Spend unavailable" = "هزینه در دسترس نیست"; "Model breakdown unavailable" = "تفکیک مدل در دسترس نیست"; +"Partial estimate" = "برآورد جزئی"; +"Partial model breakdown" = "جزئیات ناقص مدلها"; +"%d of %d subscriptions have spend" = "%d از %d اشتراک دارای هزینه هستند"; "Local estimated history" = "تاریخچه برآورد محلی"; "Coverage" = "پوشش"; "Estimated spend" = "برآورد هزینه"; diff --git a/Sources/CodexBar/Resources/fr.lproj/Localizable.strings b/Sources/CodexBar/Resources/fr.lproj/Localizable.strings index 7b4e74dac8..f9bfb81fe4 100644 --- a/Sources/CodexBar/Resources/fr.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/fr.lproj/Localizable.strings @@ -1316,6 +1316,9 @@ "Native currencies stay separate; Codex account rows exclude Pi session history." = "Les devises d’origine restent séparées ; les lignes de compte Codex excluent l’historique des sessions Pi."; "Spend unavailable" = "Dépenses indisponibles"; "Model breakdown unavailable" = "Répartition par modèle indisponible"; +"Partial estimate" = "Estimation partielle"; +"Partial model breakdown" = "Répartition des modèles partielle"; +"%d of %d subscriptions have spend" = "%d abonnements sur %d ont des dépenses"; "Local estimated history" = "Historique local estimé"; "Coverage" = "Couverture"; "Estimated spend" = "Dépenses estimées"; diff --git a/Sources/CodexBar/Resources/gl.lproj/Localizable.strings b/Sources/CodexBar/Resources/gl.lproj/Localizable.strings index 2e18a5bc75..aa4d70082a 100644 --- a/Sources/CodexBar/Resources/gl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/gl.lproj/Localizable.strings @@ -1316,6 +1316,9 @@ "Native currencies stay separate; Codex account rows exclude Pi session history." = "As moedas orixinais mantéñense separadas; as filas das contas de Codex exclúen o historial de sesións de Pi."; "Spend unavailable" = "Gasto non dispoñible"; "Model breakdown unavailable" = "Desglose por modelo non dispoñible"; +"Partial estimate" = "Estimación parcial"; +"Partial model breakdown" = "Desagregación de modelos parcial"; +"%d of %d subscriptions have spend" = "%d de %d subscricións teñen gasto"; "Local estimated history" = "Historial local estimado"; "Coverage" = "Cobertura"; "Estimated spend" = "Gasto estimado"; diff --git a/Sources/CodexBar/Resources/id.lproj/Localizable.strings b/Sources/CodexBar/Resources/id.lproj/Localizable.strings index 9524c9f47e..747a329847 100644 --- a/Sources/CodexBar/Resources/id.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/id.lproj/Localizable.strings @@ -1320,6 +1320,9 @@ "Native currencies stay separate; Codex account rows exclude Pi session history." = "Mata uang asli tetap dipisahkan; baris akun Codex tidak menyertakan riwayat sesi Pi."; "Spend unavailable" = "Data pengeluaran tidak tersedia"; "Model breakdown unavailable" = "Rincian per model tidak tersedia"; +"Partial estimate" = "Perkiraan sebagian"; +"Partial model breakdown" = "Rincian model sebagian"; +"%d of %d subscriptions have spend" = "%d dari %d langganan memiliki pengeluaran"; "Local estimated history" = "Riwayat perkiraan lokal"; "Coverage" = "Cakupan"; "Estimated spend" = "Perkiraan pengeluaran"; diff --git a/Sources/CodexBar/Resources/it.lproj/Localizable.strings b/Sources/CodexBar/Resources/it.lproj/Localizable.strings index 3e64c31e2f..a22059a630 100644 --- a/Sources/CodexBar/Resources/it.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/it.lproj/Localizable.strings @@ -1320,6 +1320,9 @@ "Native currencies stay separate; Codex account rows exclude Pi session history." = "Le valute originali rimangono separate; le righe degli account Codex escludono la cronologia delle sessioni Pi."; "Spend unavailable" = "Spesa non disponibile"; "Model breakdown unavailable" = "Ripartizione per modello non disponibile"; +"Partial estimate" = "Stima parziale"; +"Partial model breakdown" = "Dettaglio modelli parziale"; +"%d of %d subscriptions have spend" = "%d di %d abbonamenti hanno spese"; "Local estimated history" = "Cronologia locale stimata"; "Coverage" = "Copertura"; "Estimated spend" = "Spesa stimata"; diff --git a/Sources/CodexBar/Resources/ja.lproj/Localizable.strings b/Sources/CodexBar/Resources/ja.lproj/Localizable.strings index 7955995850..f5927a568a 100644 --- a/Sources/CodexBar/Resources/ja.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ja.lproj/Localizable.strings @@ -1317,6 +1317,9 @@ "Native currencies stay separate; Codex account rows exclude Pi session history." = "各通貨は別々に扱われ、Codex アカウント行には Pi セッション履歴を含めません。"; "Spend unavailable" = "支出を取得できません"; "Model breakdown unavailable" = "モデル別の内訳を取得できません"; +"Partial estimate" = "部分的な見積もり"; +"Partial model breakdown" = "部分的なモデル内訳"; +"%d of %d subscriptions have spend" = "%d / %d 件のサブスクリプションに支出データがあります"; "Local estimated history" = "ローカル推定履歴"; "Coverage" = "対象範囲"; "Estimated spend" = "推定支出"; diff --git a/Sources/CodexBar/Resources/ko.lproj/Localizable.strings b/Sources/CodexBar/Resources/ko.lproj/Localizable.strings index 65d736291b..0aa8b81736 100644 --- a/Sources/CodexBar/Resources/ko.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ko.lproj/Localizable.strings @@ -1284,6 +1284,9 @@ "Native currencies stay separate; Codex account rows exclude Pi session history." = "각 통화는 별도로 유지되며 Codex 계정 행에서는 Pi 세션 기록이 제외됩니다."; "Spend unavailable" = "지출 정보 없음"; "Model breakdown unavailable" = "모델별 내역을 사용할 수 없습니다"; +"Partial estimate" = "부분 추정치"; +"Partial model breakdown" = "부분 모델 내역"; +"%d of %d subscriptions have spend" = "%d / %d개 구독에 지출 데이터가 있습니다"; "Local estimated history" = "로컬 예상 내역"; "Coverage" = "포함 범위"; "Estimated spend" = "예상 지출"; diff --git a/Sources/CodexBar/Resources/nl.lproj/Localizable.strings b/Sources/CodexBar/Resources/nl.lproj/Localizable.strings index dc861df956..8153149387 100644 --- a/Sources/CodexBar/Resources/nl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/nl.lproj/Localizable.strings @@ -1316,6 +1316,9 @@ "Native currencies stay separate; Codex account rows exclude Pi session history." = "Oorspronkelijke valuta’s blijven gescheiden; rijen met Codex-accounts sluiten Pi-sessiegeschiedenis uit."; "Spend unavailable" = "Uitgaven niet beschikbaar"; "Model breakdown unavailable" = "Uitsplitsing per model niet beschikbaar"; +"Partial estimate" = "Gedeeltelijke schatting"; +"Partial model breakdown" = "Gedeeltelijke modeluitsplitsing"; +"%d of %d subscriptions have spend" = "%d van %d abonnementen hebben uitgaven"; "Local estimated history" = "Lokaal geschatte geschiedenis"; "Coverage" = "Dekking"; "Estimated spend" = "Geschatte uitgaven"; diff --git a/Sources/CodexBar/Resources/pl.lproj/Localizable.strings b/Sources/CodexBar/Resources/pl.lproj/Localizable.strings index 4841f7ee01..2fce14fea5 100644 --- a/Sources/CodexBar/Resources/pl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/pl.lproj/Localizable.strings @@ -1320,6 +1320,9 @@ "Native currencies stay separate; Codex account rows exclude Pi session history." = "Waluty źródłowe pozostają rozdzielone; wiersze kont Codex nie obejmują historii sesji Pi."; "Spend unavailable" = "Wydatki niedostępne"; "Model breakdown unavailable" = "Podział według modeli jest niedostępny"; +"Partial estimate" = "Szacunek częściowy"; +"Partial model breakdown" = "Częściowe zestawienie modeli"; +"%d of %d subscriptions have spend" = "%d z %d subskrypcji ma wydatki"; "Local estimated history" = "Lokalna historia szacunkowa"; "Coverage" = "Pokrycie"; "Estimated spend" = "Szacowane wydatki"; diff --git a/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings b/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings index 55101fd069..568c176f7e 100644 --- a/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings @@ -1317,6 +1317,9 @@ "Native currencies stay separate; Codex account rows exclude Pi session history." = "As moedas originais permanecem separadas; as linhas de contas do Codex excluem o histórico de sessões do Pi."; "Spend unavailable" = "Gastos indisponíveis"; "Model breakdown unavailable" = "Detalhamento por modelo indisponível"; +"Partial estimate" = "Estimativa parcial"; +"Partial model breakdown" = "Detalhamento parcial de modelos"; +"%d of %d subscriptions have spend" = "%d de %d assinaturas têm gastos"; "Local estimated history" = "Histórico local estimado"; "Coverage" = "Cobertura"; "Estimated spend" = "Gastos estimados"; diff --git a/Sources/CodexBar/Resources/ru.lproj/Localizable.strings b/Sources/CodexBar/Resources/ru.lproj/Localizable.strings index b26a39b7b5..79df5b3714 100644 --- a/Sources/CodexBar/Resources/ru.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ru.lproj/Localizable.strings @@ -1318,6 +1318,9 @@ "Native currencies stay separate; Codex account rows exclude Pi session history." = "Исходные валюты остаются раздельными; строки учётных записей Codex не включают историю сеансов Pi."; "Spend unavailable" = "Расходы недоступны"; "Model breakdown unavailable" = "Разбивка по моделям недоступна"; +"Partial estimate" = "Частичная оценка"; +"Partial model breakdown" = "Частичная разбивка по моделям"; +"%d of %d subscriptions have spend" = "У %d из %d подписок есть данные о расходах"; "Local estimated history" = "Локальная история оценок"; "Coverage" = "Охват"; "Estimated spend" = "Предполагаемые расходы"; diff --git a/Sources/CodexBar/Resources/sv.lproj/Localizable.strings b/Sources/CodexBar/Resources/sv.lproj/Localizable.strings index bbb288a4ec..3f8f7ca525 100644 --- a/Sources/CodexBar/Resources/sv.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/sv.lproj/Localizable.strings @@ -1315,6 +1315,9 @@ "Native currencies stay separate; Codex account rows exclude Pi session history." = "Ursprungliga valutor hålls åtskilda; rader för Codex-konton utesluter Pi-sessionshistorik."; "Spend unavailable" = "Utgifter ej tillgängliga"; "Model breakdown unavailable" = "Modellfördelning ej tillgänglig"; +"Partial estimate" = "Delvis uppskattning"; +"Partial model breakdown" = "Delvis modellfördelning"; +"%d of %d subscriptions have spend" = "%d av %d prenumerationer har utgifter"; "Local estimated history" = "Lokal uppskattad historik"; "Coverage" = "Täckning"; "Estimated spend" = "Uppskattade utgifter"; diff --git a/Sources/CodexBar/Resources/th.lproj/Localizable.strings b/Sources/CodexBar/Resources/th.lproj/Localizable.strings index 1ee9086b11..79d23ee6ad 100644 --- a/Sources/CodexBar/Resources/th.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/th.lproj/Localizable.strings @@ -1320,6 +1320,9 @@ "Native currencies stay separate; Codex account rows exclude Pi session history." = "สกุลเงินต้นทางแยกจากกัน แถวบัญชี Codex ไม่รวมประวัติเซสชัน Pi"; "Spend unavailable" = "ไม่มีข้อมูลค่าใช้จ่าย"; "Model breakdown unavailable" = "ไม่มีรายละเอียดแยกตามโมเดล"; +"Partial estimate" = "ประมาณการบางส่วน"; +"Partial model breakdown" = "รายละเอียดโมเดลบางส่วน"; +"%d of %d subscriptions have spend" = "%d จาก %d การสมัครมีข้อมูลการใช้จ่าย"; "Local estimated history" = "ประวัติโดยประมาณในเครื่อง"; "Coverage" = "ความครอบคลุม"; "Estimated spend" = "ค่าใช้จ่ายโดยประมาณ"; diff --git a/Sources/CodexBar/Resources/tr.lproj/Localizable.strings b/Sources/CodexBar/Resources/tr.lproj/Localizable.strings index c26a72bf96..23fbd135fe 100644 --- a/Sources/CodexBar/Resources/tr.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/tr.lproj/Localizable.strings @@ -1318,6 +1318,9 @@ "Native currencies stay separate; Codex account rows exclude Pi session history." = "Kaynak para birimleri ayrı tutulur; Codex hesap satırlarına Pi oturum geçmişi dahil edilmez."; "Spend unavailable" = "Harcama verisi kullanılamıyor"; "Model breakdown unavailable" = "Model dökümü kullanılamıyor"; +"Partial estimate" = "Kısmi tahmin"; +"Partial model breakdown" = "Kısmi model dökümü"; +"%d of %d subscriptions have spend" = "%d / %d abonelikte harcama var"; "Local estimated history" = "Yerel tahmini geçmiş"; "Coverage" = "Kapsam"; "Estimated spend" = "Tahmini harcama"; diff --git a/Sources/CodexBar/Resources/uk.lproj/Localizable.strings b/Sources/CodexBar/Resources/uk.lproj/Localizable.strings index c41779dcd7..fe9fca5782 100644 --- a/Sources/CodexBar/Resources/uk.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/uk.lproj/Localizable.strings @@ -1316,6 +1316,9 @@ "Native currencies stay separate; Codex account rows exclude Pi session history." = "Вихідні валюти залишаються розділеними; рядки облікових записів Codex не включають історію сеансів Pi."; "Spend unavailable" = "Витрати недоступні"; "Model breakdown unavailable" = "Розподіл за моделями недоступний"; +"Partial estimate" = "Часткова оцінка"; +"Partial model breakdown" = "Часткова розбивка за моделями"; +"%d of %d subscriptions have spend" = "У %d з %d підписок є дані про витрати"; "Local estimated history" = "Локальна історія оцінок"; "Coverage" = "Охоплення"; "Estimated spend" = "Орієнтовні витрати"; diff --git a/Sources/CodexBar/Resources/vi.lproj/Localizable.strings b/Sources/CodexBar/Resources/vi.lproj/Localizable.strings index c0c19bd2af..aea7b56c5a 100644 --- a/Sources/CodexBar/Resources/vi.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/vi.lproj/Localizable.strings @@ -1317,6 +1317,9 @@ "Native currencies stay separate; Codex account rows exclude Pi session history." = "Đơn vị tiền tệ gốc được giữ riêng biệt; các hàng tài khoản Codex không bao gồm lịch sử phiên Pi."; "Spend unavailable" = "Không có dữ liệu chi tiêu"; "Model breakdown unavailable" = "Phân tích theo mô hình không khả dụng"; +"Partial estimate" = "Ước tính một phần"; +"Partial model breakdown" = "Phân tách mô hình một phần"; +"%d of %d subscriptions have spend" = "%d / %d gói đăng ký có chi tiêu"; "Local estimated history" = "Lịch sử ước tính cục bộ"; "Coverage" = "Phạm vi"; "Estimated spend" = "Chi tiêu ước tính"; diff --git a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings index 7bd52093d5..5255aa2c67 100644 --- a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings @@ -1295,6 +1295,9 @@ "Native currencies stay separate; Codex account rows exclude Pi session history." = "各原始币种保持分开;Codex 帐户行不包含 Pi 会话历史。"; "Spend unavailable" = "支出数据不可用"; "Model breakdown unavailable" = "模型明细不可用"; +"Partial estimate" = "部分估算"; +"Partial model breakdown" = "部分模型明细"; +"%d of %d subscriptions have spend" = "%d / %d 个订阅有支出数据"; "Local estimated history" = "本地估算历史"; "Coverage" = "覆盖范围"; "Estimated spend" = "估算支出"; diff --git a/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings b/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings index 678d88aca6..7cf56026eb 100644 --- a/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings @@ -1347,6 +1347,9 @@ "Native currencies stay separate; Codex account rows exclude Pi session history." = "各原始幣別保持分開;Codex 帳號列不包含 Pi 工作階段歷史。"; "Spend unavailable" = "無法取得支出資料"; "Model breakdown unavailable" = "無法取得模型明細"; +"Partial estimate" = "部分估算"; +"Partial model breakdown" = "部分模型明細"; +"%d of %d subscriptions have spend" = "%d / %d 個訂閱有支出資料"; "Local estimated history" = "本機預估歷史"; "Coverage" = "涵蓋範圍"; "Estimated spend" = "預估支出"; diff --git a/Sources/CodexBar/ShareStatsPayload.swift b/Sources/CodexBar/ShareStatsPayload.swift index 8a8e2945e9..5eae3b3fb2 100644 --- a/Sources/CodexBar/ShareStatsPayload.swift +++ b/Sources/CodexBar/ShareStatsPayload.swift @@ -85,6 +85,19 @@ struct ShareStatsCurrencyPayload: Sendable, Equatable, Identifiable { let currencyCode: String let estimatedCost: Double? let coveredDayCount: Int + let isPartial: Bool + + init( + currencyCode: String, + estimatedCost: Double?, + coveredDayCount: Int, + isPartial: Bool = false) + { + self.currencyCode = currencyCode + self.estimatedCost = estimatedCost + self.coveredDayCount = coveredDayCount + self.isPartial = isPartial + } var id: String { self.currencyCode @@ -282,7 +295,8 @@ enum ShareStatsBuilder { ShareStatsCurrencyPayload( currencyCode: $0.currencyCode, estimatedCost: self.finiteCost($0.totalCost), - coveredDayCount: $0.coveredDayCount) + coveredDayCount: $0.coveredDayCount, + isPartial: $0.hasPartialCost) } let totalTokens = self.combinedTotalTokens(model.groups.map(\.totalTokens)) let periodEnd = model.groups.map(\.chartDomain.upperBound).max() ?? Date() @@ -302,9 +316,10 @@ enum ShareStatsBuilder { } static func combinedTotalTokens(_ values: [Int?]) -> Int? { + let known = values.compactMap(\.self) + guard !known.isEmpty else { return nil } var total = 0 - for value in values { - guard let value else { return nil } + for value in known { let result = total.addingReportingOverflow(value) guard !result.overflow else { return nil } total = result.partialValue @@ -348,8 +363,10 @@ enum ShareStatsFormatting { lines.append("\(self.compactCount(tokens)) tracked tokens") } lines.append(contentsOf: payload.currencies.map { currency in - let spend = currency.estimatedCost.map { "\(self.currency($0, code: currency.currencyCode)) estimated" } - ?? "Spend unavailable" + let spend = currency.estimatedCost.map { value in + let amount = "\(self.currency(value, code: currency.currencyCode)) estimated" + return currency.isPartial ? "\(amount) (partial)" : amount + } ?? "Spend unavailable" return "\(currency.currencyCode): \(spend) · " + "coverage \(currency.coveredDayCount)/\(payload.days) days" }) diff --git a/Sources/CodexBar/SpendDashboardModel.swift b/Sources/CodexBar/SpendDashboardModel.swift index a05bcbe216..0a2c1ad279 100644 --- a/Sources/CodexBar/SpendDashboardModel.swift +++ b/Sources/CodexBar/SpendDashboardModel.swift @@ -278,8 +278,8 @@ struct SpendDashboardModel: Equatable, Sendable { models: modelSummary.rows, projects: Self.projectRows(summaries: summaries, bounds: bounds, calendar: calendar), dailyPoints: dailyPoints, - totalTokens: Self.completeIntSum(providers.map(\.totalTokens)), - totalCost: Self.completeCostSum(providers.map(\.totalCost)), + totalTokens: Self.knownIntSum(providers.map(\.totalTokens)), + totalCost: Self.knownCostSum(providers.map(\.totalCost)), coveredDayCount: Self.commonCoverageDayCount(summaries: summaries, calendar: calendar), chartDomain: Self.chartDomain(bounds: bounds, calendar: calendar), modelHistoryCompleteness: modelHistoryCompleteness) @@ -841,6 +841,10 @@ struct SpendDashboardModel: Equatable, Sendable { return self.safeCostSum(values.compactMap(\.self)) } + private static func knownCostSum(_ values: [Double?]) -> Double? { + self.safeCostSum(values.compactMap(\.self)) + } + private static func safeIntSum(_ values: [Int]) -> Int? { guard !values.isEmpty else { return nil } var result = 0 @@ -857,6 +861,10 @@ struct SpendDashboardModel: Equatable, Sendable { return self.safeIntSum(values.compactMap(\.self)) } + private static func knownIntSum(_ values: [Int?]) -> Int? { + self.safeIntSum(values.compactMap(\.self)) + } + static func add(_ value: Int, to current: Int?, overflowed: inout Bool) -> Int? { guard !overflowed, let current else { return nil } let addition = current.addingReportingOverflow(value) @@ -877,3 +885,19 @@ struct SpendDashboardModel: Equatable, Sendable { return result } } + +extension SpendDashboardModel.CurrencyGroup { + var pricedProviderCount: Int { + self.providers.count { $0.totalCost != nil } + } + + var hasPartialCost: Bool { + let values = self.providers.map(\.totalCost) + return values.contains { $0 != nil } && values.contains { $0 == nil } + } + + var hasPartialTokens: Bool { + let values = self.providers.map(\.totalTokens) + return values.contains { $0 != nil } && values.contains { $0 == nil } + } +} diff --git a/Tests/CodexBarTests/LocalizationLanguageCatalogTests.swift b/Tests/CodexBarTests/LocalizationLanguageCatalogTests.swift index 992eb6c58f..01cde4fed1 100644 --- a/Tests/CodexBarTests/LocalizationLanguageCatalogTests.swift +++ b/Tests/CodexBarTests/LocalizationLanguageCatalogTests.swift @@ -312,6 +312,39 @@ struct LocalizationLanguageCatalogTests { } } + @Test + func `partial spend copy exists in every app catalog`() throws { + let root = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + let resourcesURL = root.appendingPathComponent("Sources/CodexBar/Resources") + let catalogs = try FileManager.default.contentsOfDirectory( + at: resourcesURL, + includingPropertiesForKeys: nil) + .filter { $0.pathExtension == "lproj" } + + #expect(catalogs.count == 23) + for catalogURL in catalogs { + let stringsURL = catalogURL.appendingPathComponent("Localizable.strings") + let catalog = try #require(NSDictionary(contentsOf: stringsURL) as? [String: String]) + let estimate = try #require(catalog["Partial estimate"]) + let breakdown = try #require(catalog["Partial model breakdown"]) + let subscriptions = try #require(catalog["%d of %d subscriptions have spend"]) + #expect(!estimate.isEmpty, "\(catalogURL.lastPathComponent)") + #expect(!breakdown.isEmpty, "\(catalogURL.lastPathComponent)") + #expect(!estimate.contains("%"), "\(catalogURL.lastPathComponent)") + #expect(!breakdown.contains("%"), "\(catalogURL.lastPathComponent)") + #expect(subscriptions.contains("%d"), "\(catalogURL.lastPathComponent)") + if catalogURL.lastPathComponent == "en.lproj" { + #expect(estimate == "Partial estimate") + #expect(breakdown == "Partial model breakdown") + #expect(subscriptions == "%d of %d subscriptions have spend") + #expect(String(format: subscriptions, 1, 3) == "1 of 3 subscriptions have spend") + } + } + } + @Test func `catalan localization matches the English catalog`() throws { let root = URL(fileURLWithPath: #filePath) diff --git a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift index fd1ccef3fd..3f7aacddfd 100644 --- a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift +++ b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift @@ -2217,7 +2217,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact shared provider integration dispatches a capability owned by the provider descriptor or adapter."), AllowedProviderConstruct( path: "Sources/CodexBar/ShareStatsPayload.swift", - line: 163, + line: 176, anchor: "([\"codestral-\", \"devstral-\", \"magistral-\", \"mistral-\", \"mistral \", \"mistral.\", \"mixtral-\"], \"Mistral\"),", expectedProviderIDs: ["mistral"], expectedReferenceCount: 1, diff --git a/Tests/CodexBarTests/ShareStatsTests.swift b/Tests/CodexBarTests/ShareStatsTests.swift index c06e890bf3..cdd6f02551 100644 --- a/Tests/CodexBarTests/ShareStatsTests.swift +++ b/Tests/CodexBarTests/ShareStatsTests.swift @@ -20,7 +20,7 @@ struct ShareStatsTests { } @Test - func `builder preserves native currencies and unavailable spend`() throws { + func `builder preserves native currencies and partial group spend`() throws { let subscriptionNames = try [ "codex:one": #require(Self.subscriptionName(provider: .codex, rawName: "pro")), "cursor": #require(Self.subscriptionName(provider: .cursor, rawName: "Cursor Pro")), @@ -31,10 +31,14 @@ struct ShareStatsTests { subscriptionNames: subscriptionNames)) #expect(payload.days == 30) - #expect(payload.totalTokens == nil) + #expect(payload.totalTokens == 500) #expect(payload.currencies == [ ShareStatsCurrencyPayload(currencyCode: "GBP", estimatedCost: 12, coveredDayCount: 10), - ShareStatsCurrencyPayload(currencyCode: "USD", estimatedCost: nil, coveredDayCount: 0), + ShareStatsCurrencyPayload( + currencyCode: "USD", + estimatedCost: 4, + coveredDayCount: 0, + isPartial: true), ]) #expect(payload.providers.map(\.providerName) == ["Claude", "Codex · #1", "Cursor"]) #expect(payload.providers.map(\.subscriptionName) == ["Max", "Pro 20x", "Cursor Pro"]) @@ -44,7 +48,7 @@ struct ShareStatsTests { let text = ShareStatsFormatting.text(payload) #expect(text.contains("GBP: £12.00 estimated · coverage 10/30 days")) #expect(text.contains("Claude · Max: 300 tokens · ~£12.00 est · 10/30 days")) - #expect(text.contains("USD: Spend unavailable · coverage 0/30 days")) + #expect(text.contains("USD: $4.00 estimated (partial) · coverage 0/30 days")) #expect(text.contains("Cursor · Cursor Pro: Spend unavailable")) #expect(!text.contains("£12.00 +")) } @@ -323,9 +327,10 @@ struct ShareStatsTests { } @Test - func `overall token total becomes unavailable on overflow`() { + func `overall token total skips unavailable groups and fails closed on overflow`() { #expect(ShareStatsBuilder.combinedTotalTokens([Int.max, 1]) == nil) - #expect(ShareStatsBuilder.combinedTotalTokens([10, nil]) == nil) + #expect(ShareStatsBuilder.combinedTotalTokens([10, nil]) == 10) + #expect(ShareStatsBuilder.combinedTotalTokens([nil, nil]) == nil) #expect(ShareStatsBuilder.combinedTotalTokens([10, 20]) == 30) } @@ -429,8 +434,8 @@ struct ShareStatsTests { }, projects: [], dailyPoints: [], - totalTokens: nil, - totalCost: nil, + totalTokens: 200, + totalCost: 4, coveredDayCount: 0, chartDomain: self.date...self.date, modelHistoryCompleteness: .complete), diff --git a/Tests/CodexBarTests/SpendDashboardDateTruthTests.swift b/Tests/CodexBarTests/SpendDashboardDateTruthTests.swift index ea29bd0f58..83f8141ef2 100644 --- a/Tests/CodexBarTests/SpendDashboardDateTruthTests.swift +++ b/Tests/CodexBarTests/SpendDashboardDateTruthTests.swift @@ -333,7 +333,8 @@ struct SpendDashboardDateTruthTests { let usd = try #require(groups.first(where: { $0.currencyCode == "USD" })) #expect(usd.totalCost == 7) - #expect(usd.totalTokens == nil) + #expect(usd.totalTokens == 10) + #expect(usd.hasPartialTokens) #expect(usd.modelHistoryCompleteness == .complete) #expect(usd.models.map(\.totalCost) == [4, 3]) #expect(usd.models.first(where: { $0.provider == .claude })?.totalTokens == nil) @@ -343,7 +344,8 @@ struct SpendDashboardDateTruthTests { dailyPoints: usd.dailyPoints, aggregateTotal: usd.totalCost).content == .chart) - #expect(cad.totalCost == nil) + #expect(cad.totalCost == 5) + #expect(cad.hasPartialCost) #expect(cad.totalTokens == 30) #expect(cad.modelHistoryCompleteness == .incomplete) #expect(cad.models.map(\.provider) == [.mistral]) @@ -419,7 +421,8 @@ struct SpendDashboardDateTruthTests { calendar: Self.calendar).groups.first) #expect(group.totalCost == 20) - #expect(group.totalTokens == nil) + #expect(group.totalTokens == 62) + #expect(group.hasPartialTokens) #expect(group.modelHistoryCompleteness == .complete) #expect(group.models.map(\.modelName) == ["overflow", "mismatch", "negative", "valid"]) #expect(group.models.map(\.totalCost) == [7, 6, 5, 2]) @@ -449,8 +452,10 @@ struct SpendDashboardDateTruthTests { #expect(usd.providers.first(where: { $0.id == "malformed" })?.totalCost == nil) #expect(usd.providers.first(where: { $0.id == "malformed" })?.totalTokens == nil) - #expect(usd.totalCost == nil) - #expect(usd.totalTokens == nil) + #expect(usd.totalCost == 4) + #expect(usd.totalTokens == 10) + #expect(usd.hasPartialCost) + #expect(usd.hasPartialTokens) #expect(usd.modelHistoryCompleteness == .incomplete) #expect(usd.models.map(\.provider) == [.codex]) #expect(usd.models.map(\.totalCost) == [4]) @@ -625,7 +630,8 @@ struct SpendDashboardDateTruthTests { let eur = try #require(groups.first(where: { $0.currencyCode == "EUR" })) let usd = try #require(groups.first(where: { $0.currencyCode == "USD" })) - #expect(usd.totalCost == nil) + #expect(usd.totalCost == 4) + #expect(usd.hasPartialCost) #expect(usd.modelHistoryCompleteness == .incomplete) #expect(usd.models.map(\.provider) == [.codex]) #expect(usd.models.map(\.totalCost) == [4]) @@ -733,7 +739,8 @@ struct SpendDashboardDateTruthTests { let usd = try #require(groups.first(where: { $0.currencyCode == "USD" })) #expect(usd.totalCost == 0) - #expect(usd.totalTokens == nil) + #expect(usd.totalTokens == 0) + #expect(usd.hasPartialTokens) #expect(usd.modelHistoryCompleteness == .complete) #expect(eur.totalCost == nil) #expect(eur.totalTokens == 0) diff --git a/Tests/CodexBarTests/SpendDashboardModelTests.swift b/Tests/CodexBarTests/SpendDashboardModelTests.swift index f205a09d7f..f848caf600 100644 --- a/Tests/CodexBarTests/SpendDashboardModelTests.swift +++ b/Tests/CodexBarTests/SpendDashboardModelTests.swift @@ -260,8 +260,11 @@ struct SpendDashboardModelTests { now: Self.now, calendar: Self.calendar).groups.first) - #expect(group.totalCost == nil) - #expect(group.totalTokens == nil) + #expect(group.totalCost == 4) + #expect(group.totalTokens == 10) + #expect(group.hasPartialCost) + #expect(group.hasPartialTokens) + #expect(group.pricedProviderCount == 1) #expect(group.modelHistoryCompleteness == .incomplete) #expect(group.models.map(\.provider) == [.claude]) #expect(group.models.map(\.modelName) == ["test-model"]) @@ -395,7 +398,8 @@ struct SpendDashboardModelTests { #expect(group.providers.first(where: { $0.id == "invalid" })?.totalCost == nil) #expect(group.totalCost == nil) - #expect(group.totalTokens == nil) + #expect(group.totalTokens == 20) + #expect(group.hasPartialTokens) #expect(group.dailyPoints.isEmpty) } @@ -513,7 +517,8 @@ struct SpendDashboardModelTests { #expect(group.providers.first(where: { $0.id == "nonfinite" })?.totalTokens == 2) #expect(group.providers.filter { $0.id != "nonfinite" }.allSatisfy { $0.totalTokens == nil }) #expect(group.totalCost == nil) - #expect(group.totalTokens == nil) + #expect(group.totalTokens == 2) + #expect(group.hasPartialTokens) } @Test diff --git a/Tests/CodexBarTests/SpendDashboardPartialCostTests.swift b/Tests/CodexBarTests/SpendDashboardPartialCostTests.swift index 30448ea6af..495368148c 100644 --- a/Tests/CodexBarTests/SpendDashboardPartialCostTests.swift +++ b/Tests/CodexBarTests/SpendDashboardPartialCostTests.swift @@ -105,9 +105,65 @@ struct SpendDashboardPartialCostTests { #expect(group.dailyPoints.map(\.cost) == [3, 4]) } + @Test + func `priced subscription keeps group spend when peers lack prices`() throws { + let priced = Self.snapshot( + entries: [Self.entry(day: "2026-07-15", cost: 4, tokens: 40, model: "gpt-5.4-mini")], + last30DaysTokens: 40, + last30DaysCostUSD: 4) + let unpriced = Self.snapshot( + entries: [Self.entry(day: "2026-07-15", cost: nil, tokens: 100, model: "deepseek-v4-flash")], + last30DaysTokens: 100, + last30DaysCostUSD: nil) + let group = try Self.group(inputs: [ + .init(provider: .codex, displayName: "Codex", snapshot: priced), + .init(provider: .claude, displayName: "Claude", snapshot: unpriced), + .init(provider: .cursor, displayName: "Cursor", snapshot: unpriced), + ]) + + #expect(group.totalCost == 4) + #expect(group.totalTokens == 240) + #expect(group.hasPartialCost) + #expect(!group.hasPartialTokens) + #expect(group.pricedProviderCount == 1) + #expect(group.providers.count == 3) + #expect(group.providers.map(\SpendDashboardModel.ProviderRow.totalCost) == [4, nil, nil]) + CodexBarLocalizationOverride.$appLanguage.withValue("en") { + #expect(spendDashboardGroupCostText(group).hasPrefix("~")) + #expect(spendDashboardPartialSubscriptionsText(group) == "1 of 3 subscriptions have spend") + #expect(spendDashboardHistoryCaption(group, requestedDays: 30).contains("Partial estimate")) + } + } + + @Test + func `all unpriced subscriptions keep group spend unavailable`() throws { + let unpriced = Self.snapshot( + entries: [Self.entry(day: "2026-07-15", cost: nil, tokens: 100, model: "deepseek-v4-flash")], + last30DaysTokens: 100, + last30DaysCostUSD: nil) + let group = try Self.group(inputs: [ + .init(provider: .claude, displayName: "Claude", snapshot: unpriced), + .init(provider: .cursor, displayName: "Cursor", snapshot: unpriced), + ]) + + #expect(group.totalCost == nil) + #expect(group.totalTokens == 200) + #expect(!group.hasPartialCost) + #expect(!group.hasPartialTokens) + CodexBarLocalizationOverride.$appLanguage.withValue("en") { + #expect(spendDashboardGroupCostText(group) == "Spend unavailable") + } + } + private static func group(_ snapshot: CostUsageTokenSnapshot) throws -> SpendDashboardModel.CurrencyGroup { + try self.group(inputs: [.init(provider: .codex, displayName: "Codex", snapshot: snapshot)]) + } + + private static func group( + inputs: [SpendDashboardModel.ProviderInput]) throws -> SpendDashboardModel.CurrencyGroup + { try #require(SpendDashboardModel.build( - inputs: [.init(provider: .codex, displayName: "Codex", snapshot: snapshot)], + inputs: inputs, requestedDays: 30, now: self.now, calendar: self.calendar).groups.first) diff --git a/Tests/CodexBarTests/UserFacingLocalizationCoverageTests.swift b/Tests/CodexBarTests/UserFacingLocalizationCoverageTests.swift index e4c9e3e37f..3d97302c11 100644 --- a/Tests/CodexBarTests/UserFacingLocalizationCoverageTests.swift +++ b/Tests/CodexBarTests/UserFacingLocalizationCoverageTests.swift @@ -63,6 +63,8 @@ struct UserFacingLocalizationCoverageTests { ], "Sources/CodexBar/PreferencesSpendDashboardPane.swift": [ "Text(\"Model breakdown unavailable\")", + "Text(\"Partial model breakdown\")", + "Text(\"Partial estimate\")", ], "Sources/CodexBar/PreferencesProviderSettingsRows.swift": [ "Text(self.title)", @@ -126,6 +128,7 @@ struct UserFacingLocalizationCoverageTests { encoding: .utf8) #expect(source.contains(#"Text(L("Model breakdown unavailable"))"#)) + #expect(source.contains(#"L("Partial model breakdown")"#)) #expect(source.contains(#"Text(L("No model-level history"))"#)) }