From 7a7d41c873c1efe32d9caf4733f44c57d3a07fee Mon Sep 17 00:00:00 2001 From: SaladDay Date: Sun, 5 Jul 2026 10:54:20 -0400 Subject: [PATCH] fix(subscription): display Codex free-plan 30-day quota window (#3651) (#4886) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(subscription): display Codex free-plan 30-day quota window (#3651) Codex free accounts are metered on a rolling 30-day secondary window (limit_window_seconds = 2,592,000) instead of the weekly window used by paid plans. The backend already maps this correctly to the tier name "30_day", but the frontend TIER_I18N_KEYS whitelist had no entry for it, so SubscriptionQuotaView filtered the tier out. When that was the only surviving tier (as happens for free accounts), the whole quota footer rendered nothing — free accounts showed no remaining quota or reset time while paid accounts worked. - Add "30_day" -> subscription.thirtyDay to TIER_I18N_KEYS - Add the thirtyDay label to all four locales (zh/en/ja/zh-TW) - Add a unit test locking in window_seconds_to_tier_name mappings, including the 30-day case Fixes #3651 * fix(tray): render Codex free-plan 30-day window in tray menu The tray keeps its own tier-name whitelist (TIER_LABEL_GROUPS) independent of the frontend TIER_I18N_KEYS. Its month group only listed "monthly", so a free Codex account whose only tier is "30_day" produced empty labeled parts → format_subscription_summary returned None → the tray showed no quota, even though the footer now does. That breaks the invariant the existing gemini_summary_lite_only_still_renders test guards: any tier visible in the footer must not leave the tray blank. - Add TIER_THIRTY_DAY ("30_day") constant so the string is single-sourced across backend mapping, tray grouping, and the frontend whitelist - Map 2_592_000s explicitly to it in window_seconds_to_tier_name - Add TIER_THIRTY_DAY to the tray month ("m") group - Add codex_summary_thirty_day_only_still_renders regression test Follow-up to the review on #3651 / PR for the 30-day footer fix. --- src-tauri/src/services/subscription.rs | 32 ++++++++++++++++++++-- src-tauri/src/tray.rs | 21 ++++++++++++-- src/components/SubscriptionQuotaFooter.tsx | 2 ++ src/i18n/locales/en.json | 1 + src/i18n/locales/ja.json | 1 + src/i18n/locales/zh-TW.json | 1 + src/i18n/locales/zh.json | 1 + 7 files changed, 54 insertions(+), 5 deletions(-) diff --git a/src-tauri/src/services/subscription.rs b/src-tauri/src/services/subscription.rs index 5528455e6..dfe8d0a0e 100644 --- a/src-tauri/src/services/subscription.rs +++ b/src-tauri/src/services/subscription.rs @@ -312,6 +312,12 @@ pub const TIER_WEEKLY_LIMIT: &str = "weekly_limit"; /// 映射到 `subscription.monthly`。 pub const TIER_MONTHLY: &str = "monthly"; +/// Codex 免费方案的 30 天(月)滚动窗口 tier 名。付费方案的次要窗口是 7 天 +/// (`seven_day`),免费方案则是 30 天。由 `window_seconds_to_tier_name` 产出、 +/// tray 的月分组渲染、前端 `TIER_I18N_KEYS` 映射到 `subscription.thirtyDay` +/// 三处共用同一标识。见 #3651。 +pub const TIER_THIRTY_DAY: &str = "30_day"; + /// Gemini 用量分组名称(按模型而非时间窗口)。`classify_gemini_model` 输出。 pub const TIER_GEMINI_PRO: &str = "gemini_pro"; pub const TIER_GEMINI_FLASH: &str = "gemini_flash"; @@ -632,8 +638,12 @@ struct CodexUsageResponse { /// 根据窗口秒数映射到 tier 名称(与 Claude 的命名兼容以复用前端 i18n) fn window_seconds_to_tier_name(secs: i64) -> String { match secs { - 18000 => "five_hour".to_string(), - 604800 => "seven_day".to_string(), + 18000 => TIER_FIVE_HOUR.to_string(), + 604800 => TIER_SEVEN_DAY.to_string(), + // Codex 免费方案的 30 天窗口。显式映射到常量,与 tray 月分组、前端 + // TIER_I18N_KEYS 保持同一标识(否则动态回退虽也得到 "30_day",但字符串 + // 分散在多处、易和托盘/前端白名单脱节)。见 #3651。 + 2_592_000 => TIER_THIRTY_DAY.to_string(), s => { let hours = s / 3600; if hours >= 24 { @@ -1340,3 +1350,21 @@ fn now_millis() -> i64 { .unwrap_or_default() .as_millis() as i64 } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn window_seconds_map_to_expected_tier_names() { + // 官方特例窗口 + assert_eq!(window_seconds_to_tier_name(18000), TIER_FIVE_HOUR); + assert_eq!(window_seconds_to_tier_name(604800), TIER_SEVEN_DAY); + // Codex 免费方案的次要窗口是 30 天(30 * 24 * 3600 = 2_592_000 秒)。 + // 前端 TIER_I18N_KEYS 与 tray 月分组都需要认得 "30_day",见 #3651。 + assert_eq!(window_seconds_to_tier_name(2_592_000), TIER_THIRTY_DAY); + // 其他窗口按小时/天回退命名 + assert_eq!(window_seconds_to_tier_name(3600), "1_hour"); + assert_eq!(window_seconds_to_tier_name(86400), "1_day"); + } +} diff --git a/src-tauri/src/tray.rs b/src-tauri/src/tray.rs index ea614838a..0b159ae67 100644 --- a/src-tauri/src/tray.rs +++ b/src-tauri/src/tray.rs @@ -19,8 +19,13 @@ const W_TIER_NAMES: &[&str] = &[ crate::services::subscription::TIER_SEVEN_DAY_OPUS, crate::services::subscription::TIER_SEVEN_DAY_SONNET, ]; -// 火山方舟 Agent/Coding Plan 的月窗口(5h/周/月 三档)。 -const M_TIER_NAMES: &[&str] = &[crate::services::subscription::TIER_MONTHLY]; +// 月窗口分组:火山方舟 Agent/Coding Plan 的月窗口(5h/周/月 三档), +// 以及 Codex 免费方案的 30 天窗口(#3651)——两者都归入 "m" 档,避免免费 +// Codex 账号在托盘里空白(前端 footer 能看到、托盘却不显示的不对称)。 +const M_TIER_NAMES: &[&str] = &[ + crate::services::subscription::TIER_MONTHLY, + crate::services::subscription::TIER_THIRTY_DAY, +]; const GEMINI_PRO_TIER_NAMES: &[&str] = &[crate::services::subscription::TIER_GEMINI_PRO]; const GEMINI_FLASH_TIER_NAMES: &[&str] = &[crate::services::subscription::TIER_GEMINI_FLASH]; const GEMINI_FLASH_LITE_TIER_NAMES: &[&str] = @@ -882,7 +887,7 @@ mod tests { use crate::services::subscription::{ CredentialStatus, QuotaTier, SubscriptionQuota, TIER_FIVE_HOUR, TIER_GEMINI_FLASH, TIER_GEMINI_FLASH_LITE, TIER_GEMINI_PRO, TIER_MONTHLY, TIER_SEVEN_DAY, TIER_SEVEN_DAY_OPUS, - TIER_SEVEN_DAY_SONNET, TIER_WEEKLY_LIMIT, + TIER_SEVEN_DAY_SONNET, TIER_THIRTY_DAY, TIER_WEEKLY_LIMIT, }; #[test] @@ -964,6 +969,16 @@ mod tests { assert!(s.contains("l80%"), "expected l80% in {s}"); } + #[test] + fn codex_summary_thirty_day_only_still_renders() { + // Codex 免费方案的唯一 tier 是 30 天窗口。前端 footer 已能显示(TIER_I18N_KEYS + // 有 "30_day"),托盘也必须能显示——否则就是这条不变量要防的非对称:footer + // 能看到、托盘却空白。30_day 归入 "m" 月分组。见 #3651。 + let quota = make_quota("codex", true, vec![tier(TIER_THIRTY_DAY, 85.0)]); + let s = format_subscription_summary("a).expect("should format"); + assert!(s.contains("m85%"), "expected m85% in {s}"); + } + #[test] fn gemini_summary_emoji_reflects_highest_tier_including_lite() { // lite 是利用率最高的那条 → emoji 必须是红色,不能被 pro/flash 掩盖。 diff --git a/src/components/SubscriptionQuotaFooter.tsx b/src/components/SubscriptionQuotaFooter.tsx index 7d46bc376..e5ce8ecaf 100644 --- a/src/components/SubscriptionQuotaFooter.tsx +++ b/src/components/SubscriptionQuotaFooter.tsx @@ -27,6 +27,8 @@ export const TIER_I18N_KEYS: Record = { seven_day: "subscription.sevenDay", seven_day_opus: "subscription.sevenDayOpus", seven_day_sonnet: "subscription.sevenDaySonnet", + // Codex 免费方案的次要窗口是 30 天(付费方案为 7 天) + "30_day": "subscription.thirtyDay", // Gemini 模型分类 gemini_pro: "subscription.geminiPro", gemini_flash: "subscription.geminiFlash", diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index f6d011efd..debf8bb5e 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -2903,6 +2903,7 @@ "sevenDay": "7-Day", "sevenDayOpus": "7-Day (Opus)", "sevenDaySonnet": "7-Day (Sonnet)", + "thirtyDay": "30-Day", "geminiPro": "Pro", "geminiFlash": "Flash", "geminiFlashLite": "Flash Lite", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index cee1c1f85..124005185 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -2903,6 +2903,7 @@ "sevenDay": "7日間", "sevenDayOpus": "7日間(Opus)", "sevenDaySonnet": "7日間(Sonnet)", + "thirtyDay": "30日間", "geminiPro": "Pro", "geminiFlash": "Flash", "geminiFlashLite": "Flash Lite", diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json index db85f5027..0e6650b92 100644 --- a/src/i18n/locales/zh-TW.json +++ b/src/i18n/locales/zh-TW.json @@ -2875,6 +2875,7 @@ "sevenDay": "7 天", "sevenDayOpus": "7 天 (Opus)", "sevenDaySonnet": "7 天 (Sonnet)", + "thirtyDay": "30 天", "geminiPro": "Pro", "geminiFlash": "Flash", "geminiFlashLite": "Flash Lite", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 7e254de70..9c310a23b 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -2903,6 +2903,7 @@ "sevenDay": "7天", "sevenDayOpus": "7天(Opus)", "sevenDaySonnet": "7天(Sonnet)", + "thirtyDay": "30天", "geminiPro": "Pro", "geminiFlash": "Flash", "geminiFlashLite": "Flash Lite",