From fa2b1bd8ca84ab8ef06b1c0a31496da4c73046f9 Mon Sep 17 00:00:00 2001 From: Trim Date: Mon, 20 Jul 2026 08:34:41 -0500 Subject: [PATCH] Show fractional session quota estimates --- .../CodexBar/MenuCardHeightFingerprint.swift | 8 +-- Sources/CodexBar/MenuCardView.swift | 23 ++++--- .../Resources/ar.lproj/Localizable.strings | 3 + .../Resources/ca.lproj/Localizable.strings | 3 + .../Resources/de.lproj/Localizable.strings | 3 + .../Resources/en.lproj/Localizable.strings | 5 +- .../Resources/es.lproj/Localizable.strings | 3 + .../Resources/fa.lproj/Localizable.strings | 3 + .../Resources/fr.lproj/Localizable.strings | 3 + .../Resources/gl.lproj/Localizable.strings | 3 + .../Resources/id.lproj/Localizable.strings | 3 + .../Resources/it.lproj/Localizable.strings | 3 + .../Resources/ja.lproj/Localizable.strings | 3 + .../Resources/ko.lproj/Localizable.strings | 3 + .../Resources/nl.lproj/Localizable.strings | 3 + .../Resources/pl.lproj/Localizable.strings | 3 + .../Resources/pt-BR.lproj/Localizable.strings | 3 + .../Resources/ru.lproj/Localizable.strings | 3 + .../Resources/sv.lproj/Localizable.strings | 3 + .../Resources/th.lproj/Localizable.strings | 3 + .../Resources/tr.lproj/Localizable.strings | 3 + .../Resources/uk.lproj/Localizable.strings | 3 + .../Resources/vi.lproj/Localizable.strings | 3 + .../zh-Hans.lproj/Localizable.strings | 3 + .../zh-Hant.lproj/Localizable.strings | 3 + Sources/CodexBar/UsagePaceText.swift | 63 ++++++++--------- .../LocalizationLanguageCatalogTests.swift | 1 + .../MenuCardOverrideIsolationTests.swift | 6 +- .../SessionEquivalentForecastTests.swift | 67 +++++++++++-------- Tests/CodexBarTests/UsagePaceTextTests.swift | 27 ++++++++ 30 files changed, 190 insertions(+), 76 deletions(-) diff --git a/Sources/CodexBar/MenuCardHeightFingerprint.swift b/Sources/CodexBar/MenuCardHeightFingerprint.swift index efdc81fe36..f2d938887a 100644 --- a/Sources/CodexBar/MenuCardHeightFingerprint.swift +++ b/Sources/CodexBar/MenuCardHeightFingerprint.swift @@ -87,11 +87,11 @@ extension UsageMenuCardView.Model.Metric { MenuCardHeightFingerprint.field("detailLeft", self.detailLeftText), MenuCardHeightFingerprint.field("detailRight", self.detailRightText), MenuCardHeightFingerprint.field( - "sessionEquivalentVerdict", - self.sessionEquivalentDetail?.verdictText), + "sessionEquivalentLeft", + self.sessionEquivalentDetail?.leftText), MenuCardHeightFingerprint.field( - "sessionEquivalentNumber", - self.sessionEquivalentDetail?.numberText), + "sessionEquivalentRight", + self.sessionEquivalentDetail?.rightText), self.pacePercent == nil ? "pace=0" : "pace=1", self.paceOnTop ? "paceTop=1" : "paceTop=0", self.cardStyle ? "card=1" : "card=0", diff --git a/Sources/CodexBar/MenuCardView.swift b/Sources/CodexBar/MenuCardView.swift index f0b01e1cef..e877e67104 100644 --- a/Sources/CodexBar/MenuCardView.swift +++ b/Sources/CodexBar/MenuCardView.swift @@ -552,16 +552,19 @@ private struct MetricRow: View { } } if let sessionEquivalentDetail = self.metric.sessionEquivalentDetail { - Text(sessionEquivalentDetail.verdictText) - .font(.footnote) - .foregroundStyle(MenuHighlightStyle.primary(self.isHighlighted)) - .lineLimit(1) - .accessibilityLabel(sessionEquivalentDetail.verdictAccessibilityLabel) - Text(sessionEquivalentDetail.numberText) - .font(.footnote) - .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) - .lineLimit(1) - .accessibilityLabel(sessionEquivalentDetail.numberAccessibilityLabel) + HStack(alignment: .firstTextBaseline) { + Text(sessionEquivalentDetail.leftText) + .font(.footnote) + .foregroundStyle(MenuHighlightStyle.primary(self.isHighlighted)) + .lineLimit(1) + Spacer() + Text(sessionEquivalentDetail.rightText) + .font(.footnote) + .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) + .lineLimit(1) + } + .accessibilityElement(children: .ignore) + .accessibilityLabel(sessionEquivalentDetail.accessibilityLabel) } } .frame(maxWidth: .infinity, alignment: .leading) diff --git a/Sources/CodexBar/Resources/ar.lproj/Localizable.strings b/Sources/CodexBar/Resources/ar.lproj/Localizable.strings index be1b7a75dc..ee408b8d92 100644 --- a/Sources/CodexBar/Resources/ar.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ar.lproj/Localizable.strings @@ -1276,6 +1276,9 @@ "Weekly cannot run out before reset at this pace" = "لا يمكن أن ينفد الحد الأسبوعي قبل إعادة التعيين بهذه الوتيرة"; "Weekly can run out ≈%d windows early" = "قد ينفد الحد الأسبوعي مبكرًا بنحو %d نافذة"; "Estimated: %@" = "تقديري: %@"; +"session_quota_estimate_value_format" = "%1$@ %2$@"; +"session quota" = "حصة الجلسة"; +"session quotas" = "حصص الجلسات"; "Coding Plan" = "خطة البرمجة"; "Agent Plan" = "خطة الوكيل"; "Team" = "فريق"; diff --git a/Sources/CodexBar/Resources/ca.lproj/Localizable.strings b/Sources/CodexBar/Resources/ca.lproj/Localizable.strings index 55985b0af6..9fa0e846a9 100644 --- a/Sources/CodexBar/Resources/ca.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ca.lproj/Localizable.strings @@ -1275,6 +1275,9 @@ "Weekly cannot run out before reset at this pace" = "La quota setmanal no es pot esgotar abans del reinici a aquest ritme"; "Weekly can run out ≈%d windows early" = "La quota setmanal es pot esgotar ≈%d finestres abans"; "Estimated: %@" = "Estimació: %@"; +"session_quota_estimate_value_format" = "%1$@ %2$@"; +"session quota" = "quota de sessió"; +"session quotas" = "quotes de sessió"; "Coding Plan" = "Pla de programació"; "Agent Plan" = "Pla d'agent"; "Team" = "Equip"; diff --git a/Sources/CodexBar/Resources/de.lproj/Localizable.strings b/Sources/CodexBar/Resources/de.lproj/Localizable.strings index 37d2b62165..2efbc1eb82 100644 --- a/Sources/CodexBar/Resources/de.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/de.lproj/Localizable.strings @@ -1273,6 +1273,9 @@ "Weekly cannot run out before reset at this pace" = "Das Wochenlimit kann bei diesem Tempo nicht vor dem Reset aufgebraucht sein"; "Weekly can run out ≈%d windows early" = "Das Wochenlimit kann ≈%d Fenster früher aufgebraucht sein"; "Estimated: %@" = "Geschätzt: %@"; +"session_quota_estimate_value_format" = "%1$@ %2$@"; +"session quota" = "Sitzungskontingent"; +"session quotas" = "Sitzungskontingente"; "Coding Plan" = "Coding-Plan"; "Agent Plan" = "Agentenplan"; "Team" = "Team"; diff --git a/Sources/CodexBar/Resources/en.lproj/Localizable.strings b/Sources/CodexBar/Resources/en.lproj/Localizable.strings index 5cc6b2defb..2b7af31afc 100644 --- a/Sources/CodexBar/Resources/en.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/en.lproj/Localizable.strings @@ -1276,7 +1276,10 @@ "≈%d full 5h windows of weekly left · %d windows until reset" = "≈%d full 5h windows of weekly left · %d windows until reset"; "Weekly cannot run out before reset at this pace" = "Weekly cannot run out before reset at this pace"; "Weekly can run out ≈%d windows early" = "Weekly can run out ≈%d windows early"; -"Estimated: %@" = "Estimated: %@"; +"Estimated: %@" = "Est. %@"; +"session_quota_estimate_value_format" = "%1$@ %2$@"; +"session quota" = "session quota"; +"session quotas" = "session quotas"; "Coding Plan" = "Coding Plan"; "Agent Plan" = "Agent Plan"; "Team" = "Team"; diff --git a/Sources/CodexBar/Resources/es.lproj/Localizable.strings b/Sources/CodexBar/Resources/es.lproj/Localizable.strings index 90159b2bc2..318b17342f 100644 --- a/Sources/CodexBar/Resources/es.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/es.lproj/Localizable.strings @@ -1271,6 +1271,9 @@ "Weekly cannot run out before reset at this pace" = "La cuota semanal no puede agotarse antes del reinicio a este ritmo"; "Weekly can run out ≈%d windows early" = "La cuota semanal puede agotarse ≈%d ventanas antes"; "Estimated: %@" = "Estimación: %@"; +"session_quota_estimate_value_format" = "%1$@ %2$@"; +"session quota" = "cuota de sesión"; +"session quotas" = "cuotas de sesión"; "Coding Plan" = "Plan de programación"; "Agent Plan" = "Plan de agente"; "Team" = "Equipo"; diff --git a/Sources/CodexBar/Resources/fa.lproj/Localizable.strings b/Sources/CodexBar/Resources/fa.lproj/Localizable.strings index 789090531a..78946a29d8 100644 --- a/Sources/CodexBar/Resources/fa.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/fa.lproj/Localizable.strings @@ -1276,6 +1276,9 @@ "Weekly cannot run out before reset at this pace" = "با این روند، سهم هفتگی پیش از بازنشانی تمام نمی‌شود"; "Weekly can run out ≈%d windows early" = "سهم هفتگی ممکن است حدود %d بازه زودتر تمام شود"; "Estimated: %@" = "برآوردی: %@"; +"session_quota_estimate_value_format" = "%1$@ %2$@"; +"session quota" = "سهمیه جلسه"; +"session quotas" = "سهمیه‌های جلسه"; "Coding Plan" = "طرح کدنویسی"; "Agent Plan" = "طرح عامل"; "Team" = "تیم"; diff --git a/Sources/CodexBar/Resources/fr.lproj/Localizable.strings b/Sources/CodexBar/Resources/fr.lproj/Localizable.strings index fafb4abed0..3ee1d124cb 100644 --- a/Sources/CodexBar/Resources/fr.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/fr.lproj/Localizable.strings @@ -1272,6 +1272,9 @@ "Weekly cannot run out before reset at this pace" = "Le quota hebdomadaire ne peut pas être épuisé avant la réinitialisation à ce rythme"; "Weekly can run out ≈%d windows early" = "Le quota hebdomadaire peut être épuisé ≈%d fenêtres plus tôt"; "Estimated: %@" = "Estimation : %@"; +"session_quota_estimate_value_format" = "%1$@ %2$@"; +"session quota" = "quota de session"; +"session quotas" = "quotas de session"; "Coding Plan" = "Plan de codage"; "Agent Plan" = "Plan d'agent"; "Team" = "Équipe"; diff --git a/Sources/CodexBar/Resources/gl.lproj/Localizable.strings b/Sources/CodexBar/Resources/gl.lproj/Localizable.strings index 82cc28af19..bb76b0cd4f 100644 --- a/Sources/CodexBar/Resources/gl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/gl.lproj/Localizable.strings @@ -1272,6 +1272,9 @@ "Weekly cannot run out before reset at this pace" = "A cota semanal non pode esgotarse antes do restablecemento a este ritmo"; "Weekly can run out ≈%d windows early" = "A cota semanal pode esgotarse ≈%d xanelas antes"; "Estimated: %@" = "Estimación: %@"; +"session_quota_estimate_value_format" = "%1$@ %2$@"; +"session quota" = "cota de sesión"; +"session quotas" = "cotas de sesión"; "Coding Plan" = "Plan de programación"; "Agent Plan" = "Plan de axente"; "Team" = "Equipo"; diff --git a/Sources/CodexBar/Resources/id.lproj/Localizable.strings b/Sources/CodexBar/Resources/id.lproj/Localizable.strings index abebb2f911..f57824c222 100644 --- a/Sources/CodexBar/Resources/id.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/id.lproj/Localizable.strings @@ -1276,6 +1276,9 @@ "Weekly cannot run out before reset at this pace" = "Kuota mingguan tidak dapat habis sebelum reset dengan laju ini"; "Weekly can run out ≈%d windows early" = "Kuota mingguan dapat habis ≈%d jendela lebih awal"; "Estimated: %@" = "Perkiraan: %@"; +"session_quota_estimate_value_format" = "%1$@ %2$@"; +"session quota" = "kuota sesi"; +"session quotas" = "kuota sesi"; "Coding Plan" = "Paket Coding"; "Agent Plan" = "Paket Agen"; "Team" = "Tim"; diff --git a/Sources/CodexBar/Resources/it.lproj/Localizable.strings b/Sources/CodexBar/Resources/it.lproj/Localizable.strings index 402383c328..0d78717893 100644 --- a/Sources/CodexBar/Resources/it.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/it.lproj/Localizable.strings @@ -1276,6 +1276,9 @@ "Weekly cannot run out before reset at this pace" = "La quota settimanale non può esaurirsi prima del reset a questo ritmo"; "Weekly can run out ≈%d windows early" = "La quota settimanale può esaurirsi ≈%d finestre prima"; "Estimated: %@" = "Stima: %@"; +"session_quota_estimate_value_format" = "%1$@ %2$@"; +"session quota" = "quota di sessione"; +"session quotas" = "quote di sessione"; "Coding Plan" = "Piano di codifica"; "Agent Plan" = "Piano agente"; "Team" = "Squadra"; diff --git a/Sources/CodexBar/Resources/ja.lproj/Localizable.strings b/Sources/CodexBar/Resources/ja.lproj/Localizable.strings index 51f5987788..e886bd2e60 100644 --- a/Sources/CodexBar/Resources/ja.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ja.lproj/Localizable.strings @@ -1273,6 +1273,9 @@ "Weekly cannot run out before reset at this pace" = "このペースではリセット前に週間枠を使い切れません"; "Weekly can run out ≈%d windows early" = "週間枠は約%dウィンドウ早く使い切る可能性があります"; "Estimated: %@" = "推定:%@"; +"session_quota_estimate_value_format" = "%1$@%2$@"; +"session quota" = "セッション枠"; +"session quotas" = "セッション枠"; "Coding Plan" = "コーディングプラン"; "Agent Plan" = "エージェントプラン"; "Team" = "チーム"; diff --git a/Sources/CodexBar/Resources/ko.lproj/Localizable.strings b/Sources/CodexBar/Resources/ko.lproj/Localizable.strings index 57d7245382..a92c06c2af 100644 --- a/Sources/CodexBar/Resources/ko.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ko.lproj/Localizable.strings @@ -1240,6 +1240,9 @@ "Weekly cannot run out before reset at this pace" = "이 속도라면 재설정 전에 주간 한도를 소진할 수 없습니다"; "Weekly can run out ≈%d windows early" = "주간 한도가 약 %d개 창 일찍 소진될 수 있습니다"; "Estimated: %@" = "예상: %@"; +"session_quota_estimate_value_format" = "%1$@ %2$@"; +"session quota" = "세션 할당량"; +"session quotas" = "세션 할당량"; "Coding Plan" = "코딩 요금제"; "Agent Plan" = "에이전트 요금제"; "Team" = "팀"; diff --git a/Sources/CodexBar/Resources/nl.lproj/Localizable.strings b/Sources/CodexBar/Resources/nl.lproj/Localizable.strings index fbf7cbf4df..66f838a6f9 100644 --- a/Sources/CodexBar/Resources/nl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/nl.lproj/Localizable.strings @@ -1272,6 +1272,9 @@ "Weekly cannot run out before reset at this pace" = "Het weeklimiet kan bij dit tempo niet vóór de reset opraken"; "Weekly can run out ≈%d windows early" = "Het weeklimiet kan ≈%d vensters eerder opraken"; "Estimated: %@" = "Schatting: %@"; +"session_quota_estimate_value_format" = "%1$@ %2$@"; +"session quota" = "sessiequotum"; +"session quotas" = "sessiequota"; "Coding Plan" = "Codeerplan"; "Agent Plan" = "Agentplan"; "Team" = "Team"; diff --git a/Sources/CodexBar/Resources/pl.lproj/Localizable.strings b/Sources/CodexBar/Resources/pl.lproj/Localizable.strings index 92351c1788..64be308cdc 100644 --- a/Sources/CodexBar/Resources/pl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/pl.lproj/Localizable.strings @@ -1276,6 +1276,9 @@ "Weekly cannot run out before reset at this pace" = "Przy tym tempie limit tygodniowy nie może wyczerpać się przed resetem"; "Weekly can run out ≈%d windows early" = "Limit tygodniowy może wyczerpać się ≈%d okien wcześniej"; "Estimated: %@" = "Szacunek: %@"; +"session_quota_estimate_value_format" = "%1$@ %2$@"; +"session quota" = "limit sesji"; +"session quotas" = "limity sesji"; "Coding Plan" = "Plan kodowania"; "Agent Plan" = "Plan agenta"; "Team" = "Zespół"; diff --git a/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings b/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings index 9fc56f647d..d7f8839681 100644 --- a/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings @@ -1273,6 +1273,9 @@ "Weekly cannot run out before reset at this pace" = "A cota semanal não pode acabar antes da renovação nesse ritmo"; "Weekly can run out ≈%d windows early" = "A cota semanal pode acabar ≈%d janelas antes"; "Estimated: %@" = "Estimativa: %@"; +"session_quota_estimate_value_format" = "%1$@ %2$@"; +"session quota" = "cota da sessão"; +"session quotas" = "cotas da sessão"; "Coding Plan" = "Plano de codificação"; "Agent Plan" = "Plano de agente"; "Team" = "Equipe"; diff --git a/Sources/CodexBar/Resources/ru.lproj/Localizable.strings b/Sources/CodexBar/Resources/ru.lproj/Localizable.strings index debe92c848..53acdd4ae3 100644 --- a/Sources/CodexBar/Resources/ru.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ru.lproj/Localizable.strings @@ -1274,6 +1274,9 @@ "Weekly cannot run out before reset at this pace" = "При таком темпе недельный лимит не может закончиться до сброса"; "Weekly can run out ≈%d windows early" = "Недельный лимит может закончиться на ≈%d окон раньше"; "Estimated: %@" = "Оценка: %@"; +"session_quota_estimate_value_format" = "%1$@ %2$@"; +"session quota" = "лимит сессии"; +"session quotas" = "лимиты сессий"; "Coding Plan" = "План программирования"; "Agent Plan" = "План агента"; "Team" = "Команда"; diff --git a/Sources/CodexBar/Resources/sv.lproj/Localizable.strings b/Sources/CodexBar/Resources/sv.lproj/Localizable.strings index 18e1d3c828..dba14114f8 100644 --- a/Sources/CodexBar/Resources/sv.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/sv.lproj/Localizable.strings @@ -1271,6 +1271,9 @@ "Weekly cannot run out before reset at this pace" = "Veckokvoten kan inte ta slut före återställningen i den här takten"; "Weekly can run out ≈%d windows early" = "Veckokvoten kan ta slut ≈%d fönster tidigare"; "Estimated: %@" = "Uppskattning: %@"; +"session_quota_estimate_value_format" = "%1$@ %2$@"; +"session quota" = "sessionskvot"; +"session quotas" = "sessionskvoter"; "Coding Plan" = "Kodningsplan"; "Agent Plan" = "Agentplan"; "Team" = "Team"; diff --git a/Sources/CodexBar/Resources/th.lproj/Localizable.strings b/Sources/CodexBar/Resources/th.lproj/Localizable.strings index 686d42e6ef..b7be583ad9 100644 --- a/Sources/CodexBar/Resources/th.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/th.lproj/Localizable.strings @@ -1276,6 +1276,9 @@ "Weekly cannot run out before reset at this pace" = "ด้วยอัตรานี้ โควตารายสัปดาห์จะไม่หมดก่อนรีเซ็ต"; "Weekly can run out ≈%d windows early" = "โควตารายสัปดาห์อาจหมดเร็วขึ้น ≈%d ช่วง"; "Estimated: %@" = "โดยประมาณ: %@"; +"session_quota_estimate_value_format" = "%1$@ %2$@"; +"session quota" = "โควตาเซสชัน"; +"session quotas" = "โควตาเซสชัน"; "Coding Plan" = "แผนการเขียนโค้ด"; "Agent Plan" = "แผนเอเจนต์"; "Team" = "ทีม"; diff --git a/Sources/CodexBar/Resources/tr.lproj/Localizable.strings b/Sources/CodexBar/Resources/tr.lproj/Localizable.strings index b8eb70a926..49133b967f 100644 --- a/Sources/CodexBar/Resources/tr.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/tr.lproj/Localizable.strings @@ -1274,6 +1274,9 @@ "Weekly cannot run out before reset at this pace" = "Bu hızda haftalık kota sıfırlamadan önce tükenemez"; "Weekly can run out ≈%d windows early" = "Haftalık kota ≈%d pencere erken tükenebilir"; "Estimated: %@" = "Tahmini: %@"; +"session_quota_estimate_value_format" = "%1$@ %2$@"; +"session quota" = "oturum kotası"; +"session quotas" = "oturum kotaları"; "Coding Plan" = "Kodlama Planı"; "Agent Plan" = "Ajan Planı"; "Team" = "Ekip"; diff --git a/Sources/CodexBar/Resources/uk.lproj/Localizable.strings b/Sources/CodexBar/Resources/uk.lproj/Localizable.strings index 359780b371..a544d08172 100644 --- a/Sources/CodexBar/Resources/uk.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/uk.lproj/Localizable.strings @@ -1272,6 +1272,9 @@ "Weekly cannot run out before reset at this pace" = "За такого темпу тижневий ліміт не може вичерпатися до скидання"; "Weekly can run out ≈%d windows early" = "Тижневий ліміт може вичерпатися на ≈%d вікон раніше"; "Estimated: %@" = "Оцінка: %@"; +"session_quota_estimate_value_format" = "%1$@ %2$@"; +"session quota" = "ліміт сесії"; +"session quotas" = "ліміти сесій"; "Coding Plan" = "План кодування"; "Agent Plan" = "План агента"; "Team" = "Команда"; diff --git a/Sources/CodexBar/Resources/vi.lproj/Localizable.strings b/Sources/CodexBar/Resources/vi.lproj/Localizable.strings index 14dbaabac6..45aec46eec 100644 --- a/Sources/CodexBar/Resources/vi.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/vi.lproj/Localizable.strings @@ -1273,6 +1273,9 @@ "Weekly cannot run out before reset at this pace" = "Với tốc độ này, hạn mức tuần không thể hết trước khi đặt lại"; "Weekly can run out ≈%d windows early" = "Hạn mức tuần có thể hết sớm ≈%d cửa sổ"; "Estimated: %@" = "Ước tính: %@"; +"session_quota_estimate_value_format" = "%1$@ %2$@"; +"session quota" = "hạn mức phiên"; +"session quotas" = "hạn mức phiên"; "Coding Plan" = "Gói lập trình"; "Agent Plan" = "Gói tác nhân"; "Team" = "Nhóm"; diff --git a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings index ae94fdb4b8..207b07ffdf 100644 --- a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings @@ -1248,6 +1248,9 @@ "Weekly cannot run out before reset at this pace" = "按此速度,每周额度无法在重置前用完"; "Weekly can run out ≈%d windows early" = "每周额度可能提前约 %d 个窗口用完"; "Estimated: %@" = "估算:%@"; +"session_quota_estimate_value_format" = "%1$@%2$@"; +"session quota" = "会话额度"; +"session quotas" = "会话额度"; "Coding Plan" = "编程套餐"; "Agent Plan" = "智能体套餐"; "Team" = "团队"; diff --git a/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings b/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings index c5a328b8c9..e99314e463 100644 --- a/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings @@ -1303,6 +1303,9 @@ "Weekly cannot run out before reset at this pace" = "依此速度,每週額度無法在重置前用完"; "Weekly can run out ≈%d windows early" = "每週額度可能提前約 %d 個視窗用完"; "Estimated: %@" = "預估:%@"; +"session_quota_estimate_value_format" = "%1$@%2$@"; +"session quota" = "工作階段額度"; +"session quotas" = "工作階段額度"; "Coding Plan" = "程式設計方案"; "Agent Plan" = "智慧體方案"; "Team" = "團隊"; diff --git a/Sources/CodexBar/UsagePaceText.swift b/Sources/CodexBar/UsagePaceText.swift index 32823b6d22..6019de6e03 100644 --- a/Sources/CodexBar/UsagePaceText.swift +++ b/Sources/CodexBar/UsagePaceText.swift @@ -10,10 +10,9 @@ enum UsagePaceText { } struct SessionEquivalentDetail: Equatable { - let verdictText: String - let numberText: String - let verdictAccessibilityLabel: String - let numberAccessibilityLabel: String + let leftText: String + let rightText: String + let accessibilityLabel: String } private enum DetailContext { @@ -38,36 +37,40 @@ enum UsagePaceText { } static func sessionEquivalentDetail(forecast: SessionEquivalentForecast) -> SessionEquivalentDetail { - let displayedEstimate = Self.boundedFullWindowCount(forecast.estimatedWindowsToExhaustWeekly) - let numberText = String.localizedStringWithFormat( - L("≈%d full 5h windows of weekly left · %d windows until reset"), - displayedEstimate, - forecast.windowsUntilReset) - let verdictText: String - if forecast.estimatedWindowsToExhaustWeekly >= forecast.availableWindowsUntilReset { - verdictText = L("Weekly cannot run out before reset at this pace") + let leftText = Self.sessionQuotaEstimateText(forecast.estimatedWindowsToExhaustWeekly) + let rightText = Self.windowsUntilResetText(forecast.windowsUntilReset) + return SessionEquivalentDetail( + leftText: leftText, + rightText: rightText, + accessibilityLabel: L("%@ · %@", leftText, rightText)) + } + + private static func sessionQuotaEstimateText(_ value: Double) -> String { + let displayedEstimate: String + let unit: String + if value.isFinite, value > 0 { + let boundedValue = min(value, 1_000_000) + let roundedValue = (boundedValue * 10).rounded() / 10 + displayedEstimate = roundedValue.formatted( + .number + .precision(.fractionLength(0...1)) + .locale(codexBarLocalizedLocale())) + unit = roundedValue > 0 && roundedValue <= 1 ? L("session quota") : L("session quotas") } else { - let windowsEarly = Self.boundedWindowCount( - forecast.availableWindowsUntilReset - forecast.estimatedWindowsToExhaustWeekly) - verdictText = String.localizedStringWithFormat( - L("Weekly can run out ≈%d windows early"), - max(1, windowsEarly)) + displayedEstimate = codexBarLocalizedInteger(0) + unit = L("session quotas") } - return SessionEquivalentDetail( - verdictText: verdictText, - numberText: numberText, - verdictAccessibilityLabel: L("Estimated: %@", verdictText), - numberAccessibilityLabel: L("Estimated: %@", numberText)) + let estimateValue = L("session_quota_estimate_value_format", displayedEstimate, unit) + return L("Estimated: %@", L("%@ left", estimateValue)) } - private static func boundedWindowCount(_ value: Double) -> Int { - guard value.isFinite, value > 0 else { return 0 } - return Int(min(value, 1_000_000).rounded()) - } - - private static func boundedFullWindowCount(_ value: Double) -> Int { - guard value.isFinite, value > 0 else { return 0 } - return Int(floor(min(value, 1_000_000))) + private static func windowsUntilResetText(_ count: Int) -> String { + let combinedText = String.localizedStringWithFormat( + L("≈%d full 5h windows of weekly left · %d windows until reset"), + 0, + count) + guard let separatorRange = combinedText.range(of: " · ") else { return combinedText } + return String(combinedText[separatorRange.upperBound...]) } private static func detailLeftLabel(for pace: UsagePace) -> String { diff --git a/Tests/CodexBarTests/LocalizationLanguageCatalogTests.swift b/Tests/CodexBarTests/LocalizationLanguageCatalogTests.swift index 3d423cede9..27a81f1d63 100644 --- a/Tests/CodexBarTests/LocalizationLanguageCatalogTests.swift +++ b/Tests/CodexBarTests/LocalizationLanguageCatalogTests.swift @@ -539,6 +539,7 @@ struct LocalizationLanguageCatalogTests { "menu_bar_layout_token_account", "ory_session_…=…; csrftoken=…", "section_privacy", + "session_quota_estimate_value_format", "tab_menu", ] let unchanged = Set(english.keys.filter { italian[$0] == english[$0] }) diff --git a/Tests/CodexBarTests/MenuCardOverrideIsolationTests.swift b/Tests/CodexBarTests/MenuCardOverrideIsolationTests.swift index f5dc976919..e2ea1fb5e2 100644 --- a/Tests/CodexBarTests/MenuCardOverrideIsolationTests.swift +++ b/Tests/CodexBarTests/MenuCardOverrideIsolationTests.swift @@ -170,10 +170,10 @@ struct MenuCardOverrideIsolationTests { sourceLabel: nil, cacheKey: "bob"))) let weeklyMetric = try #require(model.metrics.first { $0.id == "secondary" }) - let numberText = try #require(weeklyMetric.sessionEquivalentDetail?.numberText) + let leftText = try #require(weeklyMetric.sessionEquivalentDetail?.leftText) - #expect(numberText.hasPrefix("≈8 full 5h windows")) - #expect(!numberText.hasPrefix("≈2 full 5h windows")) + #expect(leftText == "Est. 8 session quotas left") + #expect(leftText != "Est. 2 session quotas left") } @Test diff --git a/Tests/CodexBarTests/SessionEquivalentForecastTests.swift b/Tests/CodexBarTests/SessionEquivalentForecastTests.swift index 5f95bb4201..e3accacebc 100644 --- a/Tests/CodexBarTests/SessionEquivalentForecastTests.swift +++ b/Tests/CodexBarTests/SessionEquivalentForecastTests.swift @@ -155,8 +155,8 @@ struct SessionEquivalentForecastTests { hidePersonalInfo: true) let redactedDetail = try #require(redacted.first?.sessionEquivalentDetail) - #expect(redactedDetail.verdictText == detail.verdictText) - #expect(redactedDetail.numberText == detail.numberText) + #expect(redactedDetail.leftText == detail.leftText) + #expect(redactedDetail.rightText == detail.rightText) } @Test @@ -244,7 +244,7 @@ struct SessionEquivalentForecastTests { } @Test - func `formats verdict first and number second`() { + func `formats session quota estimate and reset windows`() { let early = SessionEquivalentForecast( estimatedWindowsToExhaustWeekly: 4, windowsUntilReset: 9, @@ -261,22 +261,22 @@ struct SessionEquivalentForecastTests { let earlyText = UsagePaceText.sessionEquivalentDetail(forecast: early) let strandedText = UsagePaceText.sessionEquivalentDetail(forecast: stranded) - #expect(earlyText.verdictText == "Weekly can run out ≈5 windows early") - #expect(earlyText.numberText == "≈4 full 5h windows of weekly left · 9 windows until reset") - #expect(earlyText.verdictAccessibilityLabel == "Estimated: Weekly can run out ≈5 windows early") - #expect(strandedText.verdictText == "Weekly cannot run out before reset at this pace") + #expect(earlyText.leftText == "Est. 4 session quotas left") + #expect(earlyText.rightText == "9 windows until reset") + #expect(earlyText.accessibilityLabel == "Est. 4 session quotas left · 9 windows until reset") + #expect(strandedText.leftText == "Est. 10 session quotas left") } @Test - func `formats equality as lasting to reset and pluralizes singular windows`() { + func `formats quota estimates with up to one decimal and pluralizes units`() { let equal = UsagePaceText.sessionEquivalentDetail(forecast: SessionEquivalentForecast( estimatedWindowsToExhaustWeekly: 2, windowsUntilReset: 2, sampleCount: 7, weeklyResetsAt: Self.weeklyReset, weeklyUsedPercent: 80)) - let singular = UsagePaceText.sessionEquivalentDetail(forecast: SessionEquivalentForecast( - estimatedWindowsToExhaustWeekly: 1, + let roundedSingular = UsagePaceText.sessionEquivalentDetail(forecast: SessionEquivalentForecast( + estimatedWindowsToExhaustWeekly: 1.04, windowsUntilReset: 2, sampleCount: 7, weeklyResetsAt: Self.weeklyReset, @@ -288,25 +288,25 @@ struct SessionEquivalentForecastTests { weeklyResetsAt: Self.weeklyReset, weeklyUsedPercent: 14)) - #expect(equal.verdictText == "Weekly cannot run out before reset at this pace") - #expect(singular.numberText == "≈1 full 5h window of weekly left · 2 windows until reset") - #expect(singular.verdictText == "Weekly can run out ≈1 window early") - #expect(close.numberText == "≈8 full 5h windows of weekly left · 9 windows until reset") - #expect(close.verdictText == "Weekly can run out ≈1 window early") + #expect(equal.leftText == "Est. 2 session quotas left") + #expect(roundedSingular.leftText == "Est. 1 session quota left") + #expect(roundedSingular.rightText == "2 windows until reset") + #expect(close.leftText == "Est. 8.6 session quotas left") + #expect(close.rightText == "9 windows until reset") } @Test - func `verdict uses fractional capacity while number line shows full windows`() { + func `formats sub-one quota and singular reset window`() { let detail = UsagePaceText.sessionEquivalentDetail(forecast: SessionEquivalentForecast( - estimatedWindowsToExhaustWeekly: 0.5, - windowsUntilReset: 0, - availableWindowsUntilReset: 0.8, + estimatedWindowsToExhaustWeekly: 0.3, + windowsUntilReset: 1, + availableWindowsUntilReset: 1.8, sampleCount: 7, weeklyResetsAt: Self.weeklyReset, weeklyUsedPercent: 95)) - #expect(detail.verdictText == "Weekly can run out ≈1 window early") - #expect(detail.numberText == "≈0 full 5h windows of weekly left · 0 windows until reset") + #expect(detail.leftText == "Est. 0.3 session quota left") + #expect(detail.rightText == "1 window until reset") } @Test @@ -396,7 +396,7 @@ struct SessionEquivalentForecastTests { sampleCount: 7, weeklyResetsAt: Self.weeklyReset, weeklyUsedPercent: 1)) - #expect(huge.numberText.contains("full 5h windows")) + #expect(huge.leftText == "Est. 1,000,000 session quotas left") } @Test @@ -447,7 +447,7 @@ struct SessionEquivalentForecastTests { @Test func `provider metric shows estimate only on its matching weekly window`() throws { let now = Date(timeIntervalSince1970: 1_900_000_000) - let weeklyReset = now.addingTimeInterval(2 * 24 * 3600) + let weeklyReset = now.addingTimeInterval((7 * 60 + 37) * 60) let snapshot = UsageSnapshot( primary: RateWindow( usedPercent: 20, @@ -455,18 +455,18 @@ struct SessionEquivalentForecastTests { resetsAt: now.addingTimeInterval(3600), resetDescription: nil), secondary: RateWindow( - usedPercent: 60, + usedPercent: 95, windowMinutes: 10080, resetsAt: weeklyReset, resetDescription: nil), updatedAt: now) let metadata = try #require(ProviderDefaults.metadata[.claude]) let forecast = SessionEquivalentForecast( - estimatedWindowsToExhaustWeekly: 4, - windowsUntilReset: 9, + estimatedWindowsToExhaustWeekly: 0.3, + windowsUntilReset: 1, sampleCount: 7, weeklyResetsAt: weeklyReset, - weeklyUsedPercent: 60) + weeklyUsedPercent: 95) let model = UsageMenuCardView.Model.make(.init( provider: .claude, @@ -486,13 +486,24 @@ struct SessionEquivalentForecastTests { tokenCostUsageEnabled: false, showOptionalCreditsAndExtraUsage: true, hidePersonalInfo: false, + weeklyPace: UsagePace( + stage: .onTrack, + deltaPercent: -0.4, + expectedUsedPercent: 95.4, + actualUsedPercent: 95, + etaSeconds: nil, + willLastToReset: true), sessionEquivalentForecast: forecast, now: now)) let sessionMetric = try #require(model.metrics.first { $0.id == "primary" }) let weeklyMetric = try #require(model.metrics.first { $0.id == "secondary" }) #expect(sessionMetric.sessionEquivalentDetail == nil) - #expect(weeklyMetric.sessionEquivalentDetail?.verdictText == "Weekly can run out ≈5 windows early") + #expect(weeklyMetric.percentLabel == "5% left") + #expect(weeklyMetric.detailLeftText == "On pace") + #expect(weeklyMetric.detailRightText == "Lasts until reset") + #expect(weeklyMetric.sessionEquivalentDetail?.leftText == "Est. 0.3 session quota left") + #expect(weeklyMetric.sessionEquivalentDetail?.rightText == "1 window until reset") } @MainActor diff --git a/Tests/CodexBarTests/UsagePaceTextTests.swift b/Tests/CodexBarTests/UsagePaceTextTests.swift index 9fc7974881..a703689995 100644 --- a/Tests/CodexBarTests/UsagePaceTextTests.swift +++ b/Tests/CodexBarTests/UsagePaceTextTests.swift @@ -17,6 +17,10 @@ struct UsagePaceTextTests { "Runs out in %@", "1.5× headroom", "≈ %d%% run-out risk", + "%@ left", + "session quota", + "session quotas", + "session_quota_estimate_value_format", "≈%d full 5h windows of weekly left · %d windows until reset", "Weekly cannot run out before reset at this pace", "Weekly can run out ≈%d windows early", @@ -396,6 +400,29 @@ struct UsagePaceTextTests { } } + @Test + func `session quota estimate template localizes CJK number unit spacing`() throws { + let root = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + let expectations = [ + (language: "zh-Hans", unit: "会话额度", expected: "0.3会话额度"), + (language: "zh-Hant", unit: "工作階段額度", expected: "0.3工作階段額度"), + (language: "ja", unit: "セッション枠", expected: "0.3セッション枠"), + (language: "ko", unit: "세션 할당량", expected: "0.3 세션 할당량"), + ] + + for expectation in expectations { + let url = root.appendingPathComponent( + "Sources/CodexBar/Resources/\(expectation.language).lproj/Localizable.strings") + let table = try Self.readStringsTable(at: url) + let template = try #require(table["session_quota_estimate_value_format"]) + let arguments: [CVarArg] = ["0.3", expectation.unit] + #expect(String(format: template, arguments: arguments) == expectation.expected) + } + } + private static func readStringsTable(at url: URL) throws -> [String: String] { guard let dict = NSDictionary(contentsOf: url) as? [String: String] else { throw NSError(