From 63640c7f6ad54893e546dd44d5e7629dbcbf90a1 Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Sat, 10 Oct 2026 12:18:47 +0700 Subject: [PATCH 1/7] Default the tray to % left for new installs Decision 7.2 of the Mac card rework. Settings::default() now has show_as_used false, so a missing settings file or one without the key shows remaining quota. A saved settings.json always carries show_as_used, so existing users keep their choice. Tray tests that assert used-view numbers now opt into that view explicitly. --- .../src-tauri/src/tray_bridge.rs | 29 +++++++++------ .../src-tauri/src/tray_presentation_tests.rs | 35 +++++++++++++------ rust/src/settings.rs | 4 +-- rust/src/settings/tests.rs | 28 +++++++++++++++ 4 files changed, 72 insertions(+), 24 deletions(-) diff --git a/apps/desktop-tauri/src-tauri/src/tray_bridge.rs b/apps/desktop-tauri/src-tauri/src/tray_bridge.rs index ce7e8bf1b22..e5a1752ad20 100644 --- a/apps/desktop-tauri/src-tauri/src/tray_bridge.rs +++ b/apps/desktop-tauri/src-tauri/src/tray_bridge.rs @@ -780,6 +780,13 @@ mod tests { fake_snapshot_with(id, display, used_percent, None, None, None) } + fn used_view_settings() -> Settings { + Settings { + show_as_used: true, + ..Settings::default() + } + } + fn fake_extra_window(percent: f64) -> crate::commands::NamedRateWindowSnapshot { crate::commands::NamedRateWindowSnapshot { id: "additional_budget".to_string(), @@ -919,7 +926,7 @@ mod tests { } #[test] fn selected_tray_percent_uses_cursor_extra_usage_cost() { - let mut settings = Settings::default(); + let mut settings = used_view_settings(); settings.set_provider_metric(ProviderId::Cursor, MetricPreference::ExtraUsage); let snapshot = fake_snapshot_with( "cursor", @@ -935,7 +942,7 @@ mod tests { } #[test] fn selected_tray_percent_tracks_extra_rate_window() { - let mut settings = Settings::default(); + let mut settings = used_view_settings(); settings.set_provider_metric(ProviderId::Copilot, MetricPreference::ExtraUsage); let mut snapshot = fake_snapshot("copilot", "Copilot", 20.0); snapshot.extra_rate_windows.push(fake_extra_window(42.0)); @@ -945,7 +952,7 @@ mod tests { } #[test] fn copilot_automatic_tracks_highest_extra_rate_window() { - let settings = Settings::default(); + let settings = used_view_settings(); let mut snapshot = fake_snapshot("copilot", "Copilot", 20.0); snapshot.extra_rate_windows.push(fake_extra_window(42.0)); let (primary, _) = selected_tray_percents(&snapshot, &settings); @@ -1033,7 +1040,7 @@ mod tests { } #[test] fn selected_tray_percent_falls_back_when_extra_usage_missing() { - let mut settings = Settings::default(); + let mut settings = used_view_settings(); settings.set_provider_metric(ProviderId::Cursor, MetricPreference::ExtraUsage); let snapshot = fake_snapshot_with("cursor", "Cursor", 10.0, Some(72.0), None, None); let (primary, _) = selected_tray_percents(&snapshot, &settings); @@ -1041,7 +1048,7 @@ mod tests { } #[test] fn single_meaningful_secondary_quota_uses_full_single_meter() { - let settings = Settings::default(); + let settings = used_view_settings(); let mut snapshot = fake_snapshot_with("claude", "Claude", 0.0, Some(42.0), None, None); snapshot.primary.is_informational = true; let (primary, secondary) = selected_tray_percents(&snapshot, &settings); @@ -1050,7 +1057,7 @@ mod tests { } #[test] fn selected_secondary_quota_is_not_duplicated_when_tertiary_is_meaningful() { - let settings = Settings::default(); + let settings = used_view_settings(); let mut snapshot = fake_snapshot_with("claude", "Claude", 0.0, Some(42.0), Some(30.0), None); snapshot.primary.is_informational = true; @@ -1060,7 +1067,7 @@ mod tests { } #[test] fn two_meaningful_quotas_keep_two_meter_layout() { - let mut settings = Settings::default(); + let mut settings = used_view_settings(); settings.set_provider_metric(ProviderId::Cursor, MetricPreference::Session); let snapshot = fake_snapshot_with("cursor", "Cursor", 15.0, Some(40.0), None, None); let (primary, secondary) = selected_tray_percents(&snapshot, &settings); @@ -1069,7 +1076,7 @@ mod tests { } #[test] fn informational_primary_skips_session_and_automatic_phantom_zero() { - let mut settings = Settings::default(); + let mut settings = used_view_settings(); settings.set_provider_metric(ProviderId::Claude, MetricPreference::Session); let mut snapshot = fake_snapshot_with("claude", "Claude", 0.0, Some(42.0), None, None); snapshot.primary.is_informational = true; @@ -1085,7 +1092,7 @@ mod tests { } #[test] fn claude_automatic_prefers_weekly_when_model_exhausted() { - let settings = Settings::default(); + let settings = used_view_settings(); let mut snapshot = fake_snapshot_with("claude", "Claude", 40.0, Some(22.0), None, None); snapshot.model_specific = Some(crate::commands::RateWindowSnapshot { used_percent: 100.0, @@ -1112,7 +1119,7 @@ mod tests { } #[test] fn automatic_prefers_exhausted_weekly_over_low_session() { - let settings = Settings::default(); + let settings = used_view_settings(); let snapshot = fake_snapshot_with("codex", "Codex", 20.0, Some(100.0), None, None); let (primary, _) = selected_tray_percents(&snapshot, &settings); assert_eq!(primary, 100.0); @@ -1124,7 +1131,7 @@ mod tests { } #[test] fn automatic_picks_highest_among_model_and_extra_windows() { - let settings = Settings::default(); + let settings = used_view_settings(); let mut snapshot = fake_snapshot_with("gemini", "Gemini", 10.0, Some(30.0), Some(40.0), None); snapshot.model_specific = Some(crate::commands::RateWindowSnapshot { diff --git a/apps/desktop-tauri/src-tauri/src/tray_presentation_tests.rs b/apps/desktop-tauri/src-tauri/src/tray_presentation_tests.rs index e443735870a..17887887c04 100644 --- a/apps/desktop-tauri/src-tauri/src/tray_presentation_tests.rs +++ b/apps/desktop-tauri/src-tauri/src/tray_presentation_tests.rs @@ -83,6 +83,7 @@ fn single_plan_uses_highest_provider_for_icon_and_summary() { let settings = Settings { tray_icon_mode: TrayIconMode::Single, menu_bar_shows_highest_usage: true, + show_as_used: true, ..Settings::default() }; let snapshots = vec![ @@ -157,6 +158,7 @@ fn stacked_plan_resolves_distinct_preferences_once() { tray_icon_mode: TrayIconMode::Stacked, stacked_tray_top_provider: Some("claude".to_string()), stacked_tray_bottom_provider: Some("codex".to_string()), + show_as_used: true, ..Settings::default() }; let snapshots = vec![ @@ -211,6 +213,7 @@ fn stacked_plan_falls_back_around_stale_and_duplicate_preferences() { tray_icon_mode: TrayIconMode::Stacked, stacked_tray_top_provider: Some("missing".to_string()), stacked_tray_bottom_provider: Some("claude".to_string()), + show_as_used: true, ..Settings::default() }; let snapshots = vec![ @@ -236,6 +239,7 @@ fn stacked_plan_falls_back_around_stale_and_duplicate_preferences() { fn one_provider_stacked_mode_falls_back_to_single_provider_bars() { let settings = Settings { tray_icon_mode: TrayIconMode::Stacked, + show_as_used: true, ..Settings::default() }; let snapshots = vec![fake_snapshot_with( @@ -265,6 +269,7 @@ fn one_healthy_provider_never_uses_stacked_renderer() { let settings = Settings { tray_icon_mode: TrayIconMode::Stacked, menu_bar_shows_percent: true, + show_as_used: true, ..Settings::default() }; let healthy = fake_snapshot("codex", "Codex", 30.0); @@ -341,6 +346,7 @@ fn render_icon_delegates_to_resolved_stacked_renderer() { tray_icon_mode: TrayIconMode::Stacked, stacked_tray_top_provider: Some("claude".to_string()), stacked_tray_bottom_provider: Some("codex".to_string()), + show_as_used: true, ..Settings::default() }; let snapshots = vec![ @@ -362,6 +368,13 @@ fn codex_headline_skips_informational_primary() { assert_eq!(codex_lane_headline_window(&snapshot).used_percent, 25.0); } +fn used_view_settings() -> Settings { + Settings { + show_as_used: true, + ..Settings::default() + } +} + fn fake_extra_window(percent: f64) -> crate::commands::NamedRateWindowSnapshot { crate::commands::NamedRateWindowSnapshot { id: "additional_budget".to_string(), @@ -388,7 +401,7 @@ fn fake_extra_window(percent: f64) -> crate::commands::NamedRateWindowSnapshot { #[test] fn selected_tray_percent_uses_cursor_extra_usage_cost() { - let mut settings = Settings::default(); + let mut settings = used_view_settings(); settings.set_provider_metric(ProviderId::Cursor, MetricPreference::ExtraUsage); let snapshot = fake_snapshot_with( "cursor", @@ -407,7 +420,7 @@ fn selected_tray_percent_uses_cursor_extra_usage_cost() { #[test] fn selected_tray_percent_tracks_extra_rate_window() { - let mut settings = Settings::default(); + let mut settings = used_view_settings(); settings.set_provider_metric(ProviderId::Copilot, MetricPreference::ExtraUsage); let mut snapshot = fake_snapshot("copilot", "Copilot", 20.0); snapshot.extra_rate_windows.push(fake_extra_window(42.0)); @@ -420,7 +433,7 @@ fn selected_tray_percent_tracks_extra_rate_window() { #[test] fn copilot_automatic_tracks_highest_extra_rate_window() { - let settings = Settings::default(); + let settings = used_view_settings(); let mut snapshot = fake_snapshot("copilot", "Copilot", 20.0); snapshot.extra_rate_windows.push(fake_extra_window(42.0)); @@ -523,7 +536,7 @@ fn missing_automatic_window_does_not_look_like_available_remaining_progress() { #[test] fn selected_tray_percent_falls_back_when_extra_usage_missing() { - let mut settings = Settings::default(); + let mut settings = used_view_settings(); settings.set_provider_metric(ProviderId::Cursor, MetricPreference::ExtraUsage); let snapshot = fake_snapshot_with("cursor", "Cursor", 10.0, Some(72.0), None, None); @@ -534,7 +547,7 @@ fn selected_tray_percent_falls_back_when_extra_usage_missing() { #[test] fn single_meaningful_secondary_quota_uses_full_single_meter() { - let settings = Settings::default(); + let settings = used_view_settings(); let mut snapshot = fake_snapshot_with("claude", "Claude", 0.0, Some(42.0), None, None); snapshot.primary.is_informational = true; @@ -546,7 +559,7 @@ fn single_meaningful_secondary_quota_uses_full_single_meter() { #[test] fn selected_secondary_quota_is_not_duplicated_when_tertiary_is_meaningful() { - let settings = Settings::default(); + let settings = used_view_settings(); let mut snapshot = fake_snapshot_with("claude", "Claude", 0.0, Some(42.0), Some(30.0), None); snapshot.primary.is_informational = true; @@ -558,7 +571,7 @@ fn selected_secondary_quota_is_not_duplicated_when_tertiary_is_meaningful() { #[test] fn two_meaningful_quotas_keep_two_meter_layout() { - let mut settings = Settings::default(); + let mut settings = used_view_settings(); settings.set_provider_metric(ProviderId::Cursor, MetricPreference::Session); let snapshot = fake_snapshot_with("cursor", "Cursor", 15.0, Some(40.0), None, None); @@ -570,7 +583,7 @@ fn two_meaningful_quotas_keep_two_meter_layout() { #[test] fn informational_primary_skips_session_and_automatic_phantom_zero() { - let mut settings = Settings::default(); + let mut settings = used_view_settings(); settings.set_provider_metric(ProviderId::Claude, MetricPreference::Session); let mut snapshot = fake_snapshot_with("claude", "Claude", 0.0, Some(42.0), None, None); snapshot.primary.is_informational = true; @@ -589,7 +602,7 @@ fn informational_primary_skips_session_and_automatic_phantom_zero() { #[test] fn claude_automatic_prefers_weekly_when_model_exhausted() { - let settings = Settings::default(); + let settings = used_view_settings(); let mut snapshot = fake_snapshot_with("claude", "Claude", 40.0, Some(22.0), None, None); snapshot.model_specific = Some(crate::commands::RateWindowSnapshot { used_percent: 100.0, @@ -619,7 +632,7 @@ fn claude_automatic_prefers_weekly_when_model_exhausted() { #[test] fn automatic_prefers_exhausted_weekly_over_low_session() { - let settings = Settings::default(); + let settings = used_view_settings(); let snapshot = fake_snapshot_with("codex", "Codex", 20.0, Some(100.0), None, None); let (primary, _) = selected_tray_percents(&snapshot, &settings); @@ -634,7 +647,7 @@ fn automatic_prefers_exhausted_weekly_over_low_session() { #[test] fn automatic_picks_highest_among_model_and_extra_windows() { - let settings = Settings::default(); + let settings = used_view_settings(); let mut snapshot = fake_snapshot_with("gemini", "Gemini", 10.0, Some(30.0), Some(40.0), None); snapshot.model_specific = Some(crate::commands::RateWindowSnapshot { used_percent: 55.0, diff --git a/rust/src/settings.rs b/rust/src/settings.rs index 22d361fce2f..fe22e0ef7e0 100755 --- a/rust/src/settings.rs +++ b/rust/src/settings.rs @@ -605,8 +605,8 @@ impl Default for Settings { menu_bar_shows_highest_usage: false, menu_bar_shows_percent: false, menu_bar_color_pace: false, - show_as_used: true, // Show as "used" by default - enable_animations: true, // Animations enabled by default + show_as_used: false, // "% left"; a saved file keeps its own value + enable_animations: true, // Animations enabled by default reset_time_relative: true, // Show relative times by default show_reset_when_exhausted: false, predictive_pace_warning_enabled: false, diff --git a/rust/src/settings/tests.rs b/rust/src/settings/tests.rs index f0a44605ae0..8803fca4cd6 100644 --- a/rust/src/settings/tests.rs +++ b/rust/src/settings/tests.rs @@ -1491,3 +1491,31 @@ fn cookie_denial_round_trips_through_settings_persistence() { assert_eq!(loaded.cookie_source(ProviderId::Codex), "off"); assert!(!loaded.openai_web_extras(ProviderId::Codex)); } + +/// Decision 7.2 of the Mac card rework: "% left" is the default only where no +/// choice was ever saved, so a new install and a settings file without the key +/// both show remaining quota. +#[test] +fn show_as_used_defaults_to_left_without_a_saved_value() { + let dir = tempfile::tempdir().unwrap(); + let new_install = dir.path().join("missing.json"); + let without_key = dir.path().join("settings.json"); + std::fs::write(&without_key, r#"{ "enabled_providers": ["claude"] }"#).unwrap(); + + assert!(!Settings::load_from_path(Some(&new_install)).show_as_used); + assert!(!Settings::load_from_path(Some(&without_key)).show_as_used); +} + +/// Existing users keep the "% used" choice their settings file already holds. +#[test] +fn show_as_used_keeps_a_saved_used_preference() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("settings.json"); + std::fs::write( + &path, + r#"{ "enabled_providers": ["claude"], "show_as_used": true }"#, + ) + .unwrap(); + + assert!(Settings::load_from_path(Some(&path)).show_as_used); +} From 1b0ea3f5de81d61f71dab2c3d4d687cef0b2cae6 Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Sat, 10 Oct 2026 12:19:06 +0700 Subject: [PATCH 2/7] Add per-window pace to rate window snapshots Every rate window in a provider snapshot now carries its own pace (stage, signed delta, expected and actual used percent, ETA, lasts-until-reset), behind the existing pace_authoritative gate. The session lane defaults to a 300-minute window and every other lane to 10080 minutes, matching the Mac UsagePaceText defaults. The bridge type mirrors it so the card can draw the pace tip and the pace meta line for each window. --- .../src-tauri/src/auto_resume_tests.rs | 1 + .../src-tauri/src/commands/bridge.rs | 44 +++-- .../src-tauri/src/commands/bridge/pace.rs | 186 ++++++++++++++++++ .../src-tauri/src/commands/providers.rs | 1 + .../src/commands/providers/reset_backfill.rs | 1 + apps/desktop-tauri/src-tauri/src/powertoys.rs | 1 + .../src-tauri/src/tray_bridge.rs | 6 + .../src-tauri/src/tray_presentation_tests.rs | 4 + .../src-tauri/src/usage_metric.rs | 1 + apps/desktop-tauri/src/types/bridge.ts | 23 ++- 10 files changed, 253 insertions(+), 15 deletions(-) diff --git a/apps/desktop-tauri/src-tauri/src/auto_resume_tests.rs b/apps/desktop-tauri/src-tauri/src/auto_resume_tests.rs index b8eadc0275f..37183b3a7dd 100644 --- a/apps/desktop-tauri/src-tauri/src/auto_resume_tests.rs +++ b/apps/desktop-tauri/src-tauri/src/auto_resume_tests.rs @@ -42,6 +42,7 @@ mod tests { reserve_description: None, reserve_will_last_to_reset: false, reserve_eta_seconds: None, + pace: None, monthly_limit_block: None, description_is_detail: false, } diff --git a/apps/desktop-tauri/src-tauri/src/commands/bridge.rs b/apps/desktop-tauri/src-tauri/src/commands/bridge.rs index b3839776e08..09ed8bc7900 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/bridge.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/bridge.rs @@ -41,6 +41,8 @@ pub struct RateWindowSnapshot { pub reserve_will_last_to_reset: bool, #[serde(default)] pub reserve_eta_seconds: Option, + #[serde(default)] + pub pace: Option, /// Set while a longer exhausted pool (Kimi's monthly membership) blocks /// this window; presentation only, the raw percentages above stay as is. #[serde(default)] @@ -67,6 +69,7 @@ impl RateWindowSnapshot { reserve_description: None, reserve_will_last_to_reset: false, reserve_eta_seconds: None, + pace: None, monthly_limit_block: None, description_is_detail: rw.description_is_detail, } @@ -404,7 +407,8 @@ impl ProviderUsageSnapshot { }); let primary_pace = primary_pace.flatten(); - let blocked = BlockedWindows::evaluate(id, usage, chrono::Utc::now()); + let now = chrono::Utc::now(); + let blocked = BlockedWindows::evaluate(id, usage, now); let pace = primary_pace.as_ref().map(|p| PaceSnapshot { stage: pace::stage_str(p.stage).to_string(), delta_percent: p.delta_percent, @@ -424,12 +428,22 @@ impl ProviderUsageSnapshot { }); let secondary_pace = secondary_pace.flatten(); - let primary_snap = RateWindowSnapshot::from_rate_window(&usage.primary) - .with_quota_block(blocked.primary, &blocked); + let lane = + |window: &RateWindow, is_blocked: bool, default_minutes: u32| RateWindowSnapshot { + pace: allows_pace + .then(|| pace::WindowPaceSnapshot::for_window(window, default_minutes, now)) + .flatten(), + ..RateWindowSnapshot::from_rate_window(window) + .with_quota_block(is_blocked, &blocked) + }; + let primary_snap = lane( + &usage.primary, + blocked.primary, + pace::SESSION_PACE_WINDOW_MINUTES, + ); let secondary_snap = usage.secondary.as_ref().map(|sw| { - let mut s = RateWindowSnapshot::from_rate_window(sw) - .with_quota_block(blocked.secondary, &blocked); + let mut s = lane(sw, blocked.secondary, pace::WEEKLY_PACE_WINDOW_MINUTES); if let Some(ref p) = secondary_pace { s = s.with_pace_reserve(p); } @@ -471,13 +485,14 @@ impl ProviderUsageSnapshot { .clone() .unwrap_or_else(|| metadata.weekly_label.to_string()) }), - model_specific: usage.model_specific.as_ref().map(|w| { - RateWindowSnapshot::from_rate_window(w) - .with_quota_block(blocked.model_specific, &blocked) - }), - tertiary: usage.tertiary.as_ref().map(|w| { - RateWindowSnapshot::from_rate_window(w).with_quota_block(blocked.tertiary, &blocked) - }), + model_specific: usage + .model_specific + .as_ref() + .map(|w| lane(w, blocked.model_specific, pace::WEEKLY_PACE_WINDOW_MINUTES)), + tertiary: usage + .tertiary + .as_ref() + .map(|w| lane(w, blocked.tertiary, pace::WEEKLY_PACE_WINDOW_MINUTES)), // F5 (upstream 0.48.0): label the tertiary lane by its duration cadence // so surfaces (MenuCard, CLI, tray) can show "Monthly" instead of the // generic "DetailWindowTertiary" slot key. @@ -496,8 +511,7 @@ impl ProviderUsageSnapshot { .map(|(extra, &is_blocked)| NamedRateWindowSnapshot { id: extra.id.clone(), title: extra.title.clone(), - window: RateWindowSnapshot::from_rate_window(&extra.window) - .with_quota_block(is_blocked, &blocked), + window: lane(&extra.window, is_blocked, pace::WEEKLY_PACE_WINDOW_MINUTES), fallback_lane: extra.fallback_lane, icon_fallback: extra.icon_fallback, }) @@ -600,6 +614,7 @@ impl ProviderUsageSnapshot { reserve_description: None, reserve_will_last_to_reset: false, reserve_eta_seconds: None, + pace: None, monthly_limit_block: None, description_is_detail: false, }, @@ -1157,6 +1172,7 @@ mod tests { reserve_description: None, reserve_will_last_to_reset: false, reserve_eta_seconds: None, + pace: None, monthly_limit_block: None, description_is_detail: false, } diff --git a/apps/desktop-tauri/src-tauri/src/commands/bridge/pace.rs b/apps/desktop-tauri/src-tauri/src/commands/bridge/pace.rs index 7930c362939..e9b847a5e4a 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/bridge/pace.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/bridge/pace.rs @@ -1,3 +1,11 @@ +use super::*; +use chrono::{DateTime, Utc}; + +/// Default length of a session lane without `window_minutes` (Mac `UsagePaceText`). +pub(crate) const SESSION_PACE_WINDOW_MINUTES: u32 = 300; +/// Default length of every other lane without `window_minutes`. +pub(crate) const WEEKLY_PACE_WINDOW_MINUTES: u32 = 10_080; + pub(crate) fn stage_str(stage: codexbar::core::PaceStage) -> &'static str { use codexbar::core::PaceStage; match stage { @@ -10,3 +18,181 @@ pub(crate) fn stage_str(stage: codexbar::core::PaceStage) -> &'static str { PaceStage::FarBehind => "far_behind", } } + +/// Pace of one rate window against the time elapsed in it. +/// `delta_percent` is actual minus expected used percent, so a positive delta +/// is a deficit and a negative one a reserve. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WindowPaceSnapshot { + pub stage: String, + pub delta_percent: f64, + pub expected_used_percent: f64, + pub actual_used_percent: f64, + #[serde(default)] + pub eta_seconds: Option, + #[serde(default)] + pub will_last_to_reset: bool, +} + +impl WindowPaceSnapshot { + pub(crate) fn for_window( + window: &RateWindow, + default_window_minutes: u32, + now: DateTime, + ) -> Option { + if window.is_informational { + return None; + } + let pace = codexbar::core::UsagePace::weekly(window, Some(now), default_window_minutes)?; + Some(Self { + stage: stage_str(pace.stage).to_string(), + delta_percent: pace.delta_percent, + expected_used_percent: pace.expected_used_percent, + actual_used_percent: pace.actual_used_percent, + eta_seconds: pace.eta_seconds, + will_last_to_reset: pace.will_last_to_reset, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::{Duration, TimeZone}; + use codexbar::core::UsageSnapshot; + + fn fixed_now() -> DateTime { + Utc.with_ymd_and_hms(2026, 10, 10, 12, 0, 0).unwrap() + } + + fn resetting_in( + now: DateTime, + used: f64, + window: Option, + minutes: i64, + ) -> RateWindow { + RateWindow::with_details(used, window, Some(now + Duration::minutes(minutes)), None) + } + + fn snapshot(usage: UsageSnapshot, pace_authoritative: bool) -> ProviderUsageSnapshot { + let metadata = instantiate_provider(ProviderId::Claude).metadata().clone(); + let mut result = ProviderFetchResult::new(usage, "web"); + if !pace_authoritative { + result = result.with_non_authoritative_pace(); + } + ProviderUsageSnapshot::from_fetch_result(ProviderId::Claude, &metadata, &result, None) + } + + fn expected_used(window: &RateWindowSnapshot) -> f64 { + window + .pace + .as_ref() + .expect("lane pace") + .expected_used_percent + } + + #[test] + fn reserve_is_a_negative_delta_that_lasts_to_reset() { + let window = resetting_in(fixed_now(), 30.0, Some(10_080), 5_040); + + assert_eq!( + WindowPaceSnapshot::for_window(&window, WEEKLY_PACE_WINDOW_MINUTES, fixed_now()), + Some(WindowPaceSnapshot { + stage: "far_behind".to_string(), + delta_percent: -20.0, + expected_used_percent: 50.0, + actual_used_percent: 30.0, + eta_seconds: None, + will_last_to_reset: true, + }) + ); + } + + #[test] + fn deficit_is_a_positive_delta_with_an_eta() { + let window = resetting_in(fixed_now(), 55.0, Some(10_080), 5_040); + + let pace = WindowPaceSnapshot::for_window(&window, WEEKLY_PACE_WINDOW_MINUTES, fixed_now()) + .expect("pace"); + + assert_eq!(pace.stage, "slightly_ahead"); + assert_eq!(pace.delta_percent, 5.0); + assert!(!pace.will_last_to_reset); + assert!((pace.eta_seconds.expect("eta") - 247_418.18).abs() < 0.01); + } + + #[test] + fn informational_window_has_no_pace() { + let mut window = resetting_in(fixed_now(), 30.0, Some(10_080), 5_040); + window.is_informational = true; + + assert_eq!( + WindowPaceSnapshot::for_window(&window, WEEKLY_PACE_WINDOW_MINUTES, fixed_now()), + None + ); + } + + #[test] + fn session_lane_defaults_to_a_300_minute_window() { + let primary = resetting_in(Utc::now(), 60.0, None, 150); + + let snap = snapshot(UsageSnapshot::new(primary), true); + + assert!((expected_used(&snap.primary) - 50.0).abs() < 0.1); + assert_eq!(snap.primary.pace.as_ref().unwrap().stage, "ahead"); + } + + #[test] + fn weekly_lanes_default_to_a_10080_minute_window() { + let now = Utc::now(); + let mut usage = UsageSnapshot::new(RateWindow::new(10.0)) + .with_secondary(resetting_in(now, 40.0, None, 5_040)) + .with_tertiary(resetting_in(now, 45.0, None, 5_040)) + .with_extra_rate_window("sonnet", "Sonnet", resetting_in(now, 50.0, None, 5_040)); + usage.model_specific = Some(resetting_in(now, 55.0, None, 5_040)); + + let snap = snapshot(usage, true); + + assert!((expected_used(snap.secondary.as_ref().unwrap()) - 50.0).abs() < 0.1); + assert!((expected_used(snap.tertiary.as_ref().unwrap()) - 50.0).abs() < 0.1); + assert!((expected_used(snap.model_specific.as_ref().unwrap()) - 50.0).abs() < 0.1); + assert!((expected_used(&snap.extra_rate_windows[0].window) - 50.0).abs() < 0.1); + } + + #[test] + fn informational_primary_keeps_the_weekly_lane_pace() { + let usage = UsageSnapshot::new(RateWindow::informational("No session limit")) + .with_secondary(resetting_in(Utc::now(), 30.0, Some(10_080), 5_040)); + + let snap = snapshot(usage, true); + + assert_eq!(snap.primary.pace, None); + let weekly = snap.secondary.as_ref().unwrap().pace.as_ref().unwrap(); + assert_eq!(weekly.stage, "far_behind"); + assert!(weekly.will_last_to_reset); + } + + #[test] + fn non_authoritative_pace_leaves_every_lane_empty() { + let now = Utc::now(); + let usage = UsageSnapshot::new(resetting_in(now, 60.0, Some(300), 150)) + .with_secondary(resetting_in(now, 30.0, Some(10_080), 5_040)); + + let snap = snapshot(usage, false); + + assert_eq!(snap.primary.pace, None); + assert_eq!(snap.secondary.as_ref().unwrap().pace, None); + } + + #[test] + fn window_pace_serializes_in_camel_case() { + let window = resetting_in(fixed_now(), 30.0, Some(10_080), 5_040); + let pace = WindowPaceSnapshot::for_window(&window, WEEKLY_PACE_WINDOW_MINUTES, fixed_now()); + + assert_eq!( + serde_json::to_string(&pace).unwrap(), + r#"{"stage":"far_behind","deltaPercent":-20.0,"expectedUsedPercent":50.0,"actualUsedPercent":30.0,"etaSeconds":null,"willLastToReset":true}"# + ); + } +} diff --git a/apps/desktop-tauri/src-tauri/src/commands/providers.rs b/apps/desktop-tauri/src-tauri/src/commands/providers.rs index 14457ae57d1..01a56ea6420 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/providers.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/providers.rs @@ -1618,6 +1618,7 @@ mod reset_backfill_tests { reserve_description: None, reserve_will_last_to_reset: false, reserve_eta_seconds: None, + pace: None, description_is_detail: false, monthly_limit_block: None, } diff --git a/apps/desktop-tauri/src-tauri/src/commands/providers/reset_backfill.rs b/apps/desktop-tauri/src-tauri/src/commands/providers/reset_backfill.rs index 1311db6344c..df74ed2ba85 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/providers/reset_backfill.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/providers/reset_backfill.rs @@ -110,6 +110,7 @@ mod tests { reserve_description: None, reserve_will_last_to_reset: false, reserve_eta_seconds: None, + pace: None, monthly_limit_block: None, description_is_detail: false, } diff --git a/apps/desktop-tauri/src-tauri/src/powertoys.rs b/apps/desktop-tauri/src-tauri/src/powertoys.rs index 305abe3dd9b..b00be948827 100644 --- a/apps/desktop-tauri/src-tauri/src/powertoys.rs +++ b/apps/desktop-tauri/src-tauri/src/powertoys.rs @@ -181,6 +181,7 @@ mod tests { reserve_percent: None, reserve_description: None, reserve_eta_seconds: None, + pace: None, monthly_limit_block: None, description_is_detail: false, reserve_will_last_to_reset: false, diff --git a/apps/desktop-tauri/src-tauri/src/tray_bridge.rs b/apps/desktop-tauri/src-tauri/src/tray_bridge.rs index e5a1752ad20..6b7175e5fe0 100644 --- a/apps/desktop-tauri/src-tauri/src/tray_bridge.rs +++ b/apps/desktop-tauri/src-tauri/src/tray_bridge.rs @@ -696,6 +696,7 @@ mod tests { reserve_description: None, reserve_will_last_to_reset: false, reserve_eta_seconds: None, + pace: None, monthly_limit_block: None, description_is_detail: false, }, @@ -712,6 +713,7 @@ mod tests { reserve_description: None, reserve_will_last_to_reset: false, reserve_eta_seconds: None, + pace: None, monthly_limit_block: None, description_is_detail: false, }), @@ -729,6 +731,7 @@ mod tests { reserve_description: None, reserve_will_last_to_reset: false, reserve_eta_seconds: None, + pace: None, monthly_limit_block: None, description_is_detail: false, }), @@ -805,6 +808,7 @@ mod tests { reserve_description: None, reserve_will_last_to_reset: false, reserve_eta_seconds: None, + pace: None, description_is_detail: false, monthly_limit_block: None, }, @@ -1106,6 +1110,7 @@ mod tests { reserve_description: None, reserve_will_last_to_reset: false, reserve_eta_seconds: None, + pace: None, description_is_detail: false, monthly_limit_block: None, }); @@ -1146,6 +1151,7 @@ mod tests { reserve_description: None, reserve_will_last_to_reset: false, reserve_eta_seconds: None, + pace: None, description_is_detail: false, monthly_limit_block: None, }); diff --git a/apps/desktop-tauri/src-tauri/src/tray_presentation_tests.rs b/apps/desktop-tauri/src-tauri/src/tray_presentation_tests.rs index 17887887c04..8a606b7e9ba 100644 --- a/apps/desktop-tauri/src-tauri/src/tray_presentation_tests.rs +++ b/apps/desktop-tauri/src-tauri/src/tray_presentation_tests.rs @@ -25,6 +25,7 @@ fn fake_snapshot_with( reserve_description: None, reserve_will_last_to_reset: false, reserve_eta_seconds: None, + pace: None, monthly_limit_block: None, description_is_detail: false, }; @@ -393,6 +394,7 @@ fn fake_extra_window(percent: f64) -> crate::commands::NamedRateWindowSnapshot { reserve_description: None, reserve_will_last_to_reset: false, reserve_eta_seconds: None, + pace: None, monthly_limit_block: None, description_is_detail: false, }, @@ -616,6 +618,7 @@ fn claude_automatic_prefers_weekly_when_model_exhausted() { reserve_description: None, reserve_will_last_to_reset: false, reserve_eta_seconds: None, + pace: None, monthly_limit_block: None, description_is_detail: false, }); @@ -661,6 +664,7 @@ fn automatic_picks_highest_among_model_and_extra_windows() { reserve_description: None, reserve_will_last_to_reset: false, reserve_eta_seconds: None, + pace: None, monthly_limit_block: None, description_is_detail: false, }); diff --git a/apps/desktop-tauri/src-tauri/src/usage_metric.rs b/apps/desktop-tauri/src-tauri/src/usage_metric.rs index cab5254a13e..2266daa4e42 100644 --- a/apps/desktop-tauri/src-tauri/src/usage_metric.rs +++ b/apps/desktop-tauri/src-tauri/src/usage_metric.rs @@ -401,6 +401,7 @@ fn derived_window(used_percent: f64, resets_at: Option) -> RateWindowSna reserve_description: None, reserve_will_last_to_reset: false, reserve_eta_seconds: None, + pace: None, monthly_limit_block: None, description_is_detail: false, } diff --git a/apps/desktop-tauri/src/types/bridge.ts b/apps/desktop-tauri/src/types/bridge.ts index fdbe34554cc..dbb975a02d8 100644 --- a/apps/desktop-tauri/src/types/bridge.ts +++ b/apps/desktop-tauri/src/types/bridge.ts @@ -618,6 +618,8 @@ export interface RateWindowSnapshot { reserveDescription: string | null; reserveWillLastToReset?: boolean; reserveEtaSeconds?: number | null; + /** Pace of this window; absent when the provider's pace is not authoritative or the window is informational. */ + pace?: WindowPaceSnapshot | null; /** Set while a longer exhausted pool blocks this window; raw percentages stay the provider data. */ monthlyLimitBlock?: MonthlyLimitBlock | null; } @@ -649,8 +651,27 @@ export interface CostSnapshotBridge { alwaysVisible?: boolean; } +export type PaceStage = + | "on_track" + | "slightly_ahead" + | "ahead" + | "far_ahead" + | "slightly_behind" + | "behind" + | "far_behind"; + +/** Pace of one rate window. A positive `deltaPercent` is a deficit, a negative one a reserve. */ +export interface WindowPaceSnapshot { + stage: PaceStage; + deltaPercent: number; + expectedUsedPercent: number; + actualUsedPercent: number; + etaSeconds?: number | null; + willLastToReset: boolean; +} + export interface PaceSnapshot { - stage: "on_track" | "slightly_ahead" | "ahead" | "far_ahead" | "slightly_behind" | "behind" | "far_behind"; + stage: PaceStage; deltaPercent: number; willLastToReset: boolean; etaSeconds: number | null; From 8cc5467b7cbe8660d4979d0e6edce37459e46a28 Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Sat, 10 Oct 2026 13:49:59 +0700 Subject: [PATCH 3/7] Add Fluent strings for the pace line and usage bar The metric row's pace line and usage bar need their own strings. They are on pace, N% in reserve, N% in deficit, lasts until reset, runs out in, projected empty in, the bar's quota-warning and work-day names for screen readers, and the Mac's day-plus-minute and tomorrow reset forms. Each language takes its value from the Mac 0.70 Localizable.strings. --- apps/desktop-tauri/src/i18n/keys.ts | 13 +++++++++++++ rust/src/locale.rs | 13 +++++++++++++ rust/src/locale/en-US.ftl | 13 +++++++++++++ rust/src/locale/es-MX.ftl | 13 +++++++++++++ rust/src/locale/ja-JP.ftl | 13 +++++++++++++ rust/src/locale/ko-KR.ftl | 13 +++++++++++++ rust/src/locale/pt-BR.ftl | 13 +++++++++++++ rust/src/locale/ru-RU.ftl | 13 +++++++++++++ rust/src/locale/tr-TR.ftl | 13 +++++++++++++ rust/src/locale/uk-UA.ftl | 13 +++++++++++++ rust/src/locale/zh-CN.ftl | 13 +++++++++++++ rust/src/locale/zh-TW.ftl | 13 +++++++++++++ 12 files changed, 156 insertions(+) diff --git a/apps/desktop-tauri/src/i18n/keys.ts b/apps/desktop-tauri/src/i18n/keys.ts index f64b59e2c3a..e9a0ee213f9 100644 --- a/apps/desktop-tauri/src/i18n/keys.ts +++ b/apps/desktop-tauri/src/i18n/keys.ts @@ -236,11 +236,22 @@ export const ALL_LOCALE_KEYS = [ "ActionBuyCredits", "PaceOnTrack", "PaceBehind", + "PaceOnPace", + "PaceInReserve", + "PaceInDeficit", + "PaceLastsUntilReset", + "PaceRunsOutIn", + "PaceRunsOutNow", + "PaceProjectedEmptyIn", + "PaceProjectedEmptyNow", + "UsageBarQuotaWarnings", + "UsageBarWorkDays", "MetricResetsIn", "SectionUsageBreakdown", "SectionCost", "ResetInProgress", "TomorrowAt", + "ResetTomorrowAt", "UsedPercent", "RemainingPercent", "RemainingAmount", @@ -293,6 +304,7 @@ export const ALL_LOCALE_KEYS = [ "ResetsInDaysHours", "ResetsInHoursMinutes", "ResetsInMinutes", + "ResetsInDaysMinutes", "NextExpiresInDaysHours", "NextExpiresInHoursMinutes", "NextExpiresInMinutes", @@ -1453,6 +1465,7 @@ export const ALL_LOCALE_KEYS = [ "ProviderTextExpiresIn", "ProviderTextCycleEndsIn", "DurationDaysHours", + "DurationDaysMinutes", "DurationHoursMinutes", "DurationDays", "DurationHours", diff --git a/rust/src/locale.rs b/rust/src/locale.rs index eeafc73f0f3..ea078edab91 100644 --- a/rust/src/locale.rs +++ b/rust/src/locale.rs @@ -462,6 +462,16 @@ locale_keys! { // Main popup - Pace status PaceOnTrack, PaceBehind, + PaceOnPace, + PaceInReserve, + PaceInDeficit, + PaceLastsUntilReset, + PaceRunsOutIn, + PaceRunsOutNow, + PaceProjectedEmptyIn, + PaceProjectedEmptyNow, + UsageBarQuotaWarnings, + UsageBarWorkDays, // Main popup - Reset prefix MetricResetsIn, @@ -473,6 +483,7 @@ locale_keys! { // Main popup - Usage/reset labels ResetInProgress, TomorrowAt, + ResetTomorrowAt, UsedPercent, RemainingPercent, RemainingAmount, @@ -533,6 +544,7 @@ locale_keys! { ResetsInDaysHours, ResetsInHoursMinutes, ResetsInMinutes, + ResetsInDaysMinutes, NextExpiresInDaysHours, NextExpiresInHoursMinutes, NextExpiresInMinutes, @@ -1743,6 +1755,7 @@ locale_keys! { ProviderTextExpiresIn, ProviderTextCycleEndsIn, DurationDaysHours, + DurationDaysMinutes, DurationHoursMinutes, DurationDays, DurationHours, diff --git a/rust/src/locale/en-US.ftl b/rust/src/locale/en-US.ftl index 4d60d79956e..f9b820baec7 100644 --- a/rust/src/locale/en-US.ftl +++ b/rust/src/locale/en-US.ftl @@ -223,11 +223,22 @@ ActionCopyError = Copy error ActionBuyCredits = Buy credits... PaceOnTrack = On track PaceBehind = Behind +PaceOnPace = On pace +PaceInReserve = { "{}" }% in reserve +PaceInDeficit = { "{}" }% in deficit +PaceLastsUntilReset = Lasts until reset +PaceRunsOutIn = Runs out in { "{}" } +PaceRunsOutNow = Runs out now +PaceProjectedEmptyIn = Projected empty in { "{}" } +PaceProjectedEmptyNow = Projected empty now +UsageBarQuotaWarnings = Quota warnings +UsageBarWorkDays = Work days MetricResetsIn = Resets in SectionUsageBreakdown = Usage Breakdown SectionCost = Cost ResetInProgress = Resetting... TomorrowAt = Tomorrow at { "{}" } +ResetTomorrowAt = tomorrow, { "{}" } UsedPercent = { "{:.0}" }% used RemainingPercent = { "{:.0}" }% remaining RemainingAmount = { "{:.2}" } remaining @@ -280,6 +291,7 @@ ResetsInShort = Resets in ResetsInDaysHours = Resets in { "{}" }d { "{}" }h ResetsInHoursMinutes = Resets in { "{}" }h { "{}" }m ResetsInMinutes = Resets in { "{}" }m +ResetsInDaysMinutes = Resets in { "{}" }d { "{}" }m NextExpiresInDaysHours = Next expires in { "{}" }d { "{}" }h NextExpiresInHoursMinutes = Next expires in { "{}" }h { "{}" }m NextExpiresInMinutes = Next expires in { "{}" }m @@ -1413,6 +1425,7 @@ ProviderTextRenewsIn = Renews in { "{}" } ProviderTextExpiresIn = Expires in { "{}" } ProviderTextCycleEndsIn = Cycle ends in { "{}" } DurationDaysHours = { "{}" }d { "{}" }h +DurationDaysMinutes = { "{}" }d { "{}" }m DurationHoursMinutes = { "{}" }h { "{}" }m DurationDays = { "{}" }d DurationHours = { "{}" }h diff --git a/rust/src/locale/es-MX.ftl b/rust/src/locale/es-MX.ftl index af7de2d65b4..335cdbfda41 100644 --- a/rust/src/locale/es-MX.ftl +++ b/rust/src/locale/es-MX.ftl @@ -196,11 +196,22 @@ ActionCopyError = Copiar error ActionBuyCredits = Comprar créditos... PaceOnTrack = En ritmo PaceBehind = Atrasado +PaceOnPace = Al ritmo previsto +PaceInReserve = { "{}" }% de reserva +PaceInDeficit = { "{}" }% de déficit +PaceLastsUntilReset = Dura hasta el reinicio +PaceRunsOutIn = Se agota en { "{}" } +PaceRunsOutNow = Se agota ahora +PaceProjectedEmptyIn = Agotamiento previsto en { "{}" } +PaceProjectedEmptyNow = Agotamiento previsto ahora +UsageBarQuotaWarnings = Avisos de cuota +UsageBarWorkDays = Días laborables del progreso semanal MetricResetsIn = Reinicia en SectionUsageBreakdown = Desglose de uso SectionCost = Costo ResetInProgress = Reiniciando... TomorrowAt = Mañana a las { "{}" } +ResetTomorrowAt = mañana, { "{}" } UsedPercent = { "{:.0}" }% usado RemainingPercent = { "{:.0}" }% restante RemainingAmount = { "{:.2}" } restante @@ -253,6 +264,7 @@ ResetsInShort = Reinicia en ResetsInDaysHours = Reinicia en { "{}" }d { "{}" }h ResetsInHoursMinutes = Reinicia en { "{}" }h { "{}" }m ResetsInMinutes = Reinicia en { "{}" }m +ResetsInDaysMinutes = Reinicia en { "{}" }d { "{}" }m NextExpiresInDaysHours = Próximo vence en { "{}" }d { "{}" }h NextExpiresInHoursMinutes = Próximo vence en { "{}" }h { "{}" }m NextExpiresInMinutes = Próximo vence en { "{}" }m @@ -1410,6 +1422,7 @@ ProviderTextRenewsIn = Se renueva en { "{}" } ProviderTextExpiresIn = Vence en { "{}" } ProviderTextCycleEndsIn = El ciclo termina en { "{}" } DurationDaysHours = { "{}" } d { "{}" } h +DurationDaysMinutes = { "{}" } d { "{}" } min DurationHoursMinutes = { "{}" } h { "{}" } min DurationDays = { "{}" } d DurationHours = { "{}" } h diff --git a/rust/src/locale/ja-JP.ftl b/rust/src/locale/ja-JP.ftl index 4eee2673033..8af9d9c60f9 100644 --- a/rust/src/locale/ja-JP.ftl +++ b/rust/src/locale/ja-JP.ftl @@ -196,11 +196,22 @@ ActionCopyError = エラーをコピー ActionBuyCredits = クレジットを購入… PaceOnTrack = 順調 PaceBehind = 遅れ +PaceOnPace = 想定ペース +PaceInReserve = { "{}" }% 余裕 +PaceInDeficit = { "{}" }% 不足 +PaceLastsUntilReset = リセットまで持ちます +PaceRunsOutIn = { "{}" } 後に使い切る見込み +PaceRunsOutNow = まもなく使い切ります +PaceProjectedEmptyIn = { "{}" } 後に枯渇する見込み +PaceProjectedEmptyNow = まもなく枯渇する見込み +UsageBarQuotaWarnings = クォータ警告 +UsageBarWorkDays = 週間進捗の作業日 MetricResetsIn = リセットまで SectionUsageBreakdown = 使用量の内訳 SectionCost = コスト ResetInProgress = リセット中... TomorrowAt = 明日 { "{}" } +ResetTomorrowAt = 明日 { "{}" } UsedPercent = 使用済み { "{:.0}" }% RemainingPercent = 残り { "{:.0}" }% RemainingAmount = 残り { "{:.2}" } @@ -253,6 +264,7 @@ ResetsInShort = リセットまで ResetsInDaysHours = リセットまで { "{}" }日 { "{}" }時間 ResetsInHoursMinutes = リセットまで { "{}" }時間 { "{}" }分 ResetsInMinutes = リセットまで { "{}" }分 +ResetsInDaysMinutes = リセットまで { "{}" }日 { "{}" }分 NextExpiresInDaysHours = 次回の有効期限まで { "{}" }日 { "{}" }時間 NextExpiresInHoursMinutes = 次回の有効期限まで { "{}" }時間 { "{}" }分 NextExpiresInMinutes = 次回の有効期限まで { "{}" }分 @@ -1411,6 +1423,7 @@ ProviderTextRenewsIn = { "{}" } 後に更新 ProviderTextExpiresIn = { "{}" } 後に期限切れ ProviderTextCycleEndsIn = サイクル終了まで { "{}" } DurationDaysHours = { "{}" } 日 { "{}" } 時間 +DurationDaysMinutes = { "{}" } 日 { "{}" } 分 DurationHoursMinutes = { "{}" } 時間 { "{}" } 分 DurationDays = { "{}" } 日 DurationHours = { "{}" } 時間 diff --git a/rust/src/locale/ko-KR.ftl b/rust/src/locale/ko-KR.ftl index 5dcb6cdd237..2233ad84d73 100644 --- a/rust/src/locale/ko-KR.ftl +++ b/rust/src/locale/ko-KR.ftl @@ -196,11 +196,22 @@ ActionCopyError = 오류 복사 ActionBuyCredits = 크레딧 구매... PaceOnTrack = 정상 진행 PaceBehind = 지연됨 +PaceOnPace = 정상 속도 +PaceInReserve = { "{}" }% 여유 +PaceInDeficit = { "{}" }% 부족 +PaceLastsUntilReset = 리셋까지 지속 +PaceRunsOutIn = { "{}" } 후 소진 +PaceRunsOutNow = 지금 소진 +PaceProjectedEmptyIn = { "{}" } 후 소진 예상 +PaceProjectedEmptyNow = 지금 소진 예상 +UsageBarQuotaWarnings = 할당량 경고 +UsageBarWorkDays = 주간 진행률 근무일 MetricResetsIn = 초기화까지 SectionUsageBreakdown = 사용량 내역 SectionCost = 비용 ResetInProgress = 초기화 중... TomorrowAt = 내일 { "{}" } +ResetTomorrowAt = 내일 { "{}" } UsedPercent = { "{:.0}" }% 사용됨 RemainingPercent = { "{:.0}" }% 남음 RemainingAmount = { "{:.2}" } 남음 @@ -253,6 +264,7 @@ ResetsInShort = 초기화까지 ResetsInDaysHours = 초기화까지 { "{}" }일 { "{}" }시간 ResetsInHoursMinutes = 초기화까지 { "{}" }시간 { "{}" }분 ResetsInMinutes = 초기화까지 { "{}" }분 +ResetsInDaysMinutes = 초기화까지 { "{}" }일 { "{}" }분 NextExpiresInDaysHours = 다음 만료까지 { "{}" }일 { "{}" }시간 NextExpiresInHoursMinutes = 다음 만료까지 { "{}" }시간 { "{}" }분 NextExpiresInMinutes = 다음 만료까지 { "{}" }분 @@ -1410,6 +1422,7 @@ ProviderTextRenewsIn = { "{}" } 후 갱신 ProviderTextExpiresIn = { "{}" } 후 만료 ProviderTextCycleEndsIn = 주기 종료까지 { "{}" } DurationDaysHours = { "{}" }일 { "{}" }시간 +DurationDaysMinutes = { "{}" }일 { "{}" }분 DurationHoursMinutes = { "{}" }시간 { "{}" }분 DurationDays = { "{}" }일 DurationHours = { "{}" }시간 diff --git a/rust/src/locale/pt-BR.ftl b/rust/src/locale/pt-BR.ftl index ef6f9509f14..9d5660141a4 100644 --- a/rust/src/locale/pt-BR.ftl +++ b/rust/src/locale/pt-BR.ftl @@ -219,11 +219,22 @@ ActionCopyError = Copiar erro ActionBuyCredits = Comprar créditos... PaceOnTrack = Dentro do ritmo PaceBehind = Atrasado +PaceOnPace = No ritmo +PaceInReserve = { "{}" }% em reserva +PaceInDeficit = { "{}" }% em déficit +PaceLastsUntilReset = Dura até a renovação +PaceRunsOutIn = Esgota em { "{}" } +PaceRunsOutNow = Esgota agora +PaceProjectedEmptyIn = Esgotamento previsto em { "{}" } +PaceProjectedEmptyNow = Esgotamento previsto agora +UsageBarQuotaWarnings = Alertas de cota +UsageBarWorkDays = Dias úteis no progresso semanal MetricResetsIn = Renova em SectionUsageBreakdown = Detalhamento do uso SectionCost = Custo ResetInProgress = Renovando... TomorrowAt = Amanhã às { "{}" } +ResetTomorrowAt = amanhã, { "{}" } UsedPercent = { "{:.0}" }% usado RemainingPercent = { "{:.0}" }% restante RemainingAmount = { "{:.2}" } restante @@ -275,6 +286,7 @@ ResetsInShort = Renova em ResetsInDaysHours = Renova em { "{}" }d { "{}" }h ResetsInHoursMinutes = Renova em { "{}" }h { "{}" }min ResetsInMinutes = Renova em { "{}" }min +ResetsInDaysMinutes = Renova em { "{}" }d { "{}" }min NextExpiresInDaysHours = O próximo expira em { "{}" }d { "{}" }h NextExpiresInHoursMinutes = O próximo expira em { "{}" }h { "{}" }min NextExpiresInMinutes = O próximo expira em { "{}" }min @@ -1415,6 +1427,7 @@ ProviderTextRenewsIn = Renova em { "{}" } ProviderTextExpiresIn = Expira em { "{}" } ProviderTextCycleEndsIn = O ciclo termina em { "{}" } DurationDaysHours = { "{}" } d { "{}" } h +DurationDaysMinutes = { "{}" } d { "{}" } min DurationHoursMinutes = { "{}" } h { "{}" } min DurationDays = { "{}" } d DurationHours = { "{}" } h diff --git a/rust/src/locale/ru-RU.ftl b/rust/src/locale/ru-RU.ftl index 7a00337d21c..05504dbb9e4 100644 --- a/rust/src/locale/ru-RU.ftl +++ b/rust/src/locale/ru-RU.ftl @@ -223,11 +223,22 @@ ActionCopyError = Ошибка копирования ActionBuyCredits = Купить кредиты... PaceOnTrack = На ходу PaceBehind = позади +PaceOnPace = В темпе +PaceInReserve = { "{}" }% в резерве +PaceInDeficit = { "{}" }% в дефиците +PaceLastsUntilReset = Длится до сброса +PaceRunsOutIn = Закончится через { "{}" } +PaceRunsOutNow = Сейчас заканчивается +PaceProjectedEmptyIn = По прогнозу закончится через { "{}" } +PaceProjectedEmptyNow = По прогнозу закончится сейчас +UsageBarQuotaWarnings = Предупреждения о квотах +UsageBarWorkDays = Рабочие дни для недельного прогресса MetricResetsIn = Сброс через SectionUsageBreakdown = Распределение использования SectionCost = Стоимость ResetInProgress = Сброс... TomorrowAt = Завтра в { "{}" } +ResetTomorrowAt = завтра, { "{}" } UsedPercent = Использовано { "{:.0}" }% RemainingPercent = { "{:.0}" }% осталось RemainingAmount = { "{:.2}" } осталось @@ -280,6 +291,7 @@ ResetsInShort = Сброс через ResetsInDaysHours = Сброс через { "{}" } д { "{}" } ч ResetsInHoursMinutes = Сброс через { "{}" } ч { "{}" } мин ResetsInMinutes = Сброс через { "{}" } мин +ResetsInDaysMinutes = Сброс через { "{}" } д { "{}" } мин NextExpiresInDaysHours = Срок действия следующего истекает через { "{}" }d { "{}" }h NextExpiresInHoursMinutes = Срок действия следующего истекает через { "{}" }h { "{}" }m NextExpiresInMinutes = Срок действия следующего истекает через { "{}" }m @@ -1411,6 +1423,7 @@ ProviderTextRenewsIn = Продление через { "{}" } ProviderTextExpiresIn = Истекает через { "{}" } ProviderTextCycleEndsIn = Цикл закончится через { "{}" } DurationDaysHours = { "{}" } д { "{}" } ч +DurationDaysMinutes = { "{}" } д { "{}" } мин DurationHoursMinutes = { "{}" } ч { "{}" } мин DurationDays = { "{}" } д DurationHours = { "{}" } ч diff --git a/rust/src/locale/tr-TR.ftl b/rust/src/locale/tr-TR.ftl index 41ec3e2c98d..8941e850163 100644 --- a/rust/src/locale/tr-TR.ftl +++ b/rust/src/locale/tr-TR.ftl @@ -223,11 +223,22 @@ ActionCopyError = Kopyalama hatası ActionBuyCredits = Kredi satın al... PaceOnTrack = Planlandığı gibi PaceBehind = Geride +PaceOnPace = Beklenen tempoda +PaceInReserve = %{ "{}" } rezervde +PaceInDeficit = %{ "{}" } açıkta +PaceLastsUntilReset = Sıfırlamaya kadar sürer +PaceRunsOutIn = { "{}" } içinde biter +PaceRunsOutNow = Şimdi biter +PaceProjectedEmptyIn = { "{}" } içinde tükenmesi tahmin ediliyor +PaceProjectedEmptyNow = Şimdi tükenmesi tahmin ediliyor +UsageBarQuotaWarnings = Kota uyarıları +UsageBarWorkDays = Haftalık ilerleme iş günleri MetricResetsIn = Sıfırlanmaya kalan SectionUsageBreakdown = Kullanım Dağılımı SectionCost = Maliyet ResetInProgress = Sıfırlanıyor... TomorrowAt = Yarın saat { "{}" } +ResetTomorrowAt = yarın, { "{}" } UsedPercent = %{ "{:.0}" } kullanıldı RemainingPercent = %{ "{:.0}" } kaldı RemainingAmount = { "{:.2}" } kaldı @@ -280,6 +291,7 @@ ResetsInShort = Sıfırlanmaya kalan ResetsInDaysHours = Sıfırlanmaya { "{}" } g { "{}" } sa ResetsInHoursMinutes = Sıfırlanmaya { "{}" } sa { "{}" } dk ResetsInMinutes = Sıfırlanmaya { "{}" } dk +ResetsInDaysMinutes = Sıfırlanmaya { "{}" } g { "{}" } dk NextExpiresInDaysHours = Sonraki süre bitimine { "{}" } g { "{}" } sa NextExpiresInHoursMinutes = Sonraki süre bitimine { "{}" } sa { "{}" } dk NextExpiresInMinutes = Sonraki süre bitimine { "{}" } dk @@ -1411,6 +1423,7 @@ ProviderTextRenewsIn = { "{}" } sonra yenilenir ProviderTextExpiresIn = { "{}" } sonra sona eriyor ProviderTextCycleEndsIn = Döngü { "{}" } sonra bitiyor DurationDaysHours = { "{}" } g { "{}" } sa +DurationDaysMinutes = { "{}" } g { "{}" } dk DurationHoursMinutes = { "{}" } sa { "{}" } dk DurationDays = { "{}" } g DurationHours = { "{}" } sa diff --git a/rust/src/locale/uk-UA.ftl b/rust/src/locale/uk-UA.ftl index 525fd917c62..79ae6592001 100644 --- a/rust/src/locale/uk-UA.ftl +++ b/rust/src/locale/uk-UA.ftl @@ -223,11 +223,22 @@ ActionCopyError = Копіювати помилку ActionBuyCredits = Купити кредити... PaceOnTrack = За графіком PaceBehind = Позаду +PaceOnPace = В темпі +PaceInReserve = { "{}" }% в резерві +PaceInDeficit = { "{}" }% в дефіциті +PaceLastsUntilReset = Вистачить до скидання +PaceRunsOutIn = Закінчується за { "{}" } +PaceRunsOutNow = Зараз закінчується +PaceProjectedEmptyIn = Передбачається порожній у { "{}" } +PaceProjectedEmptyNow = Зараз проектується порожнім +UsageBarQuotaWarnings = Попередження про квоту +UsageBarWorkDays = Щотижневі робочі дні MetricResetsIn = Скинеться через SectionUsageBreakdown = Розподіл використання SectionCost = Вартість ResetInProgress = Скидання... TomorrowAt = Завтра о { "{}" } +ResetTomorrowAt = завтра, { "{}" } UsedPercent = Використано { "{:.0}" }% RemainingPercent = Залишилося { "{:.0}" }% RemainingAmount = Залишилося { "{:.2}" } @@ -280,6 +291,7 @@ ResetsInShort = Скинеться через ResetsInDaysHours = Скинеться через { "{}" } дн. { "{}" } год ResetsInHoursMinutes = Скинеться через { "{}" } год { "{}" } хв ResetsInMinutes = Скинеться через { "{}" } хв +ResetsInDaysMinutes = Скинеться через { "{}" } дн. { "{}" } хв NextExpiresInDaysHours = Наступне спливає через { "{}" } дн. { "{}" } год NextExpiresInHoursMinutes = Наступне спливає через { "{}" } год { "{}" } хв NextExpiresInMinutes = Наступне спливає через { "{}" } хв @@ -1413,6 +1425,7 @@ ProviderTextRenewsIn = Поновлення через { "{}" } ProviderTextExpiresIn = Спливає через { "{}" } ProviderTextCycleEndsIn = Цикл закінчиться через { "{}" } DurationDaysHours = { "{}" } д { "{}" } год +DurationDaysMinutes = { "{}" } д { "{}" } хв DurationHoursMinutes = { "{}" } год { "{}" } хв DurationDays = { "{}" } д DurationHours = { "{}" } год diff --git a/rust/src/locale/zh-CN.ftl b/rust/src/locale/zh-CN.ftl index f5e05665fc7..c78c2046280 100644 --- a/rust/src/locale/zh-CN.ftl +++ b/rust/src/locale/zh-CN.ftl @@ -196,11 +196,22 @@ ActionCopyError = 复制错误 ActionBuyCredits = 购买额度... PaceOnTrack = 进度正常 PaceBehind = 进度滞后 +PaceOnPace = 节奏正常 +PaceInReserve = 余量 { "{}" }% +PaceInDeficit = 超额 { "{}" }% +PaceLastsUntilReset = 持续到重置 +PaceRunsOutIn = 预计 { "{}" } 后耗尽 +PaceRunsOutNow = 即将耗尽 +PaceProjectedEmptyIn = 预计 { "{}" } 后耗尽 +PaceProjectedEmptyNow = 即将耗尽 +UsageBarQuotaWarnings = 配额预警 +UsageBarWorkDays = 工作日刻度线 MetricResetsIn = 重置于 SectionUsageBreakdown = 用量明细 SectionCost = 费用 ResetInProgress = 正在重置... TomorrowAt = 明天 { "{}" } +ResetTomorrowAt = 明天 { "{}" } UsedPercent = 已使用 { "{:.0}" }% RemainingPercent = 剩余 { "{:.0}" }% RemainingAmount = 剩余 { "{:.2}" } @@ -253,6 +264,7 @@ ResetsInShort = 重置于 ResetsInDaysHours = { "{}" } 天 { "{}" } 小时后重置 ResetsInHoursMinutes = { "{}" } 小时 { "{}" } 分钟后重置 ResetsInMinutes = { "{}" } 分钟后重置 +ResetsInDaysMinutes = { "{}" } 天 { "{}" } 分钟后重置 NextExpiresInDaysHours = 下一张将在 { "{}" } 天 { "{}" } 小时后过期 NextExpiresInHoursMinutes = 下一张将在 { "{}" } 小时 { "{}" } 分钟后过期 NextExpiresInMinutes = 下一张将在 { "{}" } 分钟后过期 @@ -1411,6 +1423,7 @@ ProviderTextRenewsIn = { "{}" } 后续订 ProviderTextExpiresIn = { "{}" } 后到期 ProviderTextCycleEndsIn = 周期将在 { "{}" } 后结束 DurationDaysHours = { "{}" } 天 { "{}" } 小时 +DurationDaysMinutes = { "{}" } 天 { "{}" } 分钟 DurationHoursMinutes = { "{}" } 小时 { "{}" } 分钟 DurationDays = { "{}" } 天 DurationHours = { "{}" } 小时 diff --git a/rust/src/locale/zh-TW.ftl b/rust/src/locale/zh-TW.ftl index e9faab8be66..81532da61b4 100644 --- a/rust/src/locale/zh-TW.ftl +++ b/rust/src/locale/zh-TW.ftl @@ -196,11 +196,22 @@ ActionCopyError = 複製錯誤 ActionBuyCredits = 購買額度... PaceOnTrack = 進度正常 PaceBehind = 進度滯後 +PaceOnPace = 用量正常 +PaceInReserve = 比目前進度少用 { "{}" }% +PaceInDeficit = 比目前進度多用 { "{}" }% +PaceLastsUntilReset = 持續到重置 +PaceRunsOutIn = { "{}" } 後用完 +PaceRunsOutNow = 現在用完 +PaceProjectedEmptyIn = 預估 { "{}" } 後用完 +PaceProjectedEmptyNow = 預估現在用完 +UsageBarQuotaWarnings = 配額提醒 +UsageBarWorkDays = 每週進度工作日標記 MetricResetsIn = 重置於 SectionUsageBreakdown = 用量明細 SectionCost = 費用 ResetInProgress = 正在重置... TomorrowAt = 明天 { "{}" } +ResetTomorrowAt = 明天 { "{}" } UsedPercent = 已使用 { "{:.0}" }% RemainingPercent = 剩餘 { "{:.0}" }% RemainingAmount = 剩餘 { "{:.2}" } @@ -253,6 +264,7 @@ ResetsInShort = 重置於 ResetsInDaysHours = { "{}" } 天 { "{}" } 小時後重置 ResetsInHoursMinutes = { "{}" } 小時 { "{}" } 分鐘後重置 ResetsInMinutes = { "{}" } 分鐘後重置 +ResetsInDaysMinutes = { "{}" } 天 { "{}" } 分鐘後重置 NextExpiresInDaysHours = 下一張將在 { "{}" } 天 { "{}" } 小時後過期 NextExpiresInHoursMinutes = 下一張將在 { "{}" } 小時 { "{}" } 分鐘後過期 NextExpiresInMinutes = 下一張將在 { "{}" } 分鐘後過期 @@ -1411,6 +1423,7 @@ ProviderTextRenewsIn = { "{}" } 後續訂 ProviderTextExpiresIn = { "{}" } 後到期 ProviderTextCycleEndsIn = 週期將在 { "{}" } 後結束 DurationDaysHours = { "{}" } 天 { "{}" } 小時 +DurationDaysMinutes = { "{}" } 天 { "{}" } 分鐘 DurationHoursMinutes = { "{}" } 小時 { "{}" } 分鐘 DurationDays = { "{}" } 天 DurationHours = { "{}" } 小時 From f70b5df38b614e2dd7adb88782136f62713a640f Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Sat, 10 Oct 2026 13:51:32 +0700 Subject: [PATCH 4/7] Draw the Mac usage bar and pace line in metric rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each quota row now follows the Mac MetricRow. The percent follows the title ("Session 63% left") and the reset sits at the right end of the same line. It drops under the title when both do not fit. The 6px bar fills in the provider accent. The high and critical notification thresholds punch quota warning markers into it, with per-provider overrides applied (decision 7.6). Weekly bars get work day ticks when weekly_progress_work_days is set. The expected pace shows as a green stripe for a reserve or a red one for a deficit. A pace line under the bar replaces the separate reserve row. It reads like "12% in reserve · Lasts until reset" or "9% in deficit · Runs out in 2h 30m". The used-level bar colors are gone, and over-quota rows clamp to 100% as on the Mac. The ftl-backed test translator moves into the shared locale harness for the new metric row model tests. --- .../src/components/MenuCard.test.tsx | 216 ++++++++-- .../desktop-tauri/src/components/MenuCard.tsx | 13 + .../src/components/MenuCardDetails.tsx | 219 +++++----- .../src/components/UsageProgressBar.test.tsx | 144 +++++++ .../src/components/UsageProgressBar.tsx | 108 +++++ .../src/lib/metricRowModel.test.ts | 383 ++++++++++++++++++ apps/desktop-tauri/src/lib/metricRowModel.ts | 355 ++++++++++++++++ .../src/lib/providerText.test.ts | 14 +- apps/desktop-tauri/src/styles.css | 73 +++- .../src/surfaces/TrayPanel.test.tsx | 17 + apps/desktop-tauri/src/surfaces/TrayPanel.tsx | 2 + apps/desktop-tauri/src/test/localeHarness.tsx | 14 +- 12 files changed, 1390 insertions(+), 168 deletions(-) create mode 100644 apps/desktop-tauri/src/components/UsageProgressBar.test.tsx create mode 100644 apps/desktop-tauri/src/components/UsageProgressBar.tsx create mode 100644 apps/desktop-tauri/src/lib/metricRowModel.test.ts create mode 100644 apps/desktop-tauri/src/lib/metricRowModel.ts diff --git a/apps/desktop-tauri/src/components/MenuCard.test.tsx b/apps/desktop-tauri/src/components/MenuCard.test.tsx index 1f8fd03cd5a..9f5ad3aacf9 100644 --- a/apps/desktop-tauri/src/components/MenuCard.test.tsx +++ b/apps/desktop-tauri/src/components/MenuCard.test.tsx @@ -23,6 +23,7 @@ vi.mock("@tauri-apps/api/event", () => eventMocks); import { LocaleProvider } from "../i18n/LocaleProvider"; import { buildBundle } from "../test/localeHarness"; import { loadStyles, ruleBlock } from "../test/styles"; +import type { UsageThresholdSettings } from "../lib/metricRowModel"; import type { ProviderUsageSnapshot } from "../types/bridge"; import MenuCard from "./MenuCard"; @@ -91,6 +92,8 @@ function renderCard( compactOverview?: boolean; onLayoutChange?: () => void; costSummaryDisplayStyle?: "compact" | "detailed" | "hidden"; + usageThresholds?: UsageThresholdSettings | null; + weeklyProgressWorkDays?: number | null; } = {}, ) { return render( @@ -105,6 +108,8 @@ function renderCard( showPace: opts.showPace, compactOverview: opts.compactOverview, costSummaryDisplayStyle: opts.costSummaryDisplayStyle, + usageThresholds: opts.usageThresholds, + weeklyProgressWorkDays: opts.weeklyProgressWorkDays, }} onLayoutChange={opts.onLayoutChange} /> @@ -140,6 +145,15 @@ describe("MenuCard", () => { ResetsInHoursMinutes: "Resets in {}h {}m", ResetsInMinutes: "Resets in {}m", ResetsInDaysHours: "Resets in {}d {}h", + ResetsInHoursOnly: "Resets in {}h", + DurationHoursMinutes: "{}h {}m", + PaceOnPace: "On pace", + PaceInReserve: "{}% in reserve", + PaceInDeficit: "{}% in deficit", + PaceLastsUntilReset: "Lasts until reset", + PaceRunsOutIn: "Runs out in {}", + UsageBarQuotaWarnings: "Quota warnings", + UsageBarWorkDays: "Work days", NextExpiresInHoursMinutes: "Next expires in {}h {}m", NextExpiresInMinutes: "Next expires in {}m", NextExpiresInDaysHours: "Next expires in {}d {}h", @@ -291,8 +305,8 @@ describe("MenuCard", () => { expect(await screen.findByText("35% used")).toBeInTheDocument(); expect(screen.queryByText("65% left")).not.toBeInTheDocument(); - const fill = document.querySelector(".menu-metric__bar-fill"); - expect(fill?.style.width).toBe("35%"); + const fill = document.querySelector(".menu-metric__progress-fill"); + expect(fill?.getAttribute("width")).toBe("35%"); }); it("shows an additional balance description below the meter and reset time", async () => { @@ -310,7 +324,7 @@ describe("MenuCard", () => { const description = await screen.findByText("750 / 1000 credits left"); expect(description).toHaveClass("menu-metric__detail"); - expect(screen.getByText(/Resets in/)).toBeInTheDocument(); + expect(screen.getByText("Resets in 1h")).toHaveClass("menu-metric__reset"); }); it("does not repeat a reset-phrase description as an extra detail", async () => { @@ -321,20 +335,21 @@ describe("MenuCard", () => { renderCard(snapshot); - await screen.findByText(/Resets in/); + expect(await screen.findByText("Resets in 5h")).toHaveClass("menu-metric__reset"); expect(document.querySelectorAll(".menu-metric__reset")).toHaveLength(1); expect(document.querySelectorAll(".menu-metric__detail")).toHaveLength(0); expect(screen.queryByText("Resets in 3h")).not.toBeInTheDocument(); }); - it("displays over-quota usage without overflowing the bar", async () => { + it("clamps over-quota usage to a full bar and 100% used", async () => { renderCard(provider(null, 115, { exhausted: true, resetDescription: "115% used" }), { showAsUsed: true, }); - expect(await screen.findAllByText("115% used")).not.toHaveLength(0); - const fill = document.querySelector(".menu-metric__bar-fill"); - expect(fill?.style.width).toBe("100%"); + expect(await screen.findByText("100% used")).toHaveClass("menu-metric__percent"); + expect(screen.queryByText("115% used")).not.toBeInTheDocument(); + const fill = document.querySelector(".menu-metric__progress-fill"); + expect(fill?.getAttribute("width")).toBe("100%"); }); it("replaces an exhausted percentage with a future reset countdown", async () => { @@ -343,7 +358,8 @@ describe("MenuCard", () => { renderCard(snapshot, { showResetWhenExhausted: true }); - expect(await screen.findByText(/Resets in \d+m/)).toBeInTheDocument(); + expect(await screen.findByText("Resets in 1h")).toHaveClass("menu-metric__reset"); + expect(document.querySelector(".menu-metric__percent")).toBeNull(); expect(screen.queryByText("0% left")).not.toBeInTheDocument(); }); @@ -493,7 +509,8 @@ describe("MenuCard", () => { const title = await screen.findByText("Requests"); expect(title.parentElement).not.toHaveTextContent("100% left"); - expect(title.parentElement?.querySelector(".menu-metric__bar")).toBeNull(); + expect(title.parentElement?.querySelector(".menu-metric__progress")).toBeNull(); + expect(document.querySelectorAll(".menu-metric__progress")).toHaveLength(1); expect(screen.getByText("7 requests")).toBeInTheDocument(); }); @@ -518,7 +535,8 @@ describe("MenuCard", () => { const title = await screen.findByText("Reset credits"); expect(screen.getByText("2 reset credits available")).toBeInTheDocument(); expect(screen.getByText(/Next expires in/)).toBeInTheDocument(); - expect(title.parentElement?.querySelector(".menu-metric__bar")).toBeNull(); + expect(title.parentElement?.querySelector(".menu-metric__progress")).toBeNull(); + expect(document.querySelectorAll(".menu-metric__progress")).toHaveLength(1); }); it("keeps reset credit count in absolute reset-time mode", async () => { @@ -656,14 +674,26 @@ describe("MenuCard", () => { etaSeconds: 90 * 60, willLastToReset: false, }; - snapshot.primary = rateWindow(31, { - windowMinutes: 7 * 24 * 60, - resetsAt: resetAt.toISOString(), - }); + snapshot.primary = { + ...rateWindow(31, { + windowMinutes: 7 * 24 * 60, + resetsAt: resetAt.toISOString(), + }), + pace: { + stage: "far_ahead", + deltaPercent: 20, + expectedUsedPercent: 11, + actualUsedPercent: 31, + etaSeconds: 90 * 60, + willLastToReset: false, + }, + }; const { container } = renderCard(snapshot, { showPace: false }); expect(await screen.findByText("69% left")).toBeInTheDocument(); + expect(container.querySelector(".menu-metric__meta")).not.toBeInTheDocument(); + expect(container.querySelector(".menu-metric__progress-pace")).not.toBeInTheDocument(); expect(container.querySelector(".menu-card__pace")).not.toBeInTheDocument(); expect(screen.queryByText("On-pace budget")).not.toBeInTheDocument(); expect(container.querySelector(".menu-metric__forecast")).not.toBeInTheDocument(); @@ -679,12 +709,20 @@ describe("MenuCard", () => { windowMinutes: 5 * 60, resetsAt: new Date(Date.now() + 2 * 60 * 60 * 1000).toISOString(), }); - snapshot.secondary = rateWindow(23, { - windowMinutes: 7 * 24 * 60, - resetsAt: resetAt.toISOString(), - reservePercent: 34, - reserveWillLastToReset: true, - }); + snapshot.secondary = { + ...rateWindow(23, { + windowMinutes: 7 * 24 * 60, + resetsAt: resetAt.toISOString(), + }), + pace: { + stage: "far_behind", + deltaPercent: -34, + expectedUsedPercent: 57, + actualUsedPercent: 23, + etaSeconds: null, + willLastToReset: true, + }, + }; snapshot.pace = { stage: "far_ahead", deltaPercent: 20, @@ -701,6 +739,8 @@ describe("MenuCard", () => { expect(container.querySelector(".menu-card__pace")).not.toBeInTheDocument(); expect(screen.queryByText("On-pace budget")).not.toBeInTheDocument(); expect(screen.queryByText(/in reserve/)).not.toBeInTheDocument(); + expect(container.querySelector(".menu-metric__meta")).not.toBeInTheDocument(); + expect(container.querySelector(".menu-metric__progress-pace")).not.toBeInTheDocument(); expect(container.querySelector(".menu-metric__forecast")).not.toBeInTheDocument(); }); @@ -718,9 +758,15 @@ describe("MenuCard", () => { ...rateWindow(0, { windowMinutes: 7 * 24 * 60, resetsAt: shortReset, - reservePercent: 30, - reserveWillLastToReset: true, }), + pace: { + stage: "far_behind", + deltaPercent: -30, + expectedUsedPercent: 30, + actualUsedPercent: 0, + etaSeconds: null, + willLastToReset: true, + }, monthlyLimitBlock: block, }; snapshot.secondaryLabel = "Code 5-hour"; @@ -771,11 +817,13 @@ describe("MenuCard", () => { expect(blockedRows[0]).toHaveTextContent(/^Code 7-dayBlocked by monthly limit$/); expect(blockedRows[1]).toHaveTextContent(/^Code 5-hourBlocked by monthly limit$/); for (const row of blockedRows) { - expect(row.querySelector(".menu-metric__bar")).toBeNull(); - expect(row.querySelector(".menu-metric__pct")).toBeNull(); + expect(row.querySelector(".menu-metric__progress")).toBeNull(); + expect(row.querySelector(".menu-metric__percent")).toBeNull(); expect(row.querySelector(".menu-metric__reset")).toBeNull(); + expect(row.querySelector(".menu-metric__meta")).toBeNull(); } // The pool row keeps its own bar, percent, reset and exhausted label. + expect(container.querySelectorAll(".menu-metric__progress")).toHaveLength(1); expect(screen.getByText("Total usage")).toBeInTheDocument(); expect(screen.getAllByText("100% used")).toHaveLength(1); expect(screen.queryByText("0% used")).not.toBeInTheDocument(); @@ -1031,17 +1079,119 @@ describe("MenuCard", () => { ).not.toBeInTheDocument(); }); - it("keeps the reserve row when timing data is incomplete", async () => { + it("puts the percent after the title and the reset at the end of the same line", async () => { + const snapshot = provider(null, 37); + snapshot.primary.resetsAt = new Date(Date.now() + 90 * 60 * 1000).toISOString(); + + const { container } = renderCard(snapshot); + + expect(await screen.findByText("63% left")).toHaveClass("menu-metric__percent"); + const head = container.querySelector(".menu-metric__head"); + expect(Array.from(head?.children ?? [], (node) => node.textContent)).toEqual([ + "ProviderSessionLabel 63% left", + "Resets in 1h 30m", + ]); + }); + + it("shows a reserve as a green pace stripe and a pace line under the bar", async () => { const snapshot = provider(null, 20); - snapshot.primary = rateWindow(20, { - reservePercent: 12, - reserveWillLastToReset: true, - }); + snapshot.secondaryLabel = "Weekly"; + snapshot.secondary = { + ...rateWindow(40, { + windowMinutes: 7 * 24 * 60, + resetsAt: new Date(Date.now() + 3 * 24 * 60 * 60 * 1000).toISOString(), + }), + pace: { + stage: "behind", + deltaPercent: -12, + expectedUsedPercent: 52, + actualUsedPercent: 40, + etaSeconds: null, + willLastToReset: true, + }, + }; - renderCard(snapshot); + const { container } = renderCard(snapshot); - expect(await screen.findByText("12% in reserve")).toBeInTheDocument(); - expect(screen.queryByText("On-pace budget")).not.toBeInTheDocument(); + expect(await screen.findByText("12% in reserve · Lasts until reset")).toHaveClass( + "menu-metric__meta", + ); + const stripes = container.querySelectorAll(".menu-metric__progress-pace"); + expect(stripes).toHaveLength(1); + expect(stripes[0].getAttribute("x")).toBe("48%"); + expect(stripes[0].getAttribute("data-deficit")).toBe("false"); + }); + + it("shows a deficit as a red pace stripe and says when the window runs out", async () => { + const snapshot = provider(null, 20); + snapshot.secondaryLabel = "Weekly"; + snapshot.secondary = { + ...rateWindow(40, { + windowMinutes: 7 * 24 * 60, + resetsAt: new Date(Date.now() + 4 * 24 * 60 * 60 * 1000).toISOString(), + }), + pace: { + stage: "ahead", + deltaPercent: 9, + expectedUsedPercent: 31, + actualUsedPercent: 40, + etaSeconds: 2.5 * 60 * 60, + willLastToReset: false, + }, + }; + + const { container } = renderCard(snapshot, { showAsUsed: true }); + + expect(await screen.findByText("9% in deficit · Runs out in 2h 30m")).toHaveClass( + "menu-metric__meta", + ); + const stripe = container.querySelector(".menu-metric__progress-pace"); + expect(stripe?.getAttribute("x")).toBe("31%"); + expect(stripe?.getAttribute("data-deficit")).toBe("true"); + }); + + it.each([ + ["global thresholds on a % left bar", false, {}, ["5%", "20%"]], + ["global thresholds on a % used bar", true, {}, ["80%", "95%"]], + ["a provider session override", false, { "claude:session": { high: 60 } }, ["5%", "40%"]], + ])("draws quota warning markers from %s", async (_case, showAsUsed, overrides, expected) => { + const { container } = renderCard(provider(null, 30), { + showAsUsed, + usageThresholds: { + highUsageThreshold: 80, + criticalUsageThreshold: 95, + providerUsageThresholds: overrides, + }, + }); + + await screen.findByText(showAsUsed ? "30% used" : "70% left"); + expect( + Array.from(container.querySelectorAll(".menu-metric__progress-warning"), (node) => + node.getAttribute("x"), + ), + ).toEqual(expected); + expect(container.querySelector(".menu-metric__progress")?.getAttribute("aria-valuetext")).toBe( + `${showAsUsed ? 30 : 70}%. Quota warnings: ${expected.join(", ")}`, + ); + }); + + it("draws work day ticks on the weekly bar only", async () => { + const snapshot = provider(null, 20, {}); + snapshot.primary.windowMinutes = 5 * 60; + snapshot.secondaryLabel = "Weekly"; + snapshot.secondary = rateWindow(40, { windowMinutes: 7 * 24 * 60 }); + + const { container } = renderCard(snapshot, { weeklyProgressWorkDays: 5 }); + + await screen.findByText("60% left"); + const bars = container.querySelectorAll(".menu-metric__progress"); + expect(bars).toHaveLength(2); + expect(bars[0].querySelectorAll(".menu-metric__progress-workday")).toHaveLength(0); + expect( + Array.from(bars[1].querySelectorAll(".menu-metric__progress-workday"), (node) => + node.getAttribute("x"), + ), + ).toEqual(["20%", "40%", "60%", "80%"]); }); diff --git a/apps/desktop-tauri/src/components/MenuCard.tsx b/apps/desktop-tauri/src/components/MenuCard.tsx index caf641622da..6334a12ff6f 100644 --- a/apps/desktop-tauri/src/components/MenuCard.tsx +++ b/apps/desktop-tauri/src/components/MenuCard.tsx @@ -20,6 +20,7 @@ import type { DeepSeekPricingStatus } from "../types/bridge"; import { isUsageItemVisible } from "../lib/usageItemVisibility"; import { localizeWindowLabel } from "../lib/windowLabels"; import { useMonthlyLimitBlockNow } from "../hooks/useMonthlyLimitBlockNow"; +import type { UsageThresholdSettings } from "../lib/metricRowModel"; /** Small copy-to-clipboard button matching macOS CopyIconButton (doc.on.doc → checkmark). */ function CopyIconButton({ text }: { text: string }) { @@ -60,6 +61,9 @@ export interface MenuCardDisplayOptions { */ compactOverview?: boolean; costSummaryDisplayStyle?: CostSummaryDisplayStyle; + /** Notification thresholds, drawn as quota warning markers on the bars. */ + usageThresholds?: UsageThresholdSettings | null; + weeklyProgressWorkDays?: number | null; } interface MenuCardProps { @@ -120,6 +124,8 @@ export default function MenuCard({ showAsUsed = false, compactOverview = false, costSummaryDisplayStyle, + usageThresholds = null, + weeklyProgressWorkDays = null, } = display; const { t, language } = useLocale(); const [chartData, setChartData] = useState(null); @@ -188,6 +194,7 @@ export default function MenuCard({ id: "primary", label: localizeWindowLabel(provider.primaryLabel, t, language, provider.primary.windowMinutes) || t("DetailWindowPrimary"), snap: provider.primary, + lane: "primary" as const, }, ]), ]; @@ -196,6 +203,7 @@ export default function MenuCard({ id: "secondary", label: localizeWindowLabel(provider.secondaryLabel, t) || t("DetailWindowSecondary"), snap: provider.secondary, + lane: "secondary", sessionEquivalentForecast: provider.sessionEquivalentForecast, }); if (provider.modelSpecific) @@ -203,6 +211,7 @@ export default function MenuCard({ id: "model-specific", label: t("DetailWindowModelSpecific"), snap: provider.modelSpecific, + lane: "tertiary", }); if (provider.tertiary) metrics.push({ @@ -211,6 +220,7 @@ export default function MenuCard({ // of the generic "DetailWindowTertiary" slot key when tertiaryLabel is set. label: localizeWindowLabel(provider.tertiaryLabel, t) || t("DetailWindowTertiary"), snap: provider.tertiary, + lane: "tertiary", }); for (const extra of provider.extraRateWindows ?? []) { metrics.push({ @@ -219,6 +229,7 @@ export default function MenuCard({ localizeWindowLabel(extra.title, t, language, extra.window.windowMinutes, extra.id) || extra.title, snap: extra.window, + lane: "extra", resetFormatMode: extra.id === "reset-credits" ? "expires" : "reset", }); } @@ -323,6 +334,8 @@ export default function MenuCard({ compactOverview, costSummaryDisplayStyle, monthlyLimitBlockNow, + usageThresholds, + weeklyProgressWorkDays, }} metrics={visibleMetrics} chartData={chartData} diff --git a/apps/desktop-tauri/src/components/MenuCardDetails.tsx b/apps/desktop-tauri/src/components/MenuCardDetails.tsx index 8d8d934d4ee..1480ee2c142 100644 --- a/apps/desktop-tauri/src/components/MenuCardDetails.tsx +++ b/apps/desktop-tauri/src/components/MenuCardDetails.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useEffect, useState } from "react"; import type { CostSummaryDisplayStyle, DailyCostPoint, @@ -34,31 +34,18 @@ import { getPaceBudget, type PaceBudget } from "../lib/paceBudget"; import { isMonthlyLimitBlockActive } from "../lib/monthlyLimitBlock"; import { periodCostLabel, periodTokensLabel } from "../lib/costPeriod"; import { providerCostPeriodTitle } from "../lib/providerLabels"; -import { resetDescriptionFallback, windowDetailText } from "../lib/usageWindows"; +import { windowDetailText } from "../lib/usageWindows"; import { localizeProviderText } from "../lib/providerText"; import { localizeProviderLabel } from "../lib/windowLabels"; import { isDetailSectionVisible } from "../lib/usageItemVisibility"; import PaceDetailsChart from "./PaceDetailsChart"; - -/** Format a reserve description from raw pace data at render time. */ -function formatReserveDescription( - snap: RateWindowSnapshot, - t: (key: LocaleKey) => string, -): string | null { - if (snap.reservePercent == null) return null; - if (snap.reserveWillLastToReset) { - return t("PanelReserveLastsUntilReset"); - } - const eta = snap.reserveEtaSeconds; - if (eta == null) return null; - const h = Math.floor(eta / 3600); - if (h >= 24) { - return t("PanelReserveRunsOutInDaysHours") - .replace("{}", String(Math.floor(h / 24))) - .replace("{}", String(h % 24)); - } - return t("PanelReserveRunsOutInHours").replace("{}", String(h)); -} +import UsageProgressBar from "./UsageProgressBar"; +import { + metricResetText, + metricRowPresentation, + type MetricLane, + type UsageThresholdSettings, +} from "../lib/metricRowModel"; /** Upstream session-quota estimate: "Estimated: {n} session quota(s) left". */ function formatSessionEquivalentEstimate( @@ -296,43 +283,25 @@ function paceStageKey(stage: PaceSnapshot["stage"]): LocaleKey { } } -type UsageLevel = "normal" | "high" | "critical" | "exhausted"; const WEEKLY_WINDOW_MINUTES = 7 * 24 * 60; -function levelOf(remainPct: number, exhausted: boolean): UsageLevel { - if (exhausted) return "exhausted"; - if (remainPct <= 5) return "critical"; - if (remainPct <= 25) return "high"; - return "normal"; -} - export interface MetricEntry { id: string; label: string; snap: RateWindowSnapshot; + lane: MetricLane; resetFormatMode?: ResetTimeFormatMode; sessionEquivalentForecast?: SessionEquivalentForecastSnapshot | null; } -type MetricPaceView = - | { kind: "budget"; budget: PaceBudget } - | { kind: "reserve"; percent: number } - | { kind: "none" }; - -function getMetricPaceView(snap: RateWindowSnapshot): MetricPaceView { - if (snap.isExhausted) return { kind: "none" }; - +/** On-pace budget of a weekly window that still has quota left. */ +function metricPaceBudget(snap: RateWindowSnapshot): PaceBudget | null { + if (snap.isExhausted) return null; const isWeeklyWindow = snap.windowMinutes != null && snap.windowMinutes >= WEEKLY_WINDOW_MINUTES; - const budget = isWeeklyWindow ? getPaceBudget(snap) : null; - if (budget) return { kind: "budget", budget }; - - if (snap.reservePercent != null) { - return { kind: "reserve", percent: snap.reservePercent }; - } - - return { kind: "none" }; + return isWeeklyWindow ? getPaceBudget(snap) : null; } + type MetricRowDisplay = { resetTimeRelative: boolean; showResetWhenExhausted?: boolean; @@ -341,17 +310,33 @@ type MetricRowDisplay = { compactOverview?: boolean; costSummaryDisplayStyle?: CostSummaryDisplayStyle; monthlyLimitBlockNow?: number; + /** Notification thresholds, drawn as quota warning markers on the bars. */ + usageThresholds?: UsageThresholdSettings | null; + weeklyProgressWorkDays?: number | null; }; +/** The current time, re-read every 30 s while a reset countdown is shown. */ +function useCountdownNow(ticking: boolean): number { + const [, setTick] = useState(0); + useEffect(() => { + if (!ticking) return; + const id = window.setInterval(() => setTick((tick) => tick + 1), 30_000); + return () => window.clearInterval(id); + }, [ticking]); + return Date.now(); +} + /** - * Single metric row inside the card — mirrors upstream `MetricRow`: - * • title (body / medium) - * • UsageProgressBar (capsule, 6pt) - * • HStack: "N% used" · · reset countdown (right-aligned, secondary) + * Single metric row inside the card, after upstream `MetricRow`: + * • title and percent, with the reset on the same baseline or under it + * • UsageProgressBar (6pt) with quota warning, work day and pace markers + * • pace line (footnote, secondary) */ function MetricRow({ title, snap, + lane, + providerId, exhaustedLabel, display, expanded, @@ -361,6 +346,8 @@ function MetricRow({ }: { title: string; snap: RateWindowSnapshot; + lane: MetricLane; + providerId: string; exhaustedLabel: string; display: MetricRowDisplay; expanded: boolean; @@ -376,6 +363,8 @@ function MetricRow({ showAsUsed = false, compactOverview = false, monthlyLimitBlockNow, + usageThresholds = null, + weeklyProgressWorkDays = null, } = display; // Upstream 0.69.0 #4091: a longer exhausted pool (Kimi's monthly membership) // blocks this window until the pool resets. Raw percentages stay untouched. @@ -384,36 +373,18 @@ function MetricRow({ monthlyLimitBlockNow ?? Date.now(), ); const isInformational = snap.isInformational === true; - const usedPct = Number.isFinite(snap.usedPercent) ? Math.max(0, snap.usedPercent) : 0; - const barPct = Math.min(100, usedPct); - const remain = 100 - usedPct; - const displayPct = showAsUsed ? usedPct : Math.max(0, remain); - const barDisplayPct = showAsUsed ? barPct : Math.max(0, Math.min(100, remain)); - const displayLabel = showAsUsed ? t("PanelUsedSuffix") : t("PanelLeftSuffix"); - const level = levelOf(remain, snap.isExhausted); - const detailText = localizeProviderText(windowDetailText(snap), t) || null; - const resetText = useFormattedResetTime( - blocked ? null : snap.resetsAt, - isInformational || blocked ? null : resetDescriptionFallback(snap), + const informationalReset = useFormattedResetTime( + isInformational && !blocked ? snap.resetsAt : null, + null, resetTimeRelative, resetFormatMode ?? "reset", ); - const infoPrimary = - localizeProviderText(snap.resetDescription?.trim(), t) || resetText || "—"; - const resetTarget = snap.resetsAt ? Date.parse(snap.resetsAt) : Number.NaN; - const replacesPercent = - showResetWhenExhausted && - snap.isExhausted && - Number.isFinite(resetTarget) && - resetTarget > Date.now() && - resetText !== null; - const paceView = showPace ? getMetricPaceView(snap) : { kind: "none" as const }; - const reserveDescription = formatReserveDescription(snap, t); - const forecastText = formatSessionEquivalentEstimate(sessionEquivalentForecast); + const now = useCountdownNow(!isInformational && !blocked && snap.resetsAt != null); + const detailText = localizeProviderText(windowDetailText(snap), t) || null; if (blocked) { // Upstream `MetricRow` status layout: title plus one secondary status line, - // no bar, percent, reset, pace, reserve, or forecast. The pool's own row - // keeps its reset; the shorter resets cannot restore access. + // no bar, percent, reset, pace, or forecast. The pool's own row keeps its + // reset; the shorter resets cannot restore access. return (
{title} @@ -421,39 +392,68 @@ function MetricRow({
); } + if (isInformational) { + const infoPrimary = + localizeProviderText(snap.resetDescription?.trim(), t) || informationalReset || "—"; + return ( +
+ {title} +
+ {infoPrimary} + {!compactOverview && + snap.resetDescription?.trim() && + informationalReset && + informationalReset !== infoPrimary && ( + {informationalReset} + )} +
+ {!compactOverview && detailText && ( +
{detailText}
+ )} +
+ ); + } + const row = metricRowPresentation( + { + snap, + lane, + providerId, + resetText: metricResetText(snap, resetTimeRelative, now, t), + compact: compactOverview, + showAsUsed, + showResetWhenExhausted, + paceEnabled: showPace, + usageThresholds, + weeklyProgressWorkDays, + now, + }, + t, + ); + const budget = showPace && !compactOverview ? metricPaceBudget(snap) : null; + const forecastText = formatSessionEquivalentEstimate(sessionEquivalentForecast); return (
- {title} - {!isInformational && ( -
-
-
- )} -
- - {isInformational - ? infoPrimary - : replacesPercent - ? resetText - : `${Math.round(displayPct)}% ${displayLabel}`} - - {!compactOverview && isInformational && - snap.resetDescription?.trim() && - resetText && - resetText !== infoPrimary && ( - {resetText} +
+ + {title} + {row.percentText && ( + <> + {" "} + {row.percentText} + )} - {!compactOverview && !isInformational && resetText && !replacesPercent && ( - {resetText} - )} + + {row.resetText && {row.resetText}}
+ + {row.metaText &&
{row.metaText}
} {!compactOverview && detailText && (
{detailText}
)} - {!compactOverview && !isInformational && snap.isExhausted && ( + {!compactOverview && snap.isExhausted && (
{exhaustedLabel}
)} - {!compactOverview && !isInformational && paceView.kind === "budget" && ( + {budget && (
{expanded &&
{[ - [t("PanelNow"), paceView.budget.now], - [t("PanelOneHour"), paceView.budget.nextHour], - [t("PanelFiveHours"), paceView.budget.nextFiveHours], - [t("PanelTodayBudget"), paceView.budget.today], + [t("PanelNow"), budget.now], + [t("PanelOneHour"), budget.nextHour], + [t("PanelFiveHours"), budget.nextFiveHours], + [t("PanelTodayBudget"), budget.today], ].map(([label, value]) => ( {label} {formatBudget(Number(value))}% @@ -479,15 +478,7 @@ function MetricRow({ {expanded && }
)} - {!compactOverview && !isInformational && paceView.kind === "reserve" && ( -
- {Math.round(paceView.percent)}% {t("PanelReserveSuffix")} - {reserveDescription && ( - {reserveDescription} - )} -
- )} - {!compactOverview && showPace && !isInformational && forecastText && ( + {!compactOverview && showPace && forecastText && (
{forecastText}
@@ -662,6 +653,8 @@ export default function MenuCardDetails({ key={m.id} title={m.label} snap={m.snap} + lane={m.lane} + providerId={provider.providerId} exhaustedLabel={t("DetailWindowExhausted")} display={metricDisplay} expanded={expandedPaceWindow === m.id} diff --git a/apps/desktop-tauri/src/components/UsageProgressBar.test.tsx b/apps/desktop-tauri/src/components/UsageProgressBar.test.tsx new file mode 100644 index 00000000000..a4cd8c3888f --- /dev/null +++ b/apps/desktop-tauri/src/components/UsageProgressBar.test.tsx @@ -0,0 +1,144 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import type { UsageBarModel } from "../lib/metricRowModel"; +import { loadStyles, ruleBlock } from "../test/styles"; +import UsageProgressBar from "./UsageProgressBar"; + +function bar(overrides: Partial = {}): UsageBarModel { + return { + fillPercent: 63, + valuePercent: 63, + valueText: "63%", + pacePercent: null, + paceOnTop: true, + markers: [], + ...overrides, + }; +} + +function attrs(container: HTMLElement, selector: string, name: string): Array { + return Array.from(container.querySelectorAll(selector), (node) => node.getAttribute(name)); +} + +/** The cut-out rects of the mask a group references through `mask="url(#id)"`. */ +function cuts(container: HTMLElement, group: Element | null) { + const id = group?.getAttribute("mask")?.match(/^url\(#(.+)\)$/)?.[1]; + const mask = id ? container.querySelector(`mask[id="${id}"]`) : null; + return Array.from(mask?.querySelectorAll("rect[fill='black']") ?? [], (rect) => ({ + x: rect.getAttribute("x"), + width: rect.getAttribute("width"), + opacity: rect.getAttribute("fill-opacity"), + })); +} + +describe("UsageProgressBar", () => { + it("exposes the value and markers as an accessible progress bar", () => { + render( + , + ); + const progress = screen.getByRole("progressbar", { name: "Session" }); + expect(progress.getAttribute("aria-valuenow")).toBe("63"); + expect(progress.getAttribute("aria-valuemin")).toBe("0"); + expect(progress.getAttribute("aria-valuemax")).toBe("100"); + expect(progress.getAttribute("aria-valuetext")).toBe("63%. Quota warnings: 10%, 30%"); + expect(progress.getAttribute("height")).toBe("6"); + }); + + it("draws a full rounded track and a fill as wide as the shown percent", () => { + const { container } = render(); + expect(attrs(container, ".menu-metric__progress-track", "width")).toEqual(["100%"]); + expect(attrs(container, ".menu-metric__progress-track", "rx")).toEqual(["3"]); + expect(attrs(container, ".menu-metric__progress-fill", "width")).toEqual(["37.5%"]); + expect(attrs(container, ".menu-metric__progress-fill", "rx")).toEqual(["3"]); + }); + + it("leaves an empty bar without a fill or masks", () => { + const { container } = render(); + expect(container.querySelector(".menu-metric__progress-fill")).toBeNull(); + expect(container.querySelector("mask")).toBeNull(); + expect(container.querySelector("[mask]")).toBeNull(); + }); + + it("punches 5px gaps for quota warnings and draws a 1px stripe in each", () => { + const { container } = render( + , + ); + expect(attrs(container, ".menu-metric__progress-warning", "x")).toEqual(["10%", "30%"]); + expect(attrs(container, ".menu-metric__progress-warning", "width")).toEqual(["1", "1"]); + expect(attrs(container, ".menu-metric__progress-warning", "transform")).toEqual([ + "translate(-0.5 0)", + "translate(-0.5 0)", + ]); + const fillGroup = container.querySelector(".menu-metric__progress-fill")?.parentElement ?? null; + expect(cuts(container, fillGroup)).toEqual([ + { x: "10%", width: "5", opacity: "0.9" }, + { x: "30%", width: "5", opacity: "0.9" }, + ]); + }); + + it("draws work day ticks in the lower half without punching the bar", () => { + const { container } = render( + , + ); + expect(attrs(container, ".menu-metric__progress-workday", "x")).toEqual(["20%", "40%"]); + expect(attrs(container, ".menu-metric__progress-workday", "y")).toEqual(["3", "3"]); + expect(attrs(container, ".menu-metric__progress-workday", "height")).toEqual(["3", "3"]); + expect(container.querySelector("mask")).toBeNull(); + }); + + it.each([ + [true, "false"], + [false, "true"], + ])("cuts a 6px gap for the pace stripe and marks paceOnTop %s as deficit %s", (paceOnTop, deficit) => { + const { container } = render( + , + ); + const stripe = container.querySelector(".menu-metric__progress-pace"); + expect(stripe?.getAttribute("x")).toBe("40%"); + expect(stripe?.getAttribute("width")).toBe("2"); + expect(stripe?.getAttribute("transform")).toBe("translate(-1 0)"); + expect(stripe?.getAttribute("data-deficit")).toBe(deficit); + const tick = container.querySelector(".menu-metric__progress-workday"); + const pacedGroup = tick?.parentElement ?? null; + expect(pacedGroup?.contains(container.querySelector(".menu-metric__progress-track"))).toBe(true); + expect(pacedGroup?.contains(stripe ?? null)).toBe(false); + expect(cuts(container, pacedGroup)).toEqual([{ x: "40%", width: "6", opacity: "0.9" }]); + }); + + it("colors the bar from the panel tokens and the pace stripe by direction", () => { + const css = loadStyles(); + expect(ruleBlock(css, ".menu-metric__progress-track")).toContain("fill: var(--usage-bar-track)"); + expect(ruleBlock(css, ".menu-metric__progress-fill")).toContain( + "fill: var(--provider-accent, var(--usage-bar-normal))", + ); + expect(ruleBlock(css, ".menu-metric__progress-warning")).toContain( + "fill: color-mix(in srgb, var(--text-primary) 68%, transparent)", + ); + expect(ruleBlock(css, ".menu-metric__progress-workday")).toContain( + "fill: color-mix(in srgb, var(--text-primary) 30%, transparent)", + ); + expect(ruleBlock(css, ".menu-metric__progress-pace")).toContain("fill: var(--mac-pace-ok, #34c759)"); + expect(ruleBlock(css, '.menu-metric__progress-pace[data-deficit="true"]')).toContain( + "fill: var(--mac-pace-bad, #ff3b30)", + ); + }); +}); diff --git a/apps/desktop-tauri/src/components/UsageProgressBar.tsx b/apps/desktop-tauri/src/components/UsageProgressBar.tsx new file mode 100644 index 00000000000..48dfe3bdc9c --- /dev/null +++ b/apps/desktop-tauri/src/components/UsageProgressBar.tsx @@ -0,0 +1,108 @@ +import { useId } from "react"; +import type { UsageBarModel } from "../lib/metricRowModel"; + +const HEIGHT = 6; +const RADIUS = 3; +const CUT_OPACITY = 0.9; +const WARNING_CUT = 5; +const WARNING_STRIPE = 1; +const WORKDAY_TICK = 1; +const PACE_CUT = 6; +const PACE_STRIPE = 2; + +/** A rect `width` px wide, centred on a percentage of the track. */ +function centered(percent: number, width: number) { + return { x: `${percent}%`, width, transform: `translate(${-width / 2} 0)` }; +} + +/** White keeps, each black cut keeps a tenth, like a 0.9 destination-out on macOS. */ +function CutMask({ id, cuts, width }: { id: string; cuts: number[]; width: number }) { + return ( + + + {cuts.map((percent) => ( + + ))} + + ); +} + +/** The 6px usage bar of a metric row, after the macOS `UsageProgressBar`. */ +export default function UsageProgressBar({ bar, label }: { bar: UsageBarModel; label: string }) { + const id = useId().replace(/:/g, ""); + const warnings = bar.markers.filter((marker) => marker.kind === "warning").map((marker) => marker.percent); + const workdays = bar.markers.filter((marker) => marker.kind === "workday").map((marker) => marker.percent); + const pace = bar.pacePercent; + return ( + + {warnings.length > 0 && } + {pace != null && } + + 0 ? `url(#${id}-warnings)` : undefined}> + + {bar.fillPercent > 0 && ( + + )} + + {warnings.map((percent) => ( + + ))} + {workdays.map((percent) => ( + + ))} + + {pace != null && ( + + )} + + ); +} diff --git a/apps/desktop-tauri/src/lib/metricRowModel.test.ts b/apps/desktop-tauri/src/lib/metricRowModel.test.ts new file mode 100644 index 00000000000..b73819cee7c --- /dev/null +++ b/apps/desktop-tauri/src/lib/metricRowModel.test.ts @@ -0,0 +1,383 @@ +import { describe, expect, it } from "vitest"; +import { fromFtl } from "../test/localeHarness"; +import type { RateWindowSnapshot, WindowPaceSnapshot } from "../types/bridge"; +import { + metricResetText, + metricRowPresentation, + type MetricRowInput, +} from "./metricRowModel"; + +const en = fromFtl("en-US.ftl"); +const NOW = new Date(2026, 9, 10, 15, 40).getTime(); +const MINUTE = 60_000; +const HOUR = 60 * MINUTE; +const DAY = 24 * HOUR; + +function rateWindow(overrides: Partial = {}): RateWindowSnapshot { + return { + usedPercent: 37, + remainingPercent: 63, + windowMinutes: 300, + resetsAt: null, + resetDescription: null, + isExhausted: false, + reservePercent: null, + reserveDescription: null, + ...overrides, + }; +} + +function pace(overrides: Partial = {}): WindowPaceSnapshot { + return { + stage: "behind", + deltaPercent: -23.4, + expectedUsedPercent: 60, + actualUsedPercent: 36.6, + etaSeconds: null, + willLastToReset: true, + ...overrides, + }; +} + +function input(overrides: Partial = {}): MetricRowInput { + return { + snap: rateWindow(), + lane: "primary", + providerId: "claude", + resetText: null, + compact: false, + showAsUsed: false, + showResetWhenExhausted: false, + paceEnabled: true, + usageThresholds: { highUsageThreshold: 70, criticalUsageThreshold: 90, providerUsageThresholds: {} }, + weeklyProgressWorkDays: null, + now: NOW, + ...overrides, + }; +} + +function thresholds(highUsageThreshold: number, criticalUsageThreshold = 90) { + return { highUsageThreshold, criticalUsageThreshold }; +} + +function weekly(overrides: Partial = {}): RateWindowSnapshot { + return rateWindow({ usedPercent: 36.6, remainingPercent: 63.4, windowMinutes: 10080, pace: pace(), ...overrides }); +} + +function resetIn(ms: number, relative = true) { + const snap = rateWindow({ resetsAt: new Date(NOW + ms).toISOString() }); + return metricResetText(snap, relative, NOW, en)?.replace(/\u202f/g, " "); +} + +describe("metricResetText", () => { + it.each([ + [2 * HOUR, "Resets in 2h"], + [HOUR + 59 * MINUTE + 30_000, "Resets in 2h"], + [2 * HOUR + 5 * MINUTE, "Resets in 2h 5m"], + [DAY + 3 * HOUR, "Resets in 1d 3h"], + [DAY + 5 * MINUTE, "Resets in 1d 5m"], + [2 * DAY, "Resets in 2d"], + [45_000, "Resets in 1m"], + [500, "Resets now"], + [-MINUTE, "Resets now"], + ])("counts %d ms down to %j with ceiling minutes", (ms, expected) => { + expect(resetIn(ms)).toBe(expected); + }); + + it.each([ + [new Date(2026, 9, 10, 18, 5), "Resets 6:05 PM"], + [new Date(2026, 9, 11, 9, 24), "Resets tomorrow, 9:24 AM"], + [new Date(2026, 9, 14, 9, 24), "Resets Oct 14, 9:24 AM"], + ])("formats the absolute reset %s as today, tomorrow or a date", (date, expected) => { + expect(resetIn(date.getTime() - NOW, false)).toBe(expected); + }); + + it("falls back to the provider reset description without a timestamp", () => { + expect(metricResetText(rateWindow({ resetDescription: "resets in 3h" }), true, NOW, en)).toBe( + "Resets in 3h", + ); + expect( + metricResetText( + rateWindow({ resetDescription: "750 / 1000 credits left", descriptionIsDetail: true }), + true, + NOW, + en, + ), + ).toBeNull(); + expect(metricResetText(rateWindow({ resetsAt: "not a date" }), true, NOW, en)).toBeNull(); + expect( + metricResetText( + rateWindow({ isInformational: true, resetDescription: "No active 5h session" }), + true, + NOW, + en, + ), + ).toBeNull(); + }); +}); + +describe("metricRowPresentation percent and reset", () => { + it.each([ + [37, false, "63% left"], + [37, true, "37% used"], + [37.5, false, "62% left"], + [36.5, false, "64% left"], + [99.6, false, "<1% left"], + [0.4, true, "<1% used"], + [115, true, "100% used"], + [115, false, "0% left"], + [-4, false, "100% left"], + ])("labels %d%% used with showAsUsed %s as %j", (used, showAsUsed, expected) => { + const row = metricRowPresentation( + input({ snap: rateWindow({ usedPercent: used, remainingPercent: 100 - used }), showAsUsed }), + en, + ); + expect(row.percentText).toBe(expected); + }); + + it("keeps the reset text beside the title and drops it in compact rows", () => { + expect(metricRowPresentation(input({ resetText: "Resets in 2h" }), en).resetText).toBe("Resets in 2h"); + expect( + metricRowPresentation(input({ resetText: "Resets in 2h", compact: true }), en).resetText, + ).toBeNull(); + }); + + it("replaces an exhausted percentage with a future reset, even in compact rows", () => { + const snap = rateWindow({ + usedPercent: 100, + remainingPercent: 0, + isExhausted: true, + resetsAt: new Date(NOW + HOUR).toISOString(), + }); + const row = metricRowPresentation( + input({ snap, resetText: "Resets in 1h", showResetWhenExhausted: true, compact: true }), + en, + ); + expect(row.percentText).toBeNull(); + expect(row.resetText).toBe("Resets in 1h"); + }); + + it("keeps the exhausted percentage when the reset already passed", () => { + const snap = rateWindow({ + usedPercent: 100, + remainingPercent: 0, + isExhausted: true, + resetsAt: new Date(NOW - HOUR).toISOString(), + }); + const row = metricRowPresentation( + input({ snap, resetText: "Resets now", showResetWhenExhausted: true }), + en, + ); + expect(row.percentText).toBe("0% left"); + }); +}); + +describe("metricRowPresentation pace", () => { + it("shows reserve and the lasts-until-reset note on a weekly window", () => { + const row = metricRowPresentation(input({ lane: "secondary", snap: weekly() }), en); + expect(row.metaText).toBe("23% in reserve · Lasts until reset"); + expect(row.bar.pacePercent).toBe(40); + expect(row.bar.paceOnTop).toBe(true); + }); + + it("shows a weekly deficit with its run-out countdown", () => { + const snap = weekly({ + usedPercent: 52.6, + remainingPercent: 47.4, + pace: pace({ + stage: "ahead", + deltaPercent: 12.6, + expectedUsedPercent: 40, + actualUsedPercent: 52.6, + etaSeconds: 2 * 86_400 + 5 * 3600, + willLastToReset: false, + }), + }); + const row = metricRowPresentation(input({ lane: "secondary", snap, showAsUsed: true }), en); + expect(row.metaText).toBe("13% in deficit · Runs out in 2d 5h"); + expect(row.bar.pacePercent).toBe(40); + expect(row.bar.paceOnTop).toBe(false); + }); + + it("projects a session window empty instead of running out", () => { + const snap = rateWindow({ + usedPercent: 54, + remainingPercent: 46, + pace: pace({ + stage: "slightly_ahead", + deltaPercent: 4, + expectedUsedPercent: 50, + actualUsedPercent: 54, + etaSeconds: 5400, + willLastToReset: false, + }), + }); + expect(metricRowPresentation(input({ snap }), en).metaText).toBe( + "4% in deficit · Projected empty in 1h 30m", + ); + const now = { ...snap, pace: { ...snap.pace!, etaSeconds: 0 } }; + expect(metricRowPresentation(input({ snap: now }), en).metaText).toBe( + "4% in deficit · Projected empty now", + ); + }); + + it("calls a rounded zero delta or an on-track stage on pace without a pace marker", () => { + const tiny = metricRowPresentation( + input({ lane: "secondary", snap: weekly({ pace: pace({ deltaPercent: -0.4 }) }) }), + en, + ); + expect(tiny.metaText).toBe("On pace · Lasts until reset"); + const onTrack = metricRowPresentation( + input({ + lane: "secondary", + snap: weekly({ pace: pace({ stage: "on_track", deltaPercent: 3, willLastToReset: false }) }), + }), + en, + ); + expect(onTrack.metaText).toBe("On pace"); + expect(onTrack.bar.pacePercent).toBeNull(); + }); + + it("runs a weekly window out now even below the 3% expected floor", () => { + const snap = weekly({ + pace: pace({ stage: "far_ahead", deltaPercent: 40, expectedUsedPercent: 2, etaSeconds: 0, willLastToReset: false }), + }); + expect(metricRowPresentation(input({ lane: "secondary", snap }), en).metaText).toBe( + "40% in deficit · Runs out now", + ); + const later = weekly({ + pace: pace({ stage: "far_ahead", deltaPercent: 40, expectedUsedPercent: 2, etaSeconds: 3600, willLastToReset: false }), + }); + expect(metricRowPresentation(input({ lane: "secondary", snap: later }), en).metaText).toBeNull(); + }); + + it.each<[string, Partial]>([ + ["pace is turned off", { lane: "secondary", snap: weekly(), paceEnabled: false }], + ["the window is used up", { lane: "secondary", snap: weekly({ usedPercent: 100, remainingPercent: 0 }) }], + ["a session expects under 3%", { snap: rateWindow({ pace: pace({ expectedUsedPercent: 2 }) }) }], + ["the lane is tertiary", { lane: "tertiary", snap: weekly() }], + ["an extra lane is not 5h or 7d", { lane: "extra", snap: weekly({ windowMinutes: 1440 }) }], + ["the primary window length is unknown", { snap: rateWindow({ windowMinutes: null, pace: pace() }) }], + ["the window is informational", { lane: "secondary", snap: weekly({ isInformational: true }) }], + ["the window has no pace", { lane: "secondary", snap: weekly({ pace: null }) }], + ])("hides pace when %s", (_, overrides) => { + const row = metricRowPresentation(input(overrides), en); + expect(row.metaText).toBeNull(); + expect(row.bar.pacePercent).toBeNull(); + }); + + it("shows pace on extra lanes that are exactly a session or a week", () => { + const row = metricRowPresentation(input({ lane: "extra", snap: weekly() }), en); + expect(row.metaText).toBe("23% in reserve · Lasts until reset"); + }); + + it("hides the pace line in compact rows but keeps the bar marker", () => { + const row = metricRowPresentation(input({ lane: "secondary", snap: weekly(), compact: true }), en); + expect(row.metaText).toBeNull(); + expect(row.bar.pacePercent).toBe(40); + }); +}); + +describe("metricRowPresentation bar", () => { + it.each([ + [37, false, 63], + [37, true, 37], + [99.6, false, 0], + [0.3, false, 100], + [115, true, 100], + [115, false, 0], + [62.4, false, 37.6], + ])("fills %d%% used with showAsUsed %s to %d%%", (used, showAsUsed, fill) => { + const row = metricRowPresentation( + input({ snap: rateWindow({ usedPercent: used, remainingPercent: 100 - used }), showAsUsed }), + en, + ); + expect(row.bar.fillPercent).toBeCloseTo(fill, 9); + }); + + it("places the Windows usage thresholds as quota warning markers", () => { + expect(metricRowPresentation(input(), en).bar.markers).toEqual([ + { percent: 10, kind: "warning" }, + { percent: 30, kind: "warning" }, + ]); + expect(metricRowPresentation(input({ showAsUsed: true }), en).bar.markers).toEqual([ + { percent: 70, kind: "warning" }, + { percent: 90, kind: "warning" }, + ]); + }); + + it("resolves window, provider and global thresholds per field", () => { + const providerUsageThresholds = { + claude: { high: 75 }, + "claude:weekly": { critical: 95 }, + codex: { high: 50, critical: 60 }, + }; + const usageThresholds = { ...thresholds(70), providerUsageThresholds }; + const weeklyRow = metricRowPresentation( + input({ lane: "secondary", snap: weekly(), showAsUsed: true, usageThresholds }), + en, + ); + expect(weeklyRow.bar.markers.map((m) => m.percent)).toEqual([75, 95]); + const sessionRow = metricRowPresentation(input({ showAsUsed: true, usageThresholds }), en); + expect(sessionRow.bar.markers.map((m) => m.percent)).toEqual([75, 90]); + const tertiaryRow = metricRowPresentation( + input({ lane: "tertiary", snap: weekly(), showAsUsed: true, usageThresholds }), + en, + ); + expect(tertiaryRow.bar.markers.map((m) => m.percent)).toEqual([75, 95]); + }); + + it("drops edge and duplicate thresholds and never marks extra lanes", () => { + expect(metricRowPresentation(input({ usageThresholds: thresholds(0, 100) }), en).bar.markers).toEqual([]); + expect(metricRowPresentation(input({ usageThresholds: thresholds(80, 80) }), en).bar.markers).toEqual([ + { percent: 20, kind: "warning" }, + ]); + expect(metricRowPresentation(input({ lane: "extra", snap: weekly() }), en).bar.markers).toEqual([]); + }); + + it("draws no warning markers without threshold settings", () => { + expect(metricRowPresentation(input({ usageThresholds: null }), en).bar.markers).toEqual([]); + }); + + it("adds work day ticks to a seven-day secondary window only", () => { + const row = metricRowPresentation( + input({ lane: "secondary", snap: weekly(), weeklyProgressWorkDays: 5, usageThresholds: thresholds(40) }), + en, + ); + expect(row.bar.markers).toEqual([ + { percent: 10, kind: "warning" }, + { percent: 20, kind: "workday" }, + { percent: 40, kind: "workday" }, + { percent: 60, kind: "warning" }, + { percent: 80, kind: "workday" }, + ]); + for (const overrides of [ + { lane: "primary" as const, snap: weekly() }, + { lane: "secondary" as const, snap: weekly({ windowMinutes: 1440 }) }, + { lane: "secondary" as const, snap: weekly(), weeklyProgressWorkDays: 1 }, + { lane: "secondary" as const, snap: weekly(), weeklyProgressWorkDays: 8 }, + { lane: "secondary" as const, snap: weekly(), weeklyProgressWorkDays: null }, + ]) { + const kinds = metricRowPresentation(input({ weeklyProgressWorkDays: 5, ...overrides }), en).bar.markers.map( + (m) => m.kind, + ); + expect(kinds).toEqual(["warning", "warning"]); + } + }); + + it("describes the value and markers for assistive technology", () => { + expect(metricRowPresentation(input(), en).bar).toMatchObject({ + valuePercent: 63, + valueText: "63%. Quota warnings: 10%, 30%", + }); + const row = metricRowPresentation( + input({ lane: "secondary", snap: weekly(), weeklyProgressWorkDays: 3, showAsUsed: true }), + en, + ); + expect(row.bar.valuePercent).toBe(37); + expect(row.bar.valueText).toBe("37%. Quota warnings: 70%, 90%. Work days: 33%, 67%"); + expect( + metricRowPresentation(input({ lane: "extra", snap: weekly({ pace: null }) }), en).bar.valueText, + ).toBe("63%"); + }); +}); diff --git a/apps/desktop-tauri/src/lib/metricRowModel.ts b/apps/desktop-tauri/src/lib/metricRowModel.ts new file mode 100644 index 00000000000..36c43b4d4c7 --- /dev/null +++ b/apps/desktop-tauri/src/lib/metricRowModel.ts @@ -0,0 +1,355 @@ +import { normalizeResetDescription } from "../hooks/useFormattedResetTime"; +import type { LocaleKey } from "../i18n/keys"; +import type { + PaceStage, + RateWindowSnapshot, + SettingsSnapshot, + WindowPaceSnapshot, +} from "../types/bridge"; +import { resetDescriptionFallback } from "./usageWindows"; + +type Translate = (key: LocaleKey) => string; + +/** The card slot a metric row fills. The slot decides its markers and pace. */ +export type MetricLane = "primary" | "secondary" | "tertiary" | "extra"; + +type PaceKind = "session" | "weekly"; + +/** The notification settings whose usage thresholds become quota warning markers. */ +export type UsageThresholdSettings = Pick< + SettingsSnapshot, + "highUsageThreshold" | "criticalUsageThreshold" | "providerUsageThresholds" +>; + +interface LanePolicy { + /** Notification window whose usage thresholds become quota warning markers. */ + thresholds: "session" | "weekly" | null; + workdayTicks: boolean; + pace: "byWindowLength" | "exactWindow" | "never"; +} + +const SESSION_MINUTES = 300; +const WEEK_MINUTES = 7 * 24 * 60; + +const LANE_POLICY: Record = { + primary: { thresholds: "session", workdayTicks: false, pace: "byWindowLength" }, + secondary: { thresholds: "weekly", workdayTicks: true, pace: "byWindowLength" }, + tertiary: { thresholds: "weekly", workdayTicks: false, pace: "never" }, + extra: { thresholds: null, workdayTicks: false, pace: "exactWindow" }, +}; + +const PACE_DIRECTION: Record = { + on_track: "onPace", + slightly_ahead: "deficit", + ahead: "deficit", + far_ahead: "deficit", + slightly_behind: "reserve", + behind: "reserve", + far_behind: "reserve", +}; + +interface CountdownKeys { + daysHours: LocaleKey; + daysMinutes: LocaleKey; + days: LocaleKey; + hoursMinutes: LocaleKey; + hours: LocaleKey; + minutes: LocaleKey; +} + +const RESET_KEYS: CountdownKeys = { + daysHours: "ResetsInDaysHours", + daysMinutes: "ResetsInDaysMinutes", + days: "ResetsInDaysOnly", + hoursMinutes: "ResetsInHoursMinutes", + hours: "ResetsInHoursOnly", + minutes: "ResetsInMinutes", +}; + +const DURATION_KEYS: CountdownKeys = { + daysHours: "DurationDaysHours", + daysMinutes: "DurationDaysMinutes", + days: "DurationDays", + hoursMinutes: "DurationHoursMinutes", + hours: "DurationHours", + minutes: "DurationMinutes", +}; + +export interface MetricRowInput { + snap: RateWindowSnapshot; + lane: MetricLane; + /** Provider CLI name, the key of the per-provider usage threshold overrides. */ + providerId: string; + /** Reset line for the row's wording mode, usually from `metricResetText`. */ + resetText: string | null; + compact: boolean; + showAsUsed: boolean; + showResetWhenExhausted: boolean; + paceEnabled: boolean; + /** Without thresholds the bar draws no quota warning markers. */ + usageThresholds: UsageThresholdSettings | null; + weeklyProgressWorkDays: number | null; + now: number; +} + +export interface UsageBarMarker { + percent: number; + kind: "warning" | "workday"; +} + +export interface UsageBarModel { + fillPercent: number; + valuePercent: number; + valueText: string; + pacePercent: number | null; + /** The pace stripe is green when usage is at or under the expected pace. */ + paceOnTop: boolean; + markers: UsageBarMarker[]; +} + +export interface MetricRowPresentation { + /** Follows the title, unless an exhausted row shows its reset in its place. */ + percentText: string | null; + resetText: string | null; + metaText: string | null; + bar: UsageBarModel; +} + +function fill(template: string, ...values: Array): string { + return values.reduce((text, value) => text.replace("{}", String(value)), template); +} + +function clampPercent(value: number): number { + return Math.min(100, Math.max(0, value)); +} + +/** `%.0f` rounds exact halves to even. */ +function roundHalfEven(value: number): number { + const rounded = Math.round(value); + return Math.abs(value % 1) === 0.5 && rounded % 2 !== 0 ? rounded - 1 : rounded; +} + +function percentLabel(percent: number, suffix: string): string { + if (percent > 0 && percent < 1) return `<1% ${suffix}`; + return `${roundHalfEven(percent)}% ${suffix}`; +} + +/** Partial minutes round up; under one second left is "now" (null). */ +function countdownParts(seconds: number) { + if (seconds < 1) return null; + const total = Math.max(1, Math.ceil(seconds / 60)); + return { days: Math.floor(total / 1440), hours: Math.floor(total / 60) % 24, minutes: total % 60 }; +} + +function formatCountdown(seconds: number, keys: CountdownKeys, t: Translate): string | null { + const parts = countdownParts(seconds); + if (!parts) return null; + const { days, hours, minutes } = parts; + if (days > 0) { + if (hours > 0) return fill(t(keys.daysHours), days, hours); + if (minutes > 0) return fill(t(keys.daysMinutes), days, minutes); + return fill(t(keys.days), days); + } + if (hours > 0) { + return minutes > 0 ? fill(t(keys.hoursMinutes), hours, minutes) : fill(t(keys.hours), hours); + } + return fill(t(keys.minutes), minutes); +} + +function intlLocale(t: Translate): string | undefined { + const locale = t("IntlLocale"); + return /^[a-z]{2}-[A-Z]{2}$/.test(locale) ? locale : undefined; +} + +function sameLocalDay(a: Date, b: Date): boolean { + return ( + a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate() + ); +} + +function absoluteResetDescription(target: Date, now: Date, t: Translate): string { + const locale = intlLocale(t); + const time = new Intl.DateTimeFormat(locale, { hour: "numeric", minute: "2-digit" }); + if (sameLocalDay(target, now)) return time.format(target); + const tomorrow = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1); + if (sameLocalDay(target, tomorrow)) return fill(t("ResetTomorrowAt"), time.format(target)); + return new Intl.DateTimeFormat(locale, { + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + }).format(target); +} + +/** + * The reset line of a metric row. A countdown or the absolute reset time when + * the window has a timestamp, else the provider's own reset wording. + * Informational windows show their description as content, never as a reset. + */ +export function metricResetText( + snap: RateWindowSnapshot, + relative: boolean, + now: number, + t: Translate, +): string | null { + const target = snap.resetsAt ? Date.parse(snap.resetsAt) : Number.NaN; + if (!Number.isFinite(target)) { + return snap.isInformational ? null : normalizeResetDescription(resetDescriptionFallback(snap), t); + } + if (!relative) { + return fill(t("ResetsAtLabel"), absoluteResetDescription(new Date(target), new Date(now), t)); + } + return formatCountdown((target - now) / 1000, RESET_KEYS, t) ?? t("ProviderTextResetsNow"); +} + +function paceKind(lane: MetricLane, windowMinutes: number | null): PaceKind | null { + const rule = LANE_POLICY[lane].pace; + if (rule === "never" || windowMinutes == null) return null; + if (rule === "exactWindow") { + if (windowMinutes === SESSION_MINUTES) return "session"; + return windowMinutes === WEEK_MINUTES ? "weekly" : null; + } + return windowMinutes <= SESSION_MINUTES ? "session" : "weekly"; +} + +function visiblePace(input: MetricRowInput): { pace: WindowPaceSnapshot; kind: PaceKind } | null { + const { snap } = input; + const pace = snap.pace; + if (!input.paceEnabled || !pace || snap.isInformational || !(snap.usedPercent < 100)) return null; + if (!Number.isFinite(pace.expectedUsedPercent) || !Number.isFinite(pace.actualUsedPercent)) return null; + const kind = paceKind(input.lane, snap.windowMinutes); + if (!kind) return null; + const expectsEnough = pace.expectedUsedPercent >= 3; + if (kind === "session" ? !expectsEnough : !expectsEnough && pace.etaSeconds !== 0) return null; + return { pace, kind }; +} + +function paceMetaText(pace: WindowPaceSnapshot, kind: PaceKind, t: Translate): string { + const delta = Math.round(Math.abs(pace.deltaPercent)); + const direction = PACE_DIRECTION[pace.stage]; + const left = + delta === 0 || direction === "onPace" + ? t("PaceOnPace") + : fill(t(direction === "deficit" ? "PaceInDeficit" : "PaceInReserve"), delta); + let right: string | null = null; + if (pace.willLastToReset) { + right = t("PaceLastsUntilReset"); + } else if (pace.etaSeconds != null) { + const duration = formatCountdown(pace.etaSeconds, DURATION_KEYS, t); + if (kind === "session") { + right = duration ? fill(t("PaceProjectedEmptyIn"), duration) : t("PaceProjectedEmptyNow"); + } else { + right = duration ? fill(t("PaceRunsOutIn"), duration) : t("PaceRunsOutNow"); + } + } + return right ? `${left} · ${right}` : left; +} + +function threshold( + settings: UsageThresholdSettings, + providerId: string, + window: "session" | "weekly", + field: "high" | "critical", +): number { + const overrides = settings.providerUsageThresholds ?? {}; + return ( + overrides[`${providerId}:${window}`]?.[field] ?? + overrides[providerId]?.[field] ?? + (field === "high" ? settings.highUsageThreshold : settings.criticalUsageThreshold) + ); +} + +function normalizedMarkerPercents(values: number[]): number[] { + const kept: number[] = []; + for (const value of values.map(clampPercent)) { + if (value > 0 && value < 100 && !kept.some((other) => Math.abs(other - value) < 0.001)) { + kept.push(value); + } + } + return kept; +} + +/** Usage thresholds are used-percents; markers sit where the bar reaches them. */ +function barMarkers(input: MetricRowInput): UsageBarMarker[] { + const policy = LANE_POLICY[input.lane]; + const window = policy.thresholds; + const settings = input.usageThresholds; + const warnings = + window && settings + ? normalizedMarkerPercents( + (["high", "critical"] as const).map((field) => { + const used = threshold(settings, input.providerId, window, field); + return input.showAsUsed ? used : 100 - used; + }), + ) + : []; + const workDays = input.weeklyProgressWorkDays; + const showsWorkdays = + policy.workdayTicks && + input.snap.windowMinutes === WEEK_MINUTES && + workDays != null && + workDays >= 2 && + workDays <= 7; + const workdays = showsWorkdays + ? normalizedMarkerPercents( + Array.from({ length: workDays - 1 }, (_, index) => ((index + 1) * 100) / workDays), + ).filter((day) => !warnings.some((warning) => Math.abs(warning - day) < 0.001)) + : []; + return [ + ...warnings.map((percent) => ({ percent, kind: "warning" as const })), + ...workdays.map((percent) => ({ percent, kind: "workday" as const })), + ].sort((a, b) => a.percent - b.percent); +} + +function barValueText(valuePercent: number, markers: UsageBarMarker[], t: Translate): string { + const parts = [`${valuePercent}%`]; + const groups: Array<[UsageBarMarker["kind"], LocaleKey]> = [ + ["warning", "UsageBarQuotaWarnings"], + ["workday", "UsageBarWorkDays"], + ]; + for (const [kind, key] of groups) { + const percents = markers.filter((marker) => marker.kind === kind).map((marker) => `${Math.round(marker.percent)}%`); + if (percents.length > 0) parts.push(`${t(key)}: ${percents.join(", ")}`); + } + return parts.join(". "); +} + +/** A sliver under half a percent stays empty and 99.5% and up fills the track. */ +function renderedFillPercent(percent: number): number { + const shown = Math.round(percent); + if (shown <= 0) return 0; + if (shown >= 100) return 100; + return percent; +} + +/** Percent, reset, pace line and bar of one metric row, after the macOS menu card. */ +export function metricRowPresentation(input: MetricRowInput, t: Translate): MetricRowPresentation { + const { snap, showAsUsed } = input; + const used = Number.isFinite(snap.usedPercent) ? Math.max(0, snap.usedPercent) : 0; + const shown = clampPercent(showAsUsed ? used : 100 - used); + const resetTarget = snap.resetsAt ? Date.parse(snap.resetsAt) : Number.NaN; + const replacesPercent = + input.showResetWhenExhausted && snap.isExhausted && resetTarget > input.now && input.resetText !== null; + const visible = visiblePace(input); + const markers = barMarkers(input); + const valuePercent = Math.round(shown); + const pacePercent = + visible && PACE_DIRECTION[visible.pace.stage] !== "onPace" + ? clampPercent(showAsUsed ? visible.pace.expectedUsedPercent : 100 - visible.pace.expectedUsedPercent) + : null; + return { + percentText: replacesPercent + ? null + : percentLabel(shown, t(showAsUsed ? "PanelUsedSuffix" : "PanelLeftSuffix")), + resetText: input.compact && !replacesPercent ? null : input.resetText, + metaText: visible && !input.compact ? paceMetaText(visible.pace, visible.kind, t) : null, + bar: { + fillPercent: renderedFillPercent(shown), + valuePercent, + valueText: barValueText(valuePercent, markers, t), + pacePercent, + paceOnTop: visible ? visible.pace.actualUsedPercent <= visible.pace.expectedUsedPercent : true, + markers, + }, + }; +} diff --git a/apps/desktop-tauri/src/lib/providerText.test.ts b/apps/desktop-tauri/src/lib/providerText.test.ts index 7905617c1af..1871172b29e 100644 --- a/apps/desktop-tauri/src/lib/providerText.test.ts +++ b/apps/desktop-tauri/src/lib/providerText.test.ts @@ -1,20 +1,8 @@ -import { readFileSync } from "node:fs"; import { describe, expect, it } from "vitest"; -import type { LocaleKey } from "../i18n/keys"; +import { fromFtl } from "../test/localeHarness"; import { localizeDates, localizeProviderText } from "./providerText"; import { localizeProviderLabel } from "./windowLabels"; -/** Translator backed by the shipped Fluent file, with `{ "{}" }` unescaped. */ -function fromFtl(file: string) { - const text = readFileSync(`${import.meta.dirname}/../../../../rust/src/locale/${file}`, "utf8"); - const table = new Map(); - for (const line of text.split(/\r?\n/)) { - const m = /^([A-Za-z][\w-]*)\s*=\s?(.*)$/.exec(line); - if (m) table.set(m[1], m[2].replace(/\{ "\{\}" \}/g, "{}")); - } - return (key: LocaleKey) => table.get(key) ?? key; -} - const en = fromFtl("en-US.ftl"); const ru = fromFtl("ru-RU.ftl"); diff --git a/apps/desktop-tauri/src/styles.css b/apps/desktop-tauri/src/styles.css index b2c32b6156a..76c1aa600de 100644 --- a/apps/desktop-tauri/src/styles.css +++ b/apps/desktop-tauri/src/styles.css @@ -3799,6 +3799,7 @@ html:has(.menu-surface--tray) { } .menu-surface--tray .menu-metric__pct, .menu-surface--tray .menu-metric__reset, +.menu-surface--tray .menu-metric__meta, .menu-surface--tray .menu-metric__status, .menu-surface--tray .menu-metric__detail { font-size: 10px; @@ -4288,14 +4289,29 @@ html:has(.menu-surface--tray) { color: var(--text-primary); } -/* Header row — title left, "N% left" right. Mirrors the macOS compact menu - layout where the remaining-percent label sits on the same baseline as the - metric title with the progress bar directly underneath. */ +/* macOS MetricRowHeader: the reset shares the title's baseline at least 24px + away. When both do not fit it drops under the title, right aligned, in up to + two lines (ViewThatFits horizontal, then vertical). */ .menu-metric__head { display: flex; + flex-wrap: wrap; align-items: baseline; - justify-content: space-between; - gap: 8px; + column-gap: 24px; + row-gap: 2px; + min-width: 0; +} + +.menu-metric__head .menu-metric__title { + min-width: 0; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.menu-metric__head .menu-metric__reset { + margin-left: auto; + max-width: 100%; } /* UsageProgressBar — Capsule track + Capsule fill, .frame(height: 6) */ @@ -4313,9 +4329,50 @@ html:has(.menu-surface--tray) { transition: width 0.3s ease; } -.menu-metric__bar-fill[data-level="high"] { background: var(--usage-bar-high); } -.menu-metric__bar-fill[data-level="critical"] { background: var(--usage-bar-critical); } -.menu-metric__bar-fill[data-level="exhausted"] { background: var(--usage-bar-exhausted); } +/* macOS UsageProgressBar: 6px track and fill, punched quota warning and pace + markers, work day ticks in the lower half. */ +.menu-metric__progress { + display: block; + width: 100%; + height: 6px; + overflow: hidden; +} + +.menu-metric__progress-track { + fill: var(--usage-bar-track); +} + +.menu-metric__progress-fill { + fill: var(--provider-accent, var(--usage-bar-normal)); +} + +.menu-metric__progress-warning { + fill: color-mix(in srgb, var(--text-primary) 68%, transparent); +} + +.menu-metric__progress-workday { + fill: color-mix(in srgb, var(--text-primary) 30%, transparent); +} + +.menu-metric__progress-pace { + fill: var(--mac-pace-ok, #34c759); +} + +.menu-metric__progress-pace[data-deficit="true"] { + fill: var(--mac-pace-bad, #ff3b30); +} + +/* Pace line under the bar: footnote, secondary, at most two lines. */ +.menu-metric__meta { + font-size: 11px; + color: var(--text-secondary); + min-width: 0; + overflow-wrap: break-word; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} /* HStack(.firstTextBaseline) percent label — reset countdown */ .menu-metric__row { diff --git a/apps/desktop-tauri/src/surfaces/TrayPanel.test.tsx b/apps/desktop-tauri/src/surfaces/TrayPanel.test.tsx index 0556b6466dd..9ca44d5d848 100644 --- a/apps/desktop-tauri/src/surfaces/TrayPanel.test.tsx +++ b/apps/desktop-tauri/src/surfaces/TrayPanel.test.tsx @@ -828,6 +828,23 @@ describe("TrayPanel provider grid", () => { }); }); + it("draws quota warning markers at the notification thresholds", async () => { + const { container } = renderTrayPanel([provider("claude", "Claude", 20)], { + showAsUsed: false, + highUsageThreshold: 75, + criticalUsageThreshold: 90, + providerUsageThresholds: { "claude:session": { critical: 95 } }, + }); + + await waitFor(() => { + expect( + Array.from(container.querySelectorAll(".menu-metric__progress-warning"), (node) => + node.getAttribute("x"), + ), + ).toEqual(["5%", "25%"]); + }); + }); + it("hides provider grid icons when the display setting is disabled", async () => { const { container } = renderTrayPanel( [provider("codex", "Codex"), provider("claude", "Claude")], diff --git a/apps/desktop-tauri/src/surfaces/TrayPanel.tsx b/apps/desktop-tauri/src/surfaces/TrayPanel.tsx index 82b13a5586f..390cf28a89e 100644 --- a/apps/desktop-tauri/src/surfaces/TrayPanel.tsx +++ b/apps/desktop-tauri/src/surfaces/TrayPanel.tsx @@ -141,6 +141,8 @@ export default function TrayPanel({ state }: { state: BootstrapState }) { compactOverview: selectedProviderId === null && settings.overviewLayout !== "detailed", costSummaryDisplayStyle: settings.costSummaryDisplayStyle, + usageThresholds: settings, + weeklyProgressWorkDays: settings.weeklyProgressWorkDays ?? null, }} accentColor={settings.providerAccentColors[p.providerId]} onLayoutChange={requestLayout} diff --git a/apps/desktop-tauri/src/test/localeHarness.tsx b/apps/desktop-tauri/src/test/localeHarness.tsx index 01673fd5946..844378e6c16 100644 --- a/apps/desktop-tauri/src/test/localeHarness.tsx +++ b/apps/desktop-tauri/src/test/localeHarness.tsx @@ -1,6 +1,18 @@ -import { ALL_LOCALE_KEYS } from "../i18n/keys"; +import { readFileSync } from "node:fs"; +import { ALL_LOCALE_KEYS, type LocaleKey } from "../i18n/keys"; import type { Language, LocaleStrings } from "../types/bridge"; +/** Translator backed by the shipped Fluent file, with `{ "{}" }` unescaped. */ +export function fromFtl(file: string): (key: LocaleKey) => string { + const text = readFileSync(`${import.meta.dirname}/../../../../rust/src/locale/${file}`, "utf8"); + const table = new Map(); + for (const line of text.split(/\r?\n/)) { + const m = /^([A-Za-z][\w-]*)\s*=\s?(.*)$/.exec(line); + if (m) table.set(m[1], m[2].replace(/\{ "\{\}" \}/g, "{}")); + } + return (key) => table.get(key) ?? key; +} + /** * Build a complete `LocaleStrings` bundle whose entries are the key names * themselves, so tests can assert on "the translator returned *something* From 9e50cfb65ca0cfd0bda88e58c7526eea57ce4e39 Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Sat, 10 Oct 2026 13:59:51 +0700 Subject: [PATCH 5/7] Remove the retired reserve row strings and styles The metric row's pace line replaced the separate reserve row. Its four PanelReserve* strings and the .menu-metric__reserve rules have no readers left. The locale tests now check the pace line's own translations instead. The reserve fields on RateWindowSnapshot stay. The PowerToys status pipe publishes RateWindowSnapshot as is, so dropping them would change that payload. --- .../src/components/MenuCard.test.tsx | 1 - apps/desktop-tauri/src/i18n/keys.ts | 4 ---- apps/desktop-tauri/src/styles.css | 8 ------- rust/src/locale.rs | 4 ---- rust/src/locale/en-US.ftl | 4 ---- rust/src/locale/es-MX.ftl | 4 ---- rust/src/locale/ja-JP.ftl | 4 ---- rust/src/locale/ko-KR.ftl | 4 ---- rust/src/locale/pt-BR.ftl | 4 ---- rust/src/locale/ru-RU.ftl | 4 ---- rust/src/locale/tests.rs | 22 +++++++------------ rust/src/locale/tr-TR.ftl | 4 ---- rust/src/locale/uk-UA.ftl | 4 ---- rust/src/locale/zh-CN.ftl | 4 ---- rust/src/locale/zh-TW.ftl | 4 ---- 15 files changed, 8 insertions(+), 71 deletions(-) diff --git a/apps/desktop-tauri/src/components/MenuCard.test.tsx b/apps/desktop-tauri/src/components/MenuCard.test.tsx index 9f5ad3aacf9..1bce9894bbd 100644 --- a/apps/desktop-tauri/src/components/MenuCard.test.tsx +++ b/apps/desktop-tauri/src/components/MenuCard.test.tsx @@ -135,7 +135,6 @@ describe("MenuCard", () => { PanelFiveHours: "5h", PanelBlockedByMonthlyLimit: "Blocked by monthly limit", PanelOnPaceBudget: "On-pace budget", - PanelReserveSuffix: "in reserve", PanelPeriodCost: "{} cost", PanelPeriodTokens: "{} tokens", CostPeriodShortMonthToDate: "MTD", diff --git a/apps/desktop-tauri/src/i18n/keys.ts b/apps/desktop-tauri/src/i18n/keys.ts index e9a0ee213f9..ab9a302a420 100644 --- a/apps/desktop-tauri/src/i18n/keys.ts +++ b/apps/desktop-tauri/src/i18n/keys.ts @@ -815,11 +815,7 @@ export const ALL_LOCALE_KEYS = [ "PanelOneHour", "PanelFiveHours", "PanelTodayBudget", - "PanelReserveSuffix", "PanelBlockedByMonthlyLimit", - "PanelReserveLastsUntilReset", - "PanelReserveRunsOutInDaysHours", - "PanelReserveRunsOutInHours", "FloatBarNoProviders", "FloatBarRemainingSuffix", "FloatBarShowCost", diff --git a/apps/desktop-tauri/src/styles.css b/apps/desktop-tauri/src/styles.css index 76c1aa600de..6fa7859d65c 100644 --- a/apps/desktop-tauri/src/styles.css +++ b/apps/desktop-tauri/src/styles.css @@ -3804,10 +3804,6 @@ html:has(.menu-surface--tray) { .menu-surface--tray .menu-metric__detail { font-size: 10px; } -.menu-surface--tray .menu-metric__reserve { - margin-top: 0; - font-size: 10px; -} /* Secondary charts and pace are available inside the Usage details disclosure. */ .menu-surface--tray .menu-card__local-grid { gap: 9px 20px; @@ -4446,10 +4442,6 @@ html:has(.menu-surface--tray) { text-overflow: ellipsis; white-space: nowrap; } -.menu-metric__reserve { - opacity: 0.7; - font-size: 10px; -} .menu-metric__budget { display: flex; diff --git a/rust/src/locale.rs b/rust/src/locale.rs index ea078edab91..f2be1d3b4c2 100644 --- a/rust/src/locale.rs +++ b/rust/src/locale.rs @@ -1093,11 +1093,7 @@ locale_keys! { PanelOneHour, PanelFiveHours, PanelTodayBudget, - PanelReserveSuffix, PanelBlockedByMonthlyLimit, - PanelReserveLastsUntilReset, - PanelReserveRunsOutInDaysHours, - PanelReserveRunsOutInHours, FloatBarNoProviders, FloatBarRemainingSuffix, FloatBarShowCost, diff --git a/rust/src/locale/en-US.ftl b/rust/src/locale/en-US.ftl index f9b820baec7..b763ff8b20f 100644 --- a/rust/src/locale/en-US.ftl +++ b/rust/src/locale/en-US.ftl @@ -681,11 +681,7 @@ PanelNow = now PanelOneHour = 1h PanelFiveHours = 5h PanelTodayBudget = today -PanelReserveSuffix = in reserve PanelBlockedByMonthlyLimit = Blocked by monthly limit -PanelReserveLastsUntilReset = Lasts until reset -PanelReserveRunsOutInDaysHours = Runs out in { "{}" }d { "{}" }h -PanelReserveRunsOutInHours = Runs out in { "{}" }h FloatBarNoProviders = No providers FloatBarRemainingSuffix = remaining BannerCheckingForUpdates = Checking for updates… diff --git a/rust/src/locale/es-MX.ftl b/rust/src/locale/es-MX.ftl index 335cdbfda41..d7604aee98f 100644 --- a/rust/src/locale/es-MX.ftl +++ b/rust/src/locale/es-MX.ftl @@ -699,11 +699,7 @@ PanelNow = ahora PanelOneHour = 1h PanelFiveHours = 5h PanelTodayBudget = hoy -PanelReserveSuffix = en reserva PanelBlockedByMonthlyLimit = Bloqueado por el límite mensual -PanelReserveLastsUntilReset = Dura hasta el reinicio -PanelReserveRunsOutInDaysHours = Se agota en { "{}" }d { "{}" }h -PanelReserveRunsOutInHours = Se agota en { "{}" }h FloatBarNoProviders = Sin proveedores FloatBarRemainingSuffix = restante BannerCheckingForUpdates = Buscando actualizaciones… diff --git a/rust/src/locale/ja-JP.ftl b/rust/src/locale/ja-JP.ftl index 8af9d9c60f9..30c75005b86 100644 --- a/rust/src/locale/ja-JP.ftl +++ b/rust/src/locale/ja-JP.ftl @@ -654,11 +654,7 @@ PanelNow = 現在 PanelOneHour = 1時間 PanelFiveHours = 5時間 PanelTodayBudget = 今日 -PanelReserveSuffix = 予備 PanelBlockedByMonthlyLimit = 月間上限によりブロック中 -PanelReserveLastsUntilReset = リセットまで持ちます -PanelReserveRunsOutInDaysHours = 残り { "{}" }日 { "{}" }時間 -PanelReserveRunsOutInHours = 残り { "{}" }時間 FloatBarNoProviders = プロバイダーなし FloatBarRemainingSuffix = 残り BannerCheckingForUpdates = 更新を確認中… diff --git a/rust/src/locale/ko-KR.ftl b/rust/src/locale/ko-KR.ftl index 2233ad84d73..15ce0273ec1 100644 --- a/rust/src/locale/ko-KR.ftl +++ b/rust/src/locale/ko-KR.ftl @@ -699,11 +699,7 @@ PanelNow = 지금 PanelOneHour = 1시간 PanelFiveHours = 5시간 PanelTodayBudget = 오늘 -PanelReserveSuffix = 예비 PanelBlockedByMonthlyLimit = 월간 한도로 차단됨 -PanelReserveLastsUntilReset = 리셋까지 지속 -PanelReserveRunsOutInDaysHours = { "{}" }일 { "{}" }시간 후 소진 -PanelReserveRunsOutInHours = { "{}" }시간 후 소진 FloatBarNoProviders = 공급자 없음 FloatBarRemainingSuffix = 남음 BannerCheckingForUpdates = 업데이트 확인 중… diff --git a/rust/src/locale/pt-BR.ftl b/rust/src/locale/pt-BR.ftl index 9d5660141a4..7e388db964c 100644 --- a/rust/src/locale/pt-BR.ftl +++ b/rust/src/locale/pt-BR.ftl @@ -652,11 +652,7 @@ PanelNow = agora PanelOneHour = 1h PanelFiveHours = 5h PanelTodayBudget = hoje -PanelReserveSuffix = na reserva PanelBlockedByMonthlyLimit = Bloqueado pelo limite mensal -PanelReserveLastsUntilReset = Dura até a renovação -PanelReserveRunsOutInDaysHours = Esgota em { "{}" }d { "{}" }h -PanelReserveRunsOutInHours = Esgota em { "{}" }h FloatBarNoProviders = Nenhum provedor FloatBarRemainingSuffix = restante BannerCheckingForUpdates = Verificando atualizações… diff --git a/rust/src/locale/ru-RU.ftl b/rust/src/locale/ru-RU.ftl index 05504dbb9e4..d5caad1014c 100644 --- a/rust/src/locale/ru-RU.ftl +++ b/rust/src/locale/ru-RU.ftl @@ -681,11 +681,7 @@ PanelNow = сейчас PanelOneHour = 1 час PanelFiveHours = 5 часов PanelTodayBudget = сегодня -PanelReserveSuffix = в резерве PanelBlockedByMonthlyLimit = Заблокировано месячным лимитом -PanelReserveLastsUntilReset = Длится до сброса -PanelReserveRunsOutInDaysHours = Заканчивается через { "{}" }d { "{}" }h -PanelReserveRunsOutInHours = Заканчивается через { "{}" }h FloatBarNoProviders = Нет провайдеров FloatBarRemainingSuffix = оставшееся BannerCheckingForUpdates = Проверка обновлений… diff --git a/rust/src/locale/tests.rs b/rust/src/locale/tests.rs index 569c6c0b002..6547a083dc3 100644 --- a/rust/src/locale/tests.rs +++ b/rust/src/locale/tests.rs @@ -157,11 +157,8 @@ fn test_japanese_tray_panel_locale_values_are_translated() { (LocaleKey::PanelOneHour, "1時間"), (LocaleKey::PanelFiveHours, "5時間"), (LocaleKey::PanelTodayBudget, "今日"), - (LocaleKey::PanelReserveSuffix, "予備"), - ( - LocaleKey::PanelReserveLastsUntilReset, - "リセットまで持ちます", - ), + (LocaleKey::PaceOnPace, "想定ペース"), + (LocaleKey::PaceLastsUntilReset, "リセットまで持ちます"), ( LocaleKey::PanelShowAllProviders, "すべてのプロバイダーを表示", @@ -193,8 +190,8 @@ fn test_chinese_tray_panel_locale_values_are_translated() { (LocaleKey::PanelOneHour, "1小时"), (LocaleKey::PanelFiveHours, "5小时"), (LocaleKey::PanelTodayBudget, "今日"), - (LocaleKey::PanelReserveSuffix, "储备"), - (LocaleKey::PanelReserveLastsUntilReset, "持续到重置"), + (LocaleKey::PaceOnPace, "节奏正常"), + (LocaleKey::PaceLastsUntilReset, "持续到重置"), ( LocaleKey::PanelEstimatedFromLocalLogs, "根据本地日志估算;可能与账单不同", @@ -227,8 +224,8 @@ fn test_korean_tray_panel_locale_values_are_translated() { (LocaleKey::PanelOneHour, "1시간"), (LocaleKey::PanelFiveHours, "5시간"), (LocaleKey::PanelTodayBudget, "오늘"), - (LocaleKey::PanelReserveSuffix, "예비"), - (LocaleKey::PanelReserveLastsUntilReset, "리셋까지 지속"), + (LocaleKey::PaceOnPace, "정상 속도"), + (LocaleKey::PaceLastsUntilReset, "리셋까지 지속"), ( LocaleKey::PanelEstimatedFromLocalLogs, "로컬 로그에서 추정; 청구서와 다를 수 있음", @@ -261,11 +258,8 @@ fn test_spanish_tray_panel_locale_values_are_translated() { (LocaleKey::PanelOneHour, "1h"), (LocaleKey::PanelFiveHours, "5h"), (LocaleKey::PanelTodayBudget, "hoy"), - (LocaleKey::PanelReserveSuffix, "en reserva"), - ( - LocaleKey::PanelReserveLastsUntilReset, - "Dura hasta el reinicio", - ), + (LocaleKey::PaceOnPace, "Al ritmo previsto"), + (LocaleKey::PaceLastsUntilReset, "Dura hasta el reinicio"), ( LocaleKey::PanelEstimatedFromLocalLogs, "Estimado desde logs locales; puede diferir de tu factura", diff --git a/rust/src/locale/tr-TR.ftl b/rust/src/locale/tr-TR.ftl index 8941e850163..332bea206b4 100644 --- a/rust/src/locale/tr-TR.ftl +++ b/rust/src/locale/tr-TR.ftl @@ -681,11 +681,7 @@ PanelNow = şimdi PanelOneHour = 1 sa PanelFiveHours = 5 sa PanelTodayBudget = bugün -PanelReserveSuffix = yedekte PanelBlockedByMonthlyLimit = Aylık limit nedeniyle engellendi -PanelReserveLastsUntilReset = Sıfırlamaya kadar sürer -PanelReserveRunsOutInDaysHours = { "{}" } g { "{}" } sa içinde tükenir -PanelReserveRunsOutInHours = { "{}" } sa içinde tükenir FloatBarNoProviders = Sağlayıcı yok FloatBarRemainingSuffix = kaldı BannerCheckingForUpdates = Güncellemeler denetleniyor… diff --git a/rust/src/locale/uk-UA.ftl b/rust/src/locale/uk-UA.ftl index 79ae6592001..0b884de32f2 100644 --- a/rust/src/locale/uk-UA.ftl +++ b/rust/src/locale/uk-UA.ftl @@ -681,11 +681,7 @@ PanelNow = зараз PanelOneHour = 1 год PanelFiveHours = 5 год PanelTodayBudget = сьогодні -PanelReserveSuffix = у запасі PanelBlockedByMonthlyLimit = Заблоковано місячним лімітом -PanelReserveLastsUntilReset = Вистачить до скидання -PanelReserveRunsOutInDaysHours = Закінчиться через { "{}" } дн. { "{}" } год -PanelReserveRunsOutInHours = Закінчиться через { "{}" } год FloatBarNoProviders = Немає провайдерів FloatBarRemainingSuffix = залишилося BannerCheckingForUpdates = Перевірка оновлень… diff --git a/rust/src/locale/zh-CN.ftl b/rust/src/locale/zh-CN.ftl index c78c2046280..8b91b1eb267 100644 --- a/rust/src/locale/zh-CN.ftl +++ b/rust/src/locale/zh-CN.ftl @@ -654,11 +654,7 @@ PanelNow = 现在 PanelOneHour = 1小时 PanelFiveHours = 5小时 PanelTodayBudget = 今日 -PanelReserveSuffix = 储备 PanelBlockedByMonthlyLimit = 已被月度限额阻止 -PanelReserveLastsUntilReset = 持续到重置 -PanelReserveRunsOutInDaysHours = { "{}" }天{ "{}" }小时后耗尽 -PanelReserveRunsOutInHours = { "{}" }小时后耗尽 FloatBarNoProviders = 无提供商 FloatBarRemainingSuffix = 剩余 BannerCheckingForUpdates = 正在检查更新… diff --git a/rust/src/locale/zh-TW.ftl b/rust/src/locale/zh-TW.ftl index 81532da61b4..8a16a2eca98 100644 --- a/rust/src/locale/zh-TW.ftl +++ b/rust/src/locale/zh-TW.ftl @@ -654,11 +654,7 @@ PanelNow = 現在 PanelOneHour = 1小時 PanelFiveHours = 5小時 PanelTodayBudget = 今日 -PanelReserveSuffix = 儲備 PanelBlockedByMonthlyLimit = 已被月度限額阻擋 -PanelReserveLastsUntilReset = 持續到重置 -PanelReserveRunsOutInDaysHours = { "{}" }天{ "{}" }小時後耗盡 -PanelReserveRunsOutInHours = { "{}" }小時後耗盡 FloatBarNoProviders = 無提供商 FloatBarRemainingSuffix = 剩餘 BannerCheckingForUpdates = 正在檢查更新… From c838edebf64a08590eec6c8a46b6d55a33a18c33 Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Sat, 10 Oct 2026 14:49:47 +0700 Subject: [PATCH 6/7] Trim comments and unreachable guards in the pace bar Drop comments that narrated the code or cited the Mac source. Rename where a name now carries what the comment said: the pace window fallbacks become SESSION_FALLBACK_MINUTES and WEEKLY_FALLBACK_MINUTES, paceOnTop becomes paceDeficit so the bar reads it without inverting, and the lane policy field becomes thresholdWindow. MetricRow returns early for informational windows, so the row model's own informational checks could not trigger. Remove them and their two test cases. --- .../src-tauri/src/commands/bridge.rs | 10 ++--- .../src-tauri/src/commands/bridge/pace.rs | 14 +++---- .../desktop-tauri/src/components/MenuCard.tsx | 1 - .../src/components/MenuCardDetails.tsx | 9 ---- .../src/components/UsageProgressBar.test.tsx | 17 ++++---- .../src/components/UsageProgressBar.tsx | 5 +-- .../src/lib/metricRowModel.test.ts | 13 +----- apps/desktop-tauri/src/lib/metricRowModel.ts | 41 ++++++------------- apps/desktop-tauri/src/styles.css | 6 --- rust/src/settings.rs | 4 +- rust/src/settings/tests.rs | 4 -- 11 files changed, 36 insertions(+), 88 deletions(-) diff --git a/apps/desktop-tauri/src-tauri/src/commands/bridge.rs b/apps/desktop-tauri/src-tauri/src/commands/bridge.rs index 09ed8bc7900..033f24a9b96 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/bridge.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/bridge.rs @@ -439,11 +439,11 @@ impl ProviderUsageSnapshot { let primary_snap = lane( &usage.primary, blocked.primary, - pace::SESSION_PACE_WINDOW_MINUTES, + pace::SESSION_FALLBACK_MINUTES, ); let secondary_snap = usage.secondary.as_ref().map(|sw| { - let mut s = lane(sw, blocked.secondary, pace::WEEKLY_PACE_WINDOW_MINUTES); + let mut s = lane(sw, blocked.secondary, pace::WEEKLY_FALLBACK_MINUTES); if let Some(ref p) = secondary_pace { s = s.with_pace_reserve(p); } @@ -488,11 +488,11 @@ impl ProviderUsageSnapshot { model_specific: usage .model_specific .as_ref() - .map(|w| lane(w, blocked.model_specific, pace::WEEKLY_PACE_WINDOW_MINUTES)), + .map(|w| lane(w, blocked.model_specific, pace::WEEKLY_FALLBACK_MINUTES)), tertiary: usage .tertiary .as_ref() - .map(|w| lane(w, blocked.tertiary, pace::WEEKLY_PACE_WINDOW_MINUTES)), + .map(|w| lane(w, blocked.tertiary, pace::WEEKLY_FALLBACK_MINUTES)), // F5 (upstream 0.48.0): label the tertiary lane by its duration cadence // so surfaces (MenuCard, CLI, tray) can show "Monthly" instead of the // generic "DetailWindowTertiary" slot key. @@ -511,7 +511,7 @@ impl ProviderUsageSnapshot { .map(|(extra, &is_blocked)| NamedRateWindowSnapshot { id: extra.id.clone(), title: extra.title.clone(), - window: lane(&extra.window, is_blocked, pace::WEEKLY_PACE_WINDOW_MINUTES), + window: lane(&extra.window, is_blocked, pace::WEEKLY_FALLBACK_MINUTES), fallback_lane: extra.fallback_lane, icon_fallback: extra.icon_fallback, }) diff --git a/apps/desktop-tauri/src-tauri/src/commands/bridge/pace.rs b/apps/desktop-tauri/src-tauri/src/commands/bridge/pace.rs index e9b847a5e4a..b43801b98b6 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/bridge/pace.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/bridge/pace.rs @@ -1,10 +1,8 @@ use super::*; use chrono::{DateTime, Utc}; -/// Default length of a session lane without `window_minutes` (Mac `UsagePaceText`). -pub(crate) const SESSION_PACE_WINDOW_MINUTES: u32 = 300; -/// Default length of every other lane without `window_minutes`. -pub(crate) const WEEKLY_PACE_WINDOW_MINUTES: u32 = 10_080; +pub(crate) const SESSION_FALLBACK_MINUTES: u32 = 300; +pub(crate) const WEEKLY_FALLBACK_MINUTES: u32 = 10_080; pub(crate) fn stage_str(stage: codexbar::core::PaceStage) -> &'static str { use codexbar::core::PaceStage; @@ -97,7 +95,7 @@ mod tests { let window = resetting_in(fixed_now(), 30.0, Some(10_080), 5_040); assert_eq!( - WindowPaceSnapshot::for_window(&window, WEEKLY_PACE_WINDOW_MINUTES, fixed_now()), + WindowPaceSnapshot::for_window(&window, WEEKLY_FALLBACK_MINUTES, fixed_now()), Some(WindowPaceSnapshot { stage: "far_behind".to_string(), delta_percent: -20.0, @@ -113,7 +111,7 @@ mod tests { fn deficit_is_a_positive_delta_with_an_eta() { let window = resetting_in(fixed_now(), 55.0, Some(10_080), 5_040); - let pace = WindowPaceSnapshot::for_window(&window, WEEKLY_PACE_WINDOW_MINUTES, fixed_now()) + let pace = WindowPaceSnapshot::for_window(&window, WEEKLY_FALLBACK_MINUTES, fixed_now()) .expect("pace"); assert_eq!(pace.stage, "slightly_ahead"); @@ -128,7 +126,7 @@ mod tests { window.is_informational = true; assert_eq!( - WindowPaceSnapshot::for_window(&window, WEEKLY_PACE_WINDOW_MINUTES, fixed_now()), + WindowPaceSnapshot::for_window(&window, WEEKLY_FALLBACK_MINUTES, fixed_now()), None ); } @@ -188,7 +186,7 @@ mod tests { #[test] fn window_pace_serializes_in_camel_case() { let window = resetting_in(fixed_now(), 30.0, Some(10_080), 5_040); - let pace = WindowPaceSnapshot::for_window(&window, WEEKLY_PACE_WINDOW_MINUTES, fixed_now()); + let pace = WindowPaceSnapshot::for_window(&window, WEEKLY_FALLBACK_MINUTES, fixed_now()); assert_eq!( serde_json::to_string(&pace).unwrap(), diff --git a/apps/desktop-tauri/src/components/MenuCard.tsx b/apps/desktop-tauri/src/components/MenuCard.tsx index 6334a12ff6f..8e02537ff8f 100644 --- a/apps/desktop-tauri/src/components/MenuCard.tsx +++ b/apps/desktop-tauri/src/components/MenuCard.tsx @@ -61,7 +61,6 @@ export interface MenuCardDisplayOptions { */ compactOverview?: boolean; costSummaryDisplayStyle?: CostSummaryDisplayStyle; - /** Notification thresholds, drawn as quota warning markers on the bars. */ usageThresholds?: UsageThresholdSettings | null; weeklyProgressWorkDays?: number | null; } diff --git a/apps/desktop-tauri/src/components/MenuCardDetails.tsx b/apps/desktop-tauri/src/components/MenuCardDetails.tsx index 1480ee2c142..40581f4debb 100644 --- a/apps/desktop-tauri/src/components/MenuCardDetails.tsx +++ b/apps/desktop-tauri/src/components/MenuCardDetails.tsx @@ -294,7 +294,6 @@ export interface MetricEntry { sessionEquivalentForecast?: SessionEquivalentForecastSnapshot | null; } -/** On-pace budget of a weekly window that still has quota left. */ function metricPaceBudget(snap: RateWindowSnapshot): PaceBudget | null { if (snap.isExhausted) return null; const isWeeklyWindow = @@ -310,12 +309,10 @@ type MetricRowDisplay = { compactOverview?: boolean; costSummaryDisplayStyle?: CostSummaryDisplayStyle; monthlyLimitBlockNow?: number; - /** Notification thresholds, drawn as quota warning markers on the bars. */ usageThresholds?: UsageThresholdSettings | null; weeklyProgressWorkDays?: number | null; }; -/** The current time, re-read every 30 s while a reset countdown is shown. */ function useCountdownNow(ticking: boolean): number { const [, setTick] = useState(0); useEffect(() => { @@ -326,12 +323,6 @@ function useCountdownNow(ticking: boolean): number { return Date.now(); } -/** - * Single metric row inside the card, after upstream `MetricRow`: - * • title and percent, with the reset on the same baseline or under it - * • UsageProgressBar (6pt) with quota warning, work day and pace markers - * • pace line (footnote, secondary) - */ function MetricRow({ title, snap, diff --git a/apps/desktop-tauri/src/components/UsageProgressBar.test.tsx b/apps/desktop-tauri/src/components/UsageProgressBar.test.tsx index a4cd8c3888f..a2fd5b5e4c4 100644 --- a/apps/desktop-tauri/src/components/UsageProgressBar.test.tsx +++ b/apps/desktop-tauri/src/components/UsageProgressBar.test.tsx @@ -10,7 +10,7 @@ function bar(overrides: Partial = {}): UsageBarModel { valuePercent: 63, valueText: "63%", pacePercent: null, - paceOnTop: true, + paceDeficit: false, markers: [], ...overrides, }; @@ -20,8 +20,7 @@ function attrs(container: HTMLElement, selector: string, name: string): Array node.getAttribute(name)); } -/** The cut-out rects of the mask a group references through `mask="url(#id)"`. */ -function cuts(container: HTMLElement, group: Element | null) { +function maskCutsOf(container: HTMLElement, group: Element | null) { const id = group?.getAttribute("mask")?.match(/^url\(#(.+)\)$/)?.[1]; const mask = id ? container.querySelector(`mask[id="${id}"]`) : null; return Array.from(mask?.querySelectorAll("rect[fill='black']") ?? [], (rect) => ({ @@ -78,7 +77,7 @@ describe("UsageProgressBar", () => { "translate(-0.5 0)", ]); const fillGroup = container.querySelector(".menu-metric__progress-fill")?.parentElement ?? null; - expect(cuts(container, fillGroup)).toEqual([ + expect(maskCutsOf(container, fillGroup)).toEqual([ { x: "10%", width: "5", opacity: "0.9" }, { x: "30%", width: "5", opacity: "0.9" }, ]); @@ -103,13 +102,13 @@ describe("UsageProgressBar", () => { }); it.each([ - [true, "false"], - [false, "true"], - ])("cuts a 6px gap for the pace stripe and marks paceOnTop %s as deficit %s", (paceOnTop, deficit) => { + [false, "false"], + [true, "true"], + ])("cuts a 6px gap for the pace stripe and marks paceDeficit %s as deficit %s", (paceDeficit, deficit) => { const { container } = render( , ); const stripe = container.querySelector(".menu-metric__progress-pace"); @@ -121,7 +120,7 @@ describe("UsageProgressBar", () => { const pacedGroup = tick?.parentElement ?? null; expect(pacedGroup?.contains(container.querySelector(".menu-metric__progress-track"))).toBe(true); expect(pacedGroup?.contains(stripe ?? null)).toBe(false); - expect(cuts(container, pacedGroup)).toEqual([{ x: "40%", width: "6", opacity: "0.9" }]); + expect(maskCutsOf(container, pacedGroup)).toEqual([{ x: "40%", width: "6", opacity: "0.9" }]); }); it("colors the bar from the panel tokens and the pace stripe by direction", () => { diff --git a/apps/desktop-tauri/src/components/UsageProgressBar.tsx b/apps/desktop-tauri/src/components/UsageProgressBar.tsx index 48dfe3bdc9c..a541325ed8d 100644 --- a/apps/desktop-tauri/src/components/UsageProgressBar.tsx +++ b/apps/desktop-tauri/src/components/UsageProgressBar.tsx @@ -10,12 +10,10 @@ const WORKDAY_TICK = 1; const PACE_CUT = 6; const PACE_STRIPE = 2; -/** A rect `width` px wide, centred on a percentage of the track. */ function centered(percent: number, width: number) { return { x: `${percent}%`, width, transform: `translate(${-width / 2} 0)` }; } -/** White keeps, each black cut keeps a tenth, like a 0.9 destination-out on macOS. */ function CutMask({ id, cuts, width }: { id: string; cuts: number[]; width: number }) { return ( marker.kind === "warning").map((marker) => marker.percent); @@ -97,7 +94,7 @@ export default function UsageProgressBar({ bar, label }: { bar: UsageBarModel; l {pace != null && ( { ), ).toBeNull(); expect(metricResetText(rateWindow({ resetsAt: "not a date" }), true, NOW, en)).toBeNull(); - expect( - metricResetText( - rateWindow({ isInformational: true, resetDescription: "No active 5h session" }), - true, - NOW, - en, - ), - ).toBeNull(); }); }); @@ -177,7 +169,7 @@ describe("metricRowPresentation pace", () => { const row = metricRowPresentation(input({ lane: "secondary", snap: weekly() }), en); expect(row.metaText).toBe("23% in reserve · Lasts until reset"); expect(row.bar.pacePercent).toBe(40); - expect(row.bar.paceOnTop).toBe(true); + expect(row.bar.paceDeficit).toBe(false); }); it("shows a weekly deficit with its run-out countdown", () => { @@ -196,7 +188,7 @@ describe("metricRowPresentation pace", () => { const row = metricRowPresentation(input({ lane: "secondary", snap, showAsUsed: true }), en); expect(row.metaText).toBe("13% in deficit · Runs out in 2d 5h"); expect(row.bar.pacePercent).toBe(40); - expect(row.bar.paceOnTop).toBe(false); + expect(row.bar.paceDeficit).toBe(true); }); it("projects a session window empty instead of running out", () => { @@ -258,7 +250,6 @@ describe("metricRowPresentation pace", () => { ["the lane is tertiary", { lane: "tertiary", snap: weekly() }], ["an extra lane is not 5h or 7d", { lane: "extra", snap: weekly({ windowMinutes: 1440 }) }], ["the primary window length is unknown", { snap: rateWindow({ windowMinutes: null, pace: pace() }) }], - ["the window is informational", { lane: "secondary", snap: weekly({ isInformational: true }) }], ["the window has no pace", { lane: "secondary", snap: weekly({ pace: null }) }], ])("hides pace when %s", (_, overrides) => { const row = metricRowPresentation(input(overrides), en); diff --git a/apps/desktop-tauri/src/lib/metricRowModel.ts b/apps/desktop-tauri/src/lib/metricRowModel.ts index 36c43b4d4c7..a2bbeda95b5 100644 --- a/apps/desktop-tauri/src/lib/metricRowModel.ts +++ b/apps/desktop-tauri/src/lib/metricRowModel.ts @@ -10,20 +10,17 @@ import { resetDescriptionFallback } from "./usageWindows"; type Translate = (key: LocaleKey) => string; -/** The card slot a metric row fills. The slot decides its markers and pace. */ export type MetricLane = "primary" | "secondary" | "tertiary" | "extra"; type PaceKind = "session" | "weekly"; -/** The notification settings whose usage thresholds become quota warning markers. */ export type UsageThresholdSettings = Pick< SettingsSnapshot, "highUsageThreshold" | "criticalUsageThreshold" | "providerUsageThresholds" >; interface LanePolicy { - /** Notification window whose usage thresholds become quota warning markers. */ - thresholds: "session" | "weekly" | null; + thresholdWindow: "session" | "weekly" | null; workdayTicks: boolean; pace: "byWindowLength" | "exactWindow" | "never"; } @@ -32,10 +29,10 @@ const SESSION_MINUTES = 300; const WEEK_MINUTES = 7 * 24 * 60; const LANE_POLICY: Record = { - primary: { thresholds: "session", workdayTicks: false, pace: "byWindowLength" }, - secondary: { thresholds: "weekly", workdayTicks: true, pace: "byWindowLength" }, - tertiary: { thresholds: "weekly", workdayTicks: false, pace: "never" }, - extra: { thresholds: null, workdayTicks: false, pace: "exactWindow" }, + primary: { thresholdWindow: "session", workdayTicks: false, pace: "byWindowLength" }, + secondary: { thresholdWindow: "weekly", workdayTicks: true, pace: "byWindowLength" }, + tertiary: { thresholdWindow: "weekly", workdayTicks: false, pace: "never" }, + extra: { thresholdWindow: null, workdayTicks: false, pace: "exactWindow" }, }; const PACE_DIRECTION: Record = { @@ -78,15 +75,12 @@ const DURATION_KEYS: CountdownKeys = { export interface MetricRowInput { snap: RateWindowSnapshot; lane: MetricLane; - /** Provider CLI name, the key of the per-provider usage threshold overrides. */ providerId: string; - /** Reset line for the row's wording mode, usually from `metricResetText`. */ resetText: string | null; compact: boolean; showAsUsed: boolean; showResetWhenExhausted: boolean; paceEnabled: boolean; - /** Without thresholds the bar draws no quota warning markers. */ usageThresholds: UsageThresholdSettings | null; weeklyProgressWorkDays: number | null; now: number; @@ -102,13 +96,11 @@ export interface UsageBarModel { valuePercent: number; valueText: string; pacePercent: number | null; - /** The pace stripe is green when usage is at or under the expected pace. */ - paceOnTop: boolean; + paceDeficit: boolean; markers: UsageBarMarker[]; } export interface MetricRowPresentation { - /** Follows the title, unless an exhausted row shows its reset in its place. */ percentText: string | null; resetText: string | null; metaText: string | null; @@ -134,7 +126,6 @@ function percentLabel(percent: number, suffix: string): string { return `${roundHalfEven(percent)}% ${suffix}`; } -/** Partial minutes round up; under one second left is "now" (null). */ function countdownParts(seconds: number) { if (seconds < 1) return null; const total = Math.max(1, Math.ceil(seconds / 60)); @@ -181,11 +172,6 @@ function absoluteResetDescription(target: Date, now: Date, t: Translate): string }).format(target); } -/** - * The reset line of a metric row. A countdown or the absolute reset time when - * the window has a timestamp, else the provider's own reset wording. - * Informational windows show their description as content, never as a reset. - */ export function metricResetText( snap: RateWindowSnapshot, relative: boolean, @@ -194,7 +180,7 @@ export function metricResetText( ): string | null { const target = snap.resetsAt ? Date.parse(snap.resetsAt) : Number.NaN; if (!Number.isFinite(target)) { - return snap.isInformational ? null : normalizeResetDescription(resetDescriptionFallback(snap), t); + return normalizeResetDescription(resetDescriptionFallback(snap), t); } if (!relative) { return fill(t("ResetsAtLabel"), absoluteResetDescription(new Date(target), new Date(now), t)); @@ -215,7 +201,7 @@ function paceKind(lane: MetricLane, windowMinutes: number | null): PaceKind | nu function visiblePace(input: MetricRowInput): { pace: WindowPaceSnapshot; kind: PaceKind } | null { const { snap } = input; const pace = snap.pace; - if (!input.paceEnabled || !pace || snap.isInformational || !(snap.usedPercent < 100)) return null; + if (!input.paceEnabled || !pace || !(snap.usedPercent < 100)) return null; if (!Number.isFinite(pace.expectedUsedPercent) || !Number.isFinite(pace.actualUsedPercent)) return null; const kind = paceKind(input.lane, snap.windowMinutes); if (!kind) return null; @@ -269,17 +255,16 @@ function normalizedMarkerPercents(values: number[]): number[] { return kept; } -/** Usage thresholds are used-percents; markers sit where the bar reaches them. */ function barMarkers(input: MetricRowInput): UsageBarMarker[] { const policy = LANE_POLICY[input.lane]; - const window = policy.thresholds; + const window = policy.thresholdWindow; const settings = input.usageThresholds; const warnings = window && settings ? normalizedMarkerPercents( (["high", "critical"] as const).map((field) => { - const used = threshold(settings, input.providerId, window, field); - return input.showAsUsed ? used : 100 - used; + const usedThreshold = threshold(settings, input.providerId, window, field); + return input.showAsUsed ? usedThreshold : 100 - usedThreshold; }), ) : []; @@ -314,7 +299,6 @@ function barValueText(valuePercent: number, markers: UsageBarMarker[], t: Transl return parts.join(". "); } -/** A sliver under half a percent stays empty and 99.5% and up fills the track. */ function renderedFillPercent(percent: number): number { const shown = Math.round(percent); if (shown <= 0) return 0; @@ -322,7 +306,6 @@ function renderedFillPercent(percent: number): number { return percent; } -/** Percent, reset, pace line and bar of one metric row, after the macOS menu card. */ export function metricRowPresentation(input: MetricRowInput, t: Translate): MetricRowPresentation { const { snap, showAsUsed } = input; const used = Number.isFinite(snap.usedPercent) ? Math.max(0, snap.usedPercent) : 0; @@ -348,7 +331,7 @@ export function metricRowPresentation(input: MetricRowInput, t: Translate): Metr valuePercent, valueText: barValueText(valuePercent, markers, t), pacePercent, - paceOnTop: visible ? visible.pace.actualUsedPercent <= visible.pace.expectedUsedPercent : true, + paceDeficit: visible ? visible.pace.actualUsedPercent > visible.pace.expectedUsedPercent : false, markers, }, }; diff --git a/apps/desktop-tauri/src/styles.css b/apps/desktop-tauri/src/styles.css index 6fa7859d65c..41ac1418b0c 100644 --- a/apps/desktop-tauri/src/styles.css +++ b/apps/desktop-tauri/src/styles.css @@ -4285,9 +4285,6 @@ html:has(.menu-surface--tray) { color: var(--text-primary); } -/* macOS MetricRowHeader: the reset shares the title's baseline at least 24px - away. When both do not fit it drops under the title, right aligned, in up to - two lines (ViewThatFits horizontal, then vertical). */ .menu-metric__head { display: flex; flex-wrap: wrap; @@ -4325,8 +4322,6 @@ html:has(.menu-surface--tray) { transition: width 0.3s ease; } -/* macOS UsageProgressBar: 6px track and fill, punched quota warning and pace - markers, work day ticks in the lower half. */ .menu-metric__progress { display: block; width: 100%; @@ -4358,7 +4353,6 @@ html:has(.menu-surface--tray) { fill: var(--mac-pace-bad, #ff3b30); } -/* Pace line under the bar: footnote, secondary, at most two lines. */ .menu-metric__meta { font-size: 11px; color: var(--text-secondary); diff --git a/rust/src/settings.rs b/rust/src/settings.rs index fe22e0ef7e0..ef0ba7abace 100755 --- a/rust/src/settings.rs +++ b/rust/src/settings.rs @@ -605,8 +605,8 @@ impl Default for Settings { menu_bar_shows_highest_usage: false, menu_bar_shows_percent: false, menu_bar_color_pace: false, - show_as_used: false, // "% left"; a saved file keeps its own value - enable_animations: true, // Animations enabled by default + show_as_used: false, + enable_animations: true, // Animations enabled by default reset_time_relative: true, // Show relative times by default show_reset_when_exhausted: false, predictive_pace_warning_enabled: false, diff --git a/rust/src/settings/tests.rs b/rust/src/settings/tests.rs index 8803fca4cd6..36e5635a4fb 100644 --- a/rust/src/settings/tests.rs +++ b/rust/src/settings/tests.rs @@ -1492,9 +1492,6 @@ fn cookie_denial_round_trips_through_settings_persistence() { assert!(!loaded.openai_web_extras(ProviderId::Codex)); } -/// Decision 7.2 of the Mac card rework: "% left" is the default only where no -/// choice was ever saved, so a new install and a settings file without the key -/// both show remaining quota. #[test] fn show_as_used_defaults_to_left_without_a_saved_value() { let dir = tempfile::tempdir().unwrap(); @@ -1506,7 +1503,6 @@ fn show_as_used_defaults_to_left_without_a_saved_value() { assert!(!Settings::load_from_path(Some(&without_key)).show_as_used); } -/// Existing users keep the "% used" choice their settings file already holds. #[test] fn show_as_used_keeps_a_saved_used_preference() { let dir = tempfile::tempdir().unwrap(); From 7e46e48e951db409f1ae59d005ec117d25300997 Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Sat, 10 Oct 2026 22:31:59 +0700 Subject: [PATCH 7/7] Wait for the settled height cap in the Panel scale sizing test --- .../src/hooks/useTrayPanelLayout.sizing.test.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/desktop-tauri/src/hooks/useTrayPanelLayout.sizing.test.tsx b/apps/desktop-tauri/src/hooks/useTrayPanelLayout.sizing.test.tsx index f4578071b9c..247792fc619 100644 --- a/apps/desktop-tauri/src/hooks/useTrayPanelLayout.sizing.test.tsx +++ b/apps/desktop-tauri/src/hooks/useTrayPanelLayout.sizing.test.tsx @@ -349,8 +349,11 @@ describe("useTrayPanelLayout sizing", () => { timeout: 3000, }); + // A follow-up pass can lift the cap while it measures; wait for it to settle. + await waitFor(() => expect(surface.style.maxHeight).toBe("400px"), { + timeout: 3000, + }); expect(windowMocks.setSize.mock.calls[0][0]).toEqual({ width: 465, height: 200 }); expect(lastResize()).toEqual({ width: 465, height: 600 }); - expect(surface.style.maxHeight).toBe("400px"); }); });