diff --git a/scripts/verify/verify-model-contract.js b/scripts/verify/verify-model-contract.js index 7b89ea7..11650e5 100644 --- a/scripts/verify/verify-model-contract.js +++ b/scripts/verify/verify-model-contract.js @@ -85,4 +85,10 @@ for (const contract of contracts) { } } +for (const field of ['sourceKey', 'labelKey', 'localUsageSourceKey', 'piUsageSourceKey']) { + if (contracts.some((contract) => typeScriptFields(contract).has(field))) { + throw new Error(`Translation key field ${field} must not cross the Rust/TypeScript contract.`); + } +} + console.log(`${contracts.length} Rust/TypeScript model field contracts match.`); diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index f27d5df..0082eca 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2845,6 +2845,7 @@ dependencies = [ "chrono", "fontdue", "notify-rust", + "objc2-foundation", "regex", "reqwest", "roxmltree", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index e56ead4..13d9ddc 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -44,12 +44,13 @@ tauri-plugin-single-instance = "2" [target.'cfg(target_os = "macos")'.dependencies] fontdue = { version = "0.9.3", default-features = false, features = ["std"] } +objc2-foundation = { version = "0.3.2", default-features = false, features = ["NSArray", "NSLocale", "NSString"] } security-framework = "3.7" [target.'cfg(target_os = "windows")'.dependencies] webview2-com = "0.38" windows = "0.61" -windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_Security_Credentials", "Win32_UI_WindowsAndMessaging"] } +windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_Globalization", "Win32_Security_Credentials", "Win32_UI_WindowsAndMessaging"] } [target.'cfg(target_os = "linux")'.dependencies] secret-service = { version = "5.1", default-features = false, features = ["rt-async-io-crypto-rust"] } diff --git a/src-tauri/src/commands/provider.rs b/src-tauri/src/commands/provider.rs index f6c0c1c..cb7664a 100644 --- a/src-tauri/src/commands/provider.rs +++ b/src-tauri/src/commands/provider.rs @@ -184,6 +184,7 @@ mod tests { short_name: "P".into(), fallback_enabled: true, local_usage_source_note: None, + local_usage_source_kind: None, links: vec![ProviderLink::new("Status", "https://status.example.com/")], metrics: vec![MetricDefinition::new( "provider.session", diff --git a/src-tauri/src/commands/settings.rs b/src-tauri/src/commands/settings.rs index bc4351a..0e8aa35 100644 --- a/src-tauri/src/commands/settings.rs +++ b/src-tauri/src/commands/settings.rs @@ -120,6 +120,9 @@ pub async fn save_app_settings( } } crate::app_debug!("config", "application settings persisted"); + if previous.language != updated.language { + crate::update_tray_menu(&app, &updated.language); + } tray_presentation::update( &app, &service.state(), @@ -222,11 +225,13 @@ pub fn request_notification_permission( settings: State<'_, Arc>, ) -> SettingsViewState { crate::app_info!("notifications", "notification permission requested"); + let language = settings.get().language; + let labels = crate::native_i18n::Labels::for_preference(&language); let error = app .notification() .request_permission() .err() - .map(|_| "Notification permission could not be requested.".to_owned()); + .map(|_| labels.notification_permission_failed.to_owned()); if error.is_some() { crate::app_error!("notifications", "notification permission request failed"); } @@ -234,7 +239,8 @@ pub fn request_notification_permission( notification_permission(&app), error, app.state::().tray_available(), - app.state::().platform_summary(), + app.state::() + .platform_summary(&language), ) } @@ -294,6 +300,7 @@ pub fn open_log_folder(app: AppHandle) -> Result<(), String> { pub(crate) fn settings_view_state(app: &AppHandle, service: &SettingsService) -> SettingsViewState { let mut settings = service.get(); + let labels = crate::native_i18n::Labels::for_preference(&settings.language); let mut integration_error = match autostart_is_enabled(app) { Ok(enabled) => { if settings.launch_at_login != enabled { @@ -302,19 +309,20 @@ pub(crate) fn settings_view_state(app: &AppHandle, service: &SettingsService) -> } None } - Err(_) => Some("Launch at login status could not be read.".to_owned()), + Err(_) => Some(labels.launch_status_failed.to_owned()), }; if let Some(shortcut) = service.get().global_shortcut { if !app.global_shortcut().is_registered(shortcut.as_str()) { - integration_error = - Some("The saved global shortcut is currently unavailable.".to_owned()); + integration_error = Some(labels.shortcut_unavailable.to_owned()); } } + let language = service.get().language; service.view_state( notification_permission(app), integration_error, app.state::().tray_available(), - app.state::().platform_summary(), + app.state::() + .platform_summary(&language), ) } diff --git a/src-tauri/src/desktop_integration.rs b/src-tauri/src/desktop_integration.rs index 00657bd..b3d5968 100644 --- a/src-tauri/src/desktop_integration.rs +++ b/src-tauri/src/desktop_integration.rs @@ -25,7 +25,16 @@ pub enum LinuxDesktop { pub struct DesktopIntegration { tray_available: bool, floating_window: Arc, - platform_summary: Option, + #[cfg(any(target_os = "linux", test))] + platform_summary: Option, +} + +#[cfg(any(target_os = "linux", test))] +#[derive(Debug, Clone, Copy)] +struct PlatformSummary { + desktop: LinuxDesktop, + session: LinuxSessionType, + tray_available: bool, } impl DesktopIntegration { @@ -42,6 +51,7 @@ impl DesktopIntegration { Self { tray_available: true, floating_window: Arc::new(AtomicBool::new(false)), + #[cfg(test)] platform_summary: None, } } @@ -69,8 +79,44 @@ impl DesktopIntegration { self.floating_window.store(floating, Ordering::SeqCst); } - pub fn platform_summary(&self) -> Option { - self.platform_summary.clone() + pub fn platform_summary(&self, language: &str) -> Option { + #[cfg(not(any(target_os = "linux", test)))] + { + let _ = language; + None + } + #[cfg(any(target_os = "linux", test))] + { + let summary = self.platform_summary?; + let locale = crate::native_i18n::Locale::for_preference(language); + let desktop = match summary.desktop { + LinuxDesktop::Gnome => "GNOME", + LinuxDesktop::Kde => "KDE Plasma", + LinuxDesktop::Other => match locale { + crate::native_i18n::Locale::En => "Linux desktop", + crate::native_i18n::Locale::ZhCn => "Linux 桌面", + crate::native_i18n::Locale::ZhTw => "Linux 桌面", + }, + }; + let session = match summary.session { + LinuxSessionType::X11 => "X11", + LinuxSessionType::Wayland => "Wayland", + LinuxSessionType::Unknown => match locale { + crate::native_i18n::Locale::En => "unknown session", + crate::native_i18n::Locale::ZhCn => "未知会话", + crate::native_i18n::Locale::ZhTw => "未知工作階段", + }, + }; + let mode = match (locale, summary.tray_available) { + (crate::native_i18n::Locale::En, true) => "StatusNotifier tray", + (crate::native_i18n::Locale::En, false) => "standalone window", + (crate::native_i18n::Locale::ZhCn, true) => "StatusNotifier 托盘", + (crate::native_i18n::Locale::ZhCn, false) => "独立窗口", + (crate::native_i18n::Locale::ZhTw, true) => "StatusNotifier 狀態列", + (crate::native_i18n::Locale::ZhTw, false) => "獨立視窗", + }; + Some(format!("{desktop} · {session} · {mode}")) + } } } @@ -80,25 +126,14 @@ fn linux_integration( desktop: LinuxDesktop, tray_available: bool, ) -> DesktopIntegration { - let desktop = match desktop { - LinuxDesktop::Gnome => "GNOME", - LinuxDesktop::Kde => "KDE Plasma", - LinuxDesktop::Other => "Linux desktop", - }; - let session = match session { - LinuxSessionType::X11 => "X11", - LinuxSessionType::Wayland => "Wayland", - LinuxSessionType::Unknown => "unknown session", - }; - let mode = if tray_available { - "StatusNotifier tray" - } else { - "standalone window" - }; DesktopIntegration { tray_available, floating_window: Arc::new(AtomicBool::new(!tray_available)), - platform_summary: Some(format!("{desktop} · {session} · {mode}")), + platform_summary: Some(PlatformSummary { + desktop, + session, + tray_available, + }), } } @@ -180,9 +215,13 @@ mod tests { let integration = super::linux_integration(LinuxSessionType::Wayland, LinuxDesktop::Gnome, false); assert_eq!( - integration.platform_summary().as_deref(), + integration.platform_summary("en").as_deref(), Some("GNOME · Wayland · standalone window") ); + assert_eq!( + integration.platform_summary("zh-CN").as_deref(), + Some("GNOME · Wayland · 独立窗口") + ); } #[test] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index f822a48..b00e784 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -6,6 +6,7 @@ mod logging; #[cfg(any(target_os = "macos", test))] mod menu_bar; mod models; +mod native_i18n; mod notifications; mod pacing; mod policy; @@ -60,6 +61,47 @@ use crate::{ }, }; +fn tray_menu(app: &AppHandle, language: &str) -> tauri::Result> { + let labels = native_i18n::Labels::for_preference(language); + #[cfg(target_os = "macos")] + { + let settings = + MenuItem::with_id(app, "settings", labels.settings, true, Some("CmdOrCtrl+,"))?; + let separator = PredefinedMenuItem::separator(app)?; + let quit = MenuItem::with_id(app, "quit", labels.quit, true, Some("CmdOrCtrl+Q"))?; + Menu::with_items(app, &[&settings, &separator, &quit]) + } + #[cfg(not(target_os = "macos"))] + { + let open = MenuItem::with_id(app, "open", labels.open, true, None::<&str>)?; + let customize = MenuItem::with_id(app, "customize", labels.customize, true, None::<&str>)?; + let settings = MenuItem::with_id( + app, + "settings", + labels.settings_with_ellipsis, + true, + None::<&str>, + )?; + let separator = PredefinedMenuItem::separator(app)?; + let quit = MenuItem::with_id(app, "quit", labels.quit, true, None::<&str>)?; + Menu::with_items(app, &[&open, &customize, &settings, &separator, &quit]) + } +} + +pub(crate) fn update_tray_menu(app: &AppHandle, language: &str) { + let Some(tray) = app.tray_by_id("openquota-tray") else { + return; + }; + match tray_menu(app, language) { + Ok(menu) => { + if tray.set_menu(Some(menu)).is_err() { + app_warn!("tray", "tray menu language update failed"); + } + } + Err(_) => app_warn!("tray", "localized tray menu could not be built"), + } +} + fn spawn_startup_credential_detection( app: AppHandle, registry: Arc, @@ -282,33 +324,7 @@ pub fn run() { } if desktop_integration.tray_available() { - #[cfg(target_os = "macos")] - let menu = { - let settings_item = - MenuItem::with_id(app, "settings", "Settings", true, Some("CmdOrCtrl+,"))?; - let separator = PredefinedMenuItem::separator(app)?; - let quit = MenuItem::with_id( - app, - "quit", - "Quit OpenQuota", - true, - Some("CmdOrCtrl+Q"), - )?; - Menu::with_items(app, &[&settings_item, &separator, &quit])? - }; - #[cfg(not(target_os = "macos"))] - let menu = { - let open = - MenuItem::with_id(app, "open", "Open OpenQuota", true, None::<&str>)?; - let customize = - MenuItem::with_id(app, "customize", "Customize…", true, None::<&str>)?; - let settings_item = - MenuItem::with_id(app, "settings", "Settings…", true, None::<&str>)?; - let separator = PredefinedMenuItem::separator(app)?; - let quit = - MenuItem::with_id(app, "quit", "Quit OpenQuota", true, None::<&str>)?; - Menu::with_items(app, &[&open, &customize, &settings_item, &separator, &quit])? - }; + let menu = tray_menu(app.handle(), &settings.get().language)?; let tray = TrayIconBuilder::with_id("openquota-tray") .icon( diff --git a/src-tauri/src/models.rs b/src-tauri/src/models.rs index 0ac1f39..e5548d0 100644 --- a/src-tauri/src/models.rs +++ b/src-tauri/src/models.rs @@ -80,7 +80,7 @@ pub enum StatusTone { Danger, } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct StatusMetric { pub id: String, @@ -90,6 +90,16 @@ pub struct StatusMetric { pub tone: StatusTone, #[serde(default, skip_serializing_if = "Option::is_none")] pub subtitle: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub value: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub unit: Option, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum StatusMetricUnit { + Cap, } #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] @@ -106,6 +116,10 @@ pub struct ProviderNotice { pub title: String, pub message: String, pub tone: ProviderNoticeTone, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub retry_seconds: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub showing_stale_limits: Option, } #[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)] @@ -153,11 +167,32 @@ pub struct ModelUsageVariant { pub cost_usd: Option, } +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum UsageSourceKind { + #[serde(alias = "estimatedLogsSource")] + EstimatedLogs, + #[serde(alias = "estimatedLogsWithPiSource")] + EstimatedLogsWithPi, + #[serde(alias = "estimatedUsageHistorySource")] + EstimatedUsageHistory, + #[serde(alias = "estimatedHistoryWithPiSource")] + EstimatedHistoryWithPi, + #[serde(alias = "cursorExportSource")] + CursorExport, + #[serde(alias = "openCodeDatabaseSource")] + OpenCodeDatabase, + #[serde(other)] + Unknown, +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct ModelUsageBreakdown { pub models: Vec, pub source_note: String, + #[serde(default, alias = "sourceKey", skip_serializing_if = "Option::is_none")] + pub source_kind: Option, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] @@ -322,11 +357,46 @@ pub struct TrayMetricDefinition { pub suffix: Option, } +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum MetricLabelKind { + Session, + Weekly, + Today, + Yesterday, + Last30Days, + Daily, + Monthly, + UsageTrend, + ExtraUsage, + ExtraBalance, + RateLimitResets, + Credits, + TotalUsage, + AutoUsage, + ApiUsage, + #[serde(alias = "requestsLabel")] + Requests, + Balance, + ThisWeek, + ThisMonth, + KeyLimit, + WebSearches, + SparkWeekly, + ClaudeWeekly, + OrgCredits, + OrgSpend, + Chat, + Completions, +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct MetricDefinition { pub id: String, pub label: String, + #[serde(default, alias = "labelKey", skip_serializing_if = "Option::is_none")] + pub label_kind: Option, pub source: MetricSource, pub pinnable: bool, pub default_enabled: bool, @@ -336,6 +406,11 @@ pub struct MetricDefinition { } impl MetricDefinition { + pub fn with_label_kind(mut self, kind: MetricLabelKind) -> Self { + self.label_kind = Some(kind); + self + } + #[allow(clippy::too_many_arguments)] pub fn new( id: impl Into, @@ -351,6 +426,7 @@ impl MetricDefinition { Self { id: id.into(), label: label.into(), + label_kind: None, source, pinnable, default_enabled, @@ -507,6 +583,19 @@ impl MetricDefinition { pub struct ProviderLink { pub label: String, pub url: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub kind: Option, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum ProviderLinkKind { + Status, + Dashboard, + ApiKeys, + Usage, + Activity, + Credits, } impl ProviderLink { @@ -514,9 +603,15 @@ impl ProviderLink { Self { label: label.into(), url: url.into(), + kind: None, } } + pub fn with_kind(mut self, kind: ProviderLinkKind) -> Self { + self.kind = Some(kind); + self + } + pub fn visible(&self) -> Option { let label = self.label.trim(); let url = self.url.trim(); @@ -526,7 +621,9 @@ impl ProviderLink { { return None; } - Some(Self::new(label, url)) + let mut visible = Self::new(label, url); + visible.kind = self.kind; + Some(visible) } } @@ -537,7 +634,10 @@ pub struct ProviderDefinition { pub display_name: String, pub short_name: String, pub fallback_enabled: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] pub local_usage_source_note: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub local_usage_source_kind: Option, #[serde(default)] pub links: Vec, pub metrics: Vec, @@ -682,6 +782,8 @@ pub struct NotificationPreferences { #[serde(rename_all = "camelCase", default)] pub struct AppSettings { pub schema_version: u32, + #[serde(default = "default_language")] + pub language: String, pub providers: Vec, pub known_provider_ids: Vec, pub provider_names: BTreeMap, @@ -706,10 +808,25 @@ pub struct AppSettings { pub detection_notice_dismissed: bool, } +fn default_language() -> String { + "system".to_owned() +} + +pub fn normalize_language_preference(value: &str) -> &'static str { + match value { + "system" => "system", + "en" => "en", + "zh-CN" => "zh-CN", + "zh-TW" => "zh-TW", + _ => "en", + } +} + impl Default for AppSettings { fn default() -> Self { Self { schema_version: 6, + language: default_language(), providers: Vec::new(), known_provider_ids: Vec::new(), provider_names: BTreeMap::new(), @@ -749,6 +866,7 @@ impl AppSettings { #[serde(rename_all = "camelCase")] pub struct SettingsViewState { pub settings: AppSettings, + pub resolved_language: String, pub account_revision: u64, pub renamable_provider_ids: Vec, pub notification_permission: String, @@ -760,8 +878,10 @@ pub struct SettingsViewState { #[cfg(test)] mod tests { use super::{ - ApiKeyStatus, AppSettings, LogLevel, ProviderApiKeyState, ProviderErrorKind, ProviderLink, - ProviderSnapshot, ProviderViewState, UsagePeriod, WindowMode, + ApiKeyStatus, AppSettings, LogLevel, MetricDefinition, MetricLabelKind, + ModelUsageBreakdown, ProviderApiKeyState, ProviderErrorKind, ProviderLink, ProviderNotice, + ProviderNoticeTone, ProviderSnapshot, ProviderViewState, StatusMetric, StatusMetricUnit, + StatusTone, UsagePeriod, UsageSourceKind, WindowMode, }; #[test] @@ -782,6 +902,22 @@ mod tests { assert_eq!(settings.window_mode, WindowMode::Popup); } + #[test] + fn older_settings_without_language_default_to_system() { + let mut value = serde_json::to_value(AppSettings::default()).unwrap(); + value.as_object_mut().unwrap().remove("language"); + + let settings: AppSettings = serde_json::from_value(value).unwrap(); + assert_eq!(settings.language, "system"); + } + + #[test] + fn unsupported_language_preferences_fall_back_to_english() { + assert_eq!(super::normalize_language_preference("invalid-locale"), "en"); + assert_eq!(super::normalize_language_preference("zh-CN"), "zh-CN"); + assert_eq!(super::normalize_language_preference("zh-TW"), "zh-TW"); + } + #[test] fn unknown_persisted_log_levels_fall_back_to_info() { let mut value = serde_json::to_value(AppSettings::default()).unwrap(); @@ -835,6 +971,113 @@ mod tests { assert!(period.cost_estimated); } + #[test] + fn older_metric_and_usage_payloads_default_new_semantic_fields() { + let metric: MetricDefinition = serde_json::from_value(serde_json::json!({ + "id": "custom.session", + "label": "Custom Session", + "source": { + "kind": "quota", + "sourceId": "session", + "sessionWindow": false + }, + "pinnable": true, + "defaultEnabled": true, + "defaultSection": "alwaysVisible", + "defaultPinned": false, + "tray": null + })) + .unwrap(); + assert_eq!(metric.label_kind, None); + + let breakdown: ModelUsageBreakdown = serde_json::from_value(serde_json::json!({ + "models": [], + "sourceNote": "From a custom source" + })) + .unwrap(); + assert_eq!(breakdown.source_kind, None); + } + + #[test] + fn legacy_translation_keys_deserialize_to_semantics_but_are_not_serialized() { + let parsed: MetricDefinition = serde_json::from_value(serde_json::json!({ + "id": "custom.requests", + "label": "Requests", + "labelKey": "requestsLabel", + "source": { "kind": "value", "sourceId": "requests" }, + "pinnable": true, + "defaultEnabled": true, + "defaultSection": "onDemand", + "defaultPinned": false, + "tray": null + })) + .unwrap(); + assert_eq!(parsed.label_kind, Some(MetricLabelKind::Requests)); + let value = serde_json::to_value(parsed).unwrap(); + assert_eq!(value["labelKind"], "requests"); + assert!(value.get("labelKey").is_none()); + + let parsed: ModelUsageBreakdown = serde_json::from_value(serde_json::json!({ + "models": [], + "sourceNote": "Legacy source", + "sourceKey": "estimatedLogsSource" + })) + .unwrap(); + assert_eq!(parsed.source_kind, Some(UsageSourceKind::EstimatedLogs)); + let value = serde_json::to_value(parsed).unwrap(); + assert_eq!(value["sourceKind"], "estimatedLogs"); + assert!(value.get("sourceKey").is_none()); + } + + #[test] + fn older_status_payloads_default_typed_presentation_fields() { + let metric: StatusMetric = serde_json::from_value(serde_json::json!({ + "id": "payAsYouGo", + "label": "Extra Usage", + "text": "2500 cap", + "tone": "positive" + })) + .unwrap(); + assert_eq!(metric.value, None); + assert_eq!(metric.unit, None); + + let notice: ProviderNotice = serde_json::from_value(serde_json::json!({ + "id": "rateLimited", + "title": "Live usage paused", + "message": "Retrying in about 5 minutes", + "tone": "warning" + })) + .unwrap(); + assert_eq!(notice.retry_seconds, None); + assert_eq!(notice.showing_stale_limits, None); + + let status = StatusMetric { + id: "payAsYouGo".into(), + label: "Extra Usage".into(), + text: String::new(), + tone: StatusTone::Positive, + subtitle: None, + value: Some(2500.0), + unit: Some(StatusMetricUnit::Cap), + }; + assert_eq!( + serde_json::to_value(status).unwrap()["unit"], + serde_json::json!("cap") + ); + + let notice = ProviderNotice { + id: "rateLimited".into(), + title: "Live usage paused".into(), + message: String::new(), + tone: ProviderNoticeTone::Warning, + retry_seconds: Some(60), + showing_stale_limits: Some(true), + }; + let value = serde_json::to_value(notice).unwrap(); + assert_eq!(value["retrySeconds"], serde_json::json!(60)); + assert_eq!(value["showingStaleLimits"], serde_json::json!(true)); + } + #[test] fn cached_snapshots_default_new_dynamic_rows() { let snapshot: ProviderSnapshot = serde_json::from_str( diff --git a/src-tauri/src/native_i18n.rs b/src-tauri/src/native_i18n.rs new file mode 100644 index 0000000..4cfdd60 --- /dev/null +++ b/src-tauri/src/native_i18n.rs @@ -0,0 +1,319 @@ +use crate::models::{MetricLabelKind, StatusMetricUnit, StatusTone}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Locale { + En, + ZhCn, + ZhTw, +} + +impl Locale { + pub fn for_preference(preference: &str) -> Self { + match preference { + "zh-CN" => Self::ZhCn, + "zh-TW" => Self::ZhTw, + "system" => system_language() + .as_deref() + .map(Self::from_language_tag) + .unwrap_or(Self::En), + _ => Self::En, + } + } + + pub fn from_language_tag(language: &str) -> Self { + let normalized = normalize_locale_tag(language); + if normalized == "zh-tw" + || normalized == "zh-hk" + || normalized == "zh-mo" + || normalized.starts_with("zh-hant") + { + Self::ZhTw + } else if normalized.starts_with("zh") { + Self::ZhCn + } else { + Self::En + } + } + + pub fn language_tag(self) -> &'static str { + match self { + Self::En => "en", + Self::ZhCn => "zh-CN", + Self::ZhTw => "zh-TW", + } + } +} + +fn normalize_locale_tag(language: &str) -> String { + language + .trim() + .split(['.', '@']) + .next() + .unwrap_or_default() + .replace('_', "-") + .to_ascii_lowercase() +} + +pub struct Labels { + #[cfg(any(not(target_os = "macos"), test))] + pub open: &'static str, + #[cfg(any(not(target_os = "macos"), test))] + pub customize: &'static str, + #[cfg(any(target_os = "macos", test))] + pub settings: &'static str, + #[cfg(any(not(target_os = "macos"), test))] + pub settings_with_ellipsis: &'static str, + pub quit: &'static str, + #[cfg(any(target_os = "linux", target_os = "macos", test))] + pub notification_action: &'static str, + pub notification_failed: &'static str, + pub notification_permission_failed: &'static str, + pub launch_status_failed: &'static str, + pub shortcut_unavailable: &'static str, +} + +impl Labels { + pub fn for_preference(preference: &str) -> Self { + match Locale::for_preference(preference) { + Locale::En => Self { + #[cfg(any(not(target_os = "macos"), test))] + open: "Open OpenQuota", + #[cfg(any(not(target_os = "macos"), test))] + customize: "Customize…", + #[cfg(any(target_os = "macos", test))] + settings: "Settings", + #[cfg(any(not(target_os = "macos"), test))] + settings_with_ellipsis: "Settings…", + quit: "Quit OpenQuota", + #[cfg(any(target_os = "linux", target_os = "macos", test))] + notification_action: "Open OpenQuota", + notification_failed: "The notification could not be delivered.", + notification_permission_failed: "Notification permission could not be requested.", + launch_status_failed: "Launch at login status could not be read.", + shortcut_unavailable: "The saved global shortcut is currently unavailable.", + }, + Locale::ZhCn => Self { + #[cfg(any(not(target_os = "macos"), test))] + open: "打开 OpenQuota", + #[cfg(any(not(target_os = "macos"), test))] + customize: "自定义…", + #[cfg(any(target_os = "macos", test))] + settings: "设置", + #[cfg(any(not(target_os = "macos"), test))] + settings_with_ellipsis: "设置…", + quit: "退出 OpenQuota", + #[cfg(any(target_os = "linux", target_os = "macos", test))] + notification_action: "打开 OpenQuota", + notification_failed: "无法发送通知。", + notification_permission_failed: "无法请求通知权限。", + launch_status_failed: "无法读取登录时启动状态。", + shortcut_unavailable: "已保存的全局快捷键当前不可用。", + }, + Locale::ZhTw => Self { + #[cfg(any(not(target_os = "macos"), test))] + open: "開啟 OpenQuota", + #[cfg(any(not(target_os = "macos"), test))] + customize: "自訂…", + #[cfg(any(target_os = "macos", test))] + settings: "設定", + #[cfg(any(not(target_os = "macos"), test))] + settings_with_ellipsis: "設定…", + quit: "結束 OpenQuota", + #[cfg(any(target_os = "linux", target_os = "macos", test))] + notification_action: "開啟 OpenQuota", + notification_failed: "無法傳送通知。", + notification_permission_failed: "無法請求通知權限。", + launch_status_failed: "無法讀取登入時啟動狀態。", + shortcut_unavailable: "已儲存的全域快捷鍵目前無法使用。", + }, + } + } +} + +pub fn metric_label(locale: Locale, kind: Option, fallback: &str) -> String { + let common = match kind { + Some(MetricLabelKind::Session) => Some(("Session", "会话", "工作階段")), + Some(MetricLabelKind::Weekly) => Some(("Weekly", "每周", "每週")), + Some(MetricLabelKind::Today) => Some(("Today", "今天", "今天")), + Some(MetricLabelKind::Yesterday) => Some(("Yesterday", "昨天", "昨天")), + Some(MetricLabelKind::Last30Days) => Some(("30 Days", "30 天", "30 天")), + Some(MetricLabelKind::Daily) => Some(("Daily", "每日", "每日")), + Some(MetricLabelKind::Monthly) => Some(("Monthly", "每月", "每月")), + Some(MetricLabelKind::UsageTrend) => Some(("Usage Trend", "用量趋势", "用量趨勢")), + Some(MetricLabelKind::ExtraUsage) => Some(("Extra Usage", "额外用量", "額外用量")), + Some(MetricLabelKind::ExtraBalance) => Some(("Extra Balance", "额外余额", "額外餘額")), + Some(MetricLabelKind::RateLimitResets) => { + Some(("Rate Limit Resets", "限额重置", "限額重設")) + } + Some(MetricLabelKind::Credits) => Some(("Credits", "额度", "額度")), + Some(MetricLabelKind::TotalUsage) => Some(("Total Usage", "总用量", "總用量")), + Some(MetricLabelKind::AutoUsage) => Some(("Auto Usage", "自动用量", "自動用量")), + Some(MetricLabelKind::ApiUsage) => Some(("API Usage", "API 用量", "API 用量")), + Some(MetricLabelKind::Requests) => Some(("Requests", "请求", "要求")), + Some(MetricLabelKind::Balance) => Some(("Balance", "余额", "餘額")), + Some(MetricLabelKind::ThisWeek) => Some(("This Week", "本周", "本週")), + Some(MetricLabelKind::ThisMonth) => Some(("This Month", "本月", "本月")), + Some(MetricLabelKind::KeyLimit) => Some(("Key Limit", "密钥限额", "金鑰限額")), + Some(MetricLabelKind::WebSearches) => Some(("Web Searches", "网页搜索", "網頁搜尋")), + Some(MetricLabelKind::SparkWeekly) => Some(("Spark Weekly", "Spark 每周", "Spark 每週")), + Some(MetricLabelKind::ClaudeWeekly) => { + Some(("Claude Weekly", "Claude 每周", "Claude 每週")) + } + Some(MetricLabelKind::OrgCredits) => Some(("Org Credits", "组织额度", "組織額度")), + Some(MetricLabelKind::OrgSpend) => Some(("Org Spend", "组织消费", "組織消費")), + Some(MetricLabelKind::Chat) => Some(("Chat", "聊天", "聊天")), + Some(MetricLabelKind::Completions) => Some(("Completions", "代码补全", "程式碼補全")), + None => None, + }; + common + .map(|labels| match locale { + Locale::En => labels.0, + Locale::ZhCn => labels.1, + Locale::ZhTw => labels.2, + }) + .unwrap_or(fallback) + .to_owned() +} + +pub fn status_metric_text( + locale: Locale, + id: &str, + tone: StatusTone, + value: Option, + unit: Option, + fallback: &str, +) -> String { + if id == "payAsYouGo" && tone == StatusTone::Neutral { + return match locale { + Locale::En => "Disabled", + Locale::ZhCn => "已禁用", + Locale::ZhTw => "已停用", + } + .to_owned(); + } + if id == "payAsYouGo" + && tone == StatusTone::Positive + && unit == Some(StatusMetricUnit::Cap) + && value.is_some_and(f64::is_finite) + { + let number = value.unwrap(); + let number = if number.fract() == 0.0 { + format!("{number:.0}") + } else { + number.to_string() + }; + return match locale { + Locale::En => format!("{number} cap"), + Locale::ZhCn | Locale::ZhTw => format!("上限 {number}"), + }; + } + fallback.to_owned() +} + +pub fn usage_word(locale: Locale, used: bool) -> &'static str { + match (locale, used) { + (Locale::En, true) => "used", + (Locale::En, false) => "left", + (Locale::ZhCn, true) => "已用", + (Locale::ZhCn, false) => "剩余", + (Locale::ZhTw, true) => "已用", + (Locale::ZhTw, false) => "剩餘", + } +} + +pub fn count_unit(locale: Locale, unit: &str) -> String { + match (locale, unit) { + (Locale::ZhCn, "requests") => "次请求".to_owned(), + (Locale::ZhCn, "searches") => "次搜索".to_owned(), + (Locale::ZhTw, "requests") => "次要求".to_owned(), + (Locale::ZhTw, "searches") => "次搜尋".to_owned(), + _ => unit.to_owned(), + } +} + +#[cfg(target_os = "windows")] +fn system_language() -> Option { + use windows_sys::Win32::Globalization::GetUserDefaultLocaleName; + + let mut buffer = [0_u16; 85]; + let length = unsafe { GetUserDefaultLocaleName(buffer.as_mut_ptr(), buffer.len() as i32) }; + (length > 1).then(|| String::from_utf16_lossy(&buffer[..length as usize - 1])) +} + +#[cfg(target_os = "macos")] +fn system_language() -> Option { + objc2_foundation::NSLocale::preferredLanguages() + .firstObject() + .map(|language| language.to_string()) +} + +#[cfg(all(not(target_os = "windows"), not(target_os = "macos")))] +fn system_language() -> Option { + ["LC_ALL", "LC_MESSAGES", "LANG"] + .into_iter() + .find_map(|name| { + std::env::var(name) + .ok() + .filter(|value| !value.trim().is_empty()) + }) +} + +#[cfg(test)] +mod tests { + use super::{Labels, Locale}; + use crate::models::MetricLabelKind; + + #[test] + fn language_tags_match_frontend_resolution() { + assert_eq!(super::normalize_locale_tag("zh_CN.UTF-8"), "zh-cn"); + assert_eq!(super::normalize_locale_tag("zh_TW@variant"), "zh-tw"); + assert_eq!(Locale::from_language_tag("zh-CN"), Locale::ZhCn); + assert_eq!(Locale::from_language_tag("zh-SG"), Locale::ZhCn); + assert_eq!(Locale::from_language_tag("zh-TW"), Locale::ZhTw); + assert_eq!(Locale::from_language_tag("zh-HK"), Locale::ZhTw); + assert_eq!(Locale::from_language_tag("zh-Hant"), Locale::ZhTw); + assert_eq!(Locale::from_language_tag("fr-FR"), Locale::En); + assert_eq!(Locale::from_language_tag("C"), Locale::En); + assert_eq!(Locale::from_language_tag("POSIX"), Locale::En); + assert_eq!(Locale::En.language_tag(), "en"); + assert_eq!(Locale::ZhCn.language_tag(), "zh-CN"); + assert_eq!(Locale::ZhTw.language_tag(), "zh-TW"); + } + + #[test] + fn explicit_preferences_have_localized_native_labels() { + assert_eq!(Labels::for_preference("en").open, "Open OpenQuota"); + assert_eq!(Labels::for_preference("en").customize, "Customize…"); + assert_eq!(Labels::for_preference("en").settings, "Settings"); + assert_eq!( + Labels::for_preference("en").settings_with_ellipsis, + "Settings…" + ); + assert_eq!( + Labels::for_preference("en").notification_action, + "Open OpenQuota" + ); + assert_eq!(Labels::for_preference("zh-CN").open, "打开 OpenQuota"); + assert_eq!(Labels::for_preference("zh-CN").customize, "自定义…"); + assert_eq!(Labels::for_preference("zh-CN").settings, "设置"); + assert_eq!(Labels::for_preference("zh-TW").open, "開啟 OpenQuota"); + assert_eq!(Labels::for_preference("zh-TW").customize, "自訂…"); + assert_eq!(Labels::for_preference("zh-TW").settings, "設定"); + assert_eq!(Labels::for_preference("invalid").settings, "Settings"); + } + + #[test] + fn native_metric_labels_and_units_follow_the_resolved_locale() { + assert_eq!( + super::metric_label(Locale::ZhCn, Some(MetricLabelKind::Weekly), "Weekly"), + "每周" + ); + assert_eq!( + super::metric_label(Locale::ZhTw, Some(MetricLabelKind::Requests), "Requests"), + "要求" + ); + assert_eq!(super::count_unit(Locale::ZhTw, "searches"), "次搜尋"); + assert_eq!(super::usage_word(Locale::ZhCn, false), "剩余"); + assert_eq!(super::metric_label(Locale::En, None, "Custom"), "Custom"); + } +} diff --git a/src-tauri/src/notifications.rs b/src-tauri/src/notifications.rs index bdc4cdc..66b4fd9 100644 --- a/src-tauri/src/notifications.rs +++ b/src-tauri/src/notifications.rs @@ -7,6 +7,7 @@ use crate::{ models::{ProviderSnapshot, ProviderViewState}, pacing::{NotificationEvaluator, PaceAlert}, popup::PopupDismissGuard, + providers::ProviderRegistry, service::UsageViewState, settings::SettingsService, tray_presentation, @@ -38,7 +39,7 @@ pub fn finish_refresh( settings.registry(), chrono::Utc::now(), ); - let failed = deliver(app, &alerts); + let failed = deliver(app, &alerts, &preferences.language, settings.registry()); if !failed.is_empty() { notifications.rollback(&failed); } @@ -51,7 +52,12 @@ fn notification_snapshot(state: &ProviderViewState) -> Option<&ProviderSnapshot> state.snapshot.as_ref() } -fn deliver(app: &AppHandle, alerts: &[PaceAlert]) -> Vec { +fn deliver( + app: &AppHandle, + alerts: &[PaceAlert], + language: &str, + registry: &ProviderRegistry, +) -> Vec { if permission(app) != "granted" { if !alerts.is_empty() { crate::app_debug!( @@ -62,18 +68,24 @@ fn deliver(app: &AppHandle, alerts: &[PaceAlert]) -> Vec { } return alerts.to_vec(); } + let locale = crate::native_i18n::Locale::for_preference(language); alerts .iter() .filter_map(|alert| { + let metric_kind = registry + .metric(&alert.metric_id) + .and_then(|definition| definition.label_kind); + let metric = crate::native_i18n::metric_label(locale, metric_kind, &alert.metric); let result = show( app, - alert.milestone.title(), + alert.milestone.title(locale), &format!( "{} · {}\n{}", alert.provider, - alert.metric, - alert.milestone.body() + metric, + alert.milestone.body(locale) ), + language, ); if result.is_ok() { crate::app_info!("notifications", "pace alert delivered"); @@ -86,11 +98,12 @@ fn deliver(app: &AppHandle, alerts: &[PaceAlert]) -> Vec { .collect() } -fn show(app: &AppHandle, title: &str, body: &str) -> Result<(), String> { +fn show(app: &AppHandle, title: &str, body: &str, language: &str) -> Result<(), String> { + let labels = crate::native_i18n::Labels::for_preference(language); let mut notification = notify_rust::Notification::new(); notification.summary(title).body(body).appname("OpenQuota"); #[cfg(any(target_os = "linux", target_os = "macos"))] - notification.action("default", "Open OpenQuota"); + notification.action("default", labels.notification_action); #[cfg(target_os = "windows")] notification.app_id(&app.config().identifier); #[cfg(target_os = "macos")] @@ -102,7 +115,7 @@ fn show(app: &AppHandle, title: &str, body: &str) -> Result<(), String> { let handle = notification .show() - .map_err(|_| "The notification could not be delivered.".to_owned())?; + .map_err(|_| labels.notification_failed.to_owned())?; let app = app.clone(); thread::spawn(move || { let _ = handle.wait_for_response(move |response: ¬ify_rust::NotificationResponse| { diff --git a/src-tauri/src/pacing.rs b/src-tauri/src/pacing.rs index 55dcb15..ef1af34 100644 --- a/src-tauri/src/pacing.rs +++ b/src-tauri/src/pacing.rs @@ -125,19 +125,37 @@ pub enum Milestone { } impl Milestone { - pub fn title(self) -> &'static str { - match self { - Self::AlmostOut => "Almost Out", - Self::CuttingItClose => "Cutting It Close", - Self::WillRunOut => "Will Run Out", + pub fn title(self, locale: crate::native_i18n::Locale) -> &'static str { + match (locale, self) { + (crate::native_i18n::Locale::En, Self::AlmostOut) => "Almost Out", + (crate::native_i18n::Locale::En, Self::CuttingItClose) => "Cutting It Close", + (crate::native_i18n::Locale::En, Self::WillRunOut) => "Will Run Out", + (crate::native_i18n::Locale::ZhCn, Self::AlmostOut) => "即将用尽", + (crate::native_i18n::Locale::ZhCn, Self::CuttingItClose) => "接近限额", + (crate::native_i18n::Locale::ZhCn, Self::WillRunOut) => "将会用尽", + (crate::native_i18n::Locale::ZhTw, Self::AlmostOut) => "即將用盡", + (crate::native_i18n::Locale::ZhTw, Self::CuttingItClose) => "接近限額", + (crate::native_i18n::Locale::ZhTw, Self::WillRunOut) => "將會用盡", } } - pub fn body(self) -> &'static str { - match self { - Self::AlmostOut => "Under 10% usage remaining for this window.", - Self::CuttingItClose => "Projected to finish close to your limit.", - Self::WillRunOut => "Projected to run out before the limit resets.", + pub fn body(self, locale: crate::native_i18n::Locale) -> &'static str { + match (locale, self) { + (crate::native_i18n::Locale::En, Self::AlmostOut) => { + "Under 10% usage remaining for this window." + } + (crate::native_i18n::Locale::En, Self::CuttingItClose) => { + "Projected to finish close to your limit." + } + (crate::native_i18n::Locale::En, Self::WillRunOut) => { + "Projected to run out before the limit resets." + } + (crate::native_i18n::Locale::ZhCn, Self::AlmostOut) => "此周期的剩余用量低于 10%。", + (crate::native_i18n::Locale::ZhCn, Self::CuttingItClose) => "预计将在接近限额时结束。", + (crate::native_i18n::Locale::ZhCn, Self::WillRunOut) => "预计将在限额重置前用尽。", + (crate::native_i18n::Locale::ZhTw, Self::AlmostOut) => "此週期的剩餘用量低於 10%。", + (crate::native_i18n::Locale::ZhTw, Self::CuttingItClose) => "預計將在接近限額時結束。", + (crate::native_i18n::Locale::ZhTw, Self::WillRunOut) => "預計將在限額重設前用盡。", } } } @@ -147,7 +165,7 @@ pub struct PaceAlert { pub milestone: Milestone, pub provider: String, pub metric: String, - metric_id: String, + pub(crate) metric_id: String, previous_severity: Option, previous_was_under_ten: bool, } @@ -661,6 +679,7 @@ mod tests { short_name: "C".into(), fallback_enabled: true, local_usage_source_note: None, + local_usage_source_kind: None, links: vec![], metrics: vec![MetricDefinition::new( "custom.rolling", @@ -750,6 +769,7 @@ mod tests { short_name: "Sw".into(), fallback_enabled: true, local_usage_source_note: None, + local_usage_source_kind: None, links: vec![], metrics: vec![ quota_metric("switching.session", "session", "S"), diff --git a/src-tauri/src/providers/antigravity/mod.rs b/src-tauri/src/providers/antigravity/mod.rs index 2dd020b..eccbbab 100644 --- a/src-tauri/src/providers/antigravity/mod.rs +++ b/src-tauri/src/providers/antigravity/mod.rs @@ -10,7 +10,8 @@ use serde_json::{json, Value}; use thiserror::Error; use crate::models::{ - MetricDefinition, MetricSection, ProviderDefinition, ProviderSnapshot, UsageHistory, + MetricDefinition, MetricLabelKind, MetricSection, ProviderDefinition, ProviderSnapshot, + UsageHistory, }; use self::{ @@ -35,6 +36,7 @@ pub(crate) fn definition() -> ProviderDefinition { short_name: "A".into(), fallback_enabled: false, local_usage_source_note: None, + local_usage_source_kind: None, links: vec![], metrics: vec![ MetricDefinition::quota( @@ -46,7 +48,8 @@ pub(crate) fn definition() -> ProviderDefinition { MetricSection::AlwaysVisible, true, "S", - ), + ) + .with_label_kind(MetricLabelKind::Session), MetricDefinition::quota( "antigravity.geminiWeekly", "Weekly", @@ -56,7 +59,8 @@ pub(crate) fn definition() -> ProviderDefinition { MetricSection::AlwaysVisible, true, "W", - ), + ) + .with_label_kind(MetricLabelKind::Weekly), MetricDefinition::quota( "antigravity.claude", "Claude", @@ -76,7 +80,8 @@ pub(crate) fn definition() -> ProviderDefinition { MetricSection::OnDemand, false, "CW", - ), + ) + .with_label_kind(MetricLabelKind::ClaudeWeekly), ], } } diff --git a/src-tauri/src/providers/claude/local_usage.rs b/src-tauri/src/providers/claude/local_usage.rs index d3ec01b..cfd5445 100644 --- a/src-tauri/src/providers/claude/local_usage.rs +++ b/src-tauri/src/providers/claude/local_usage.rs @@ -104,12 +104,18 @@ pub fn scan_local_usage( } else { false }; - let source_note = if includes_pi { - "From your Claude usage history and pi (estimated)" + let (source_note, source_kind) = if includes_pi { + ( + "From your Claude usage history and pi (estimated)", + crate::models::UsageSourceKind::EstimatedHistoryWithPi, + ) } else { - "From your Claude usage history (estimated)" + ( + "From your Claude usage history (estimated)", + crate::models::UsageSourceKind::EstimatedUsageHistory, + ) }; - Ok(accumulator.build(now, source_note)) + Ok(accumulator.build_with_source_kind(now, source_note, Some(source_kind))) } fn discover_files(configured_roots: &[PathBuf], include_standard_roots: bool) -> Vec { @@ -438,7 +444,11 @@ fn aggregate( ) -> UsageHistory { let mut accumulator = DailyUsageAccumulator::default(); aggregate_into(events, now, pricing, &mut accumulator); - accumulator.build(now, "From your Claude usage history (estimated)") + accumulator.build_with_source_kind( + now, + "From your Claude usage history (estimated)", + Some(crate::models::UsageSourceKind::EstimatedUsageHistory), + ) } fn aggregate_into( @@ -556,7 +566,11 @@ mod tests { let events = deduplicate(events); assert_eq!(events.len(), 1); assert_eq!(events[0].total_tokens(), 170); - let history = aggregate(events, chrono::Utc::now(), &test_bundled_pricing()); + let history = aggregate( + events, + Utc.with_ymd_and_hms(2026, 7, 12, 0, 0, 0).unwrap(), + &test_bundled_pricing(), + ); assert!(history.last_30_days.unwrap().estimated_cost_usd.is_some()); } diff --git a/src-tauri/src/providers/claude/mod.rs b/src-tauri/src/providers/claude/mod.rs index a61f099..014a407 100644 --- a/src-tauri/src/providers/claude/mod.rs +++ b/src-tauri/src/providers/claude/mod.rs @@ -15,8 +15,9 @@ use thiserror::Error; use crate::{ models::{ - MetricDefinition, MetricSection, ProviderDefinition, ProviderLink, ProviderNotice, - ProviderNoticeTone, ProviderSnapshot, UsagePeriodSelection, + MetricDefinition, MetricLabelKind, MetricSection, ProviderDefinition, ProviderLink, + ProviderLinkKind, ProviderNotice, ProviderNoticeTone, ProviderSnapshot, + UsagePeriodSelection, UsageSourceKind, }, pricing::{ModelPricing, PricingStore}, storage::Storage, @@ -33,9 +34,12 @@ fn definition_for(id: &str, display_name: &str, fallback_enabled: bool) -> Provi short_name: "Cl".into(), fallback_enabled, local_usage_source_note: Some("From your Claude usage history (estimated)".into()), + local_usage_source_kind: Some(UsageSourceKind::EstimatedUsageHistory), links: vec![ - ProviderLink::new("Status", "https://status.anthropic.com/"), - ProviderLink::new("Dashboard", "https://claude.ai/settings/usage"), + ProviderLink::new("Status", "https://status.anthropic.com/") + .with_kind(ProviderLinkKind::Status), + ProviderLink::new("Dashboard", "https://claude.ai/settings/usage") + .with_kind(ProviderLinkKind::Dashboard), ], metrics: vec![ MetricDefinition::quota( @@ -47,7 +51,8 @@ fn definition_for(id: &str, display_name: &str, fallback_enabled: bool) -> Provi MetricSection::AlwaysVisible, true, "S", - ), + ) + .with_label_kind(MetricLabelKind::Session), MetricDefinition::quota( "claude.weekly", "Weekly", @@ -57,7 +62,8 @@ fn definition_for(id: &str, display_name: &str, fallback_enabled: bool) -> Provi MetricSection::AlwaysVisible, true, "W", - ), + ) + .with_label_kind(MetricLabelKind::Weekly), MetricDefinition::quota( "claude.sonnet", "Sonnet", @@ -86,29 +92,33 @@ fn definition_for(id: &str, display_name: &str, fallback_enabled: bool) -> Provi MetricSection::AlwaysVisible, false, "E", - ), - MetricDefinition::trend("claude.trend"), + ) + .with_label_kind(MetricLabelKind::ExtraUsage), + MetricDefinition::trend("claude.trend").with_label_kind(MetricLabelKind::UsageTrend), MetricDefinition::usage( "claude.today", "Today", UsagePeriodSelection::Today, MetricSection::OnDemand, "T", - ), + ) + .with_label_kind(MetricLabelKind::Today), MetricDefinition::usage( "claude.yesterday", "Yesterday", UsagePeriodSelection::Yesterday, MetricSection::OnDemand, "Y", - ), + ) + .with_label_kind(MetricLabelKind::Yesterday), MetricDefinition::usage( "claude.last30", "Last 30 Days", UsagePeriodSelection::Last30Days, MetricSection::OnDemand, "M", - ), + ) + .with_label_kind(MetricLabelKind::Last30Days), ], }; if id != "claude" { @@ -458,17 +468,10 @@ impl ClaudeProvider { let retry = until.signed_duration_since(now).num_seconds().max(0) as u64; if let Some(mut snapshot) = self.last_good.lock().ok().and_then(|value| value.clone()) { snapshot.usage = usage; - snapshot.warnings.push( - "Claude live usage is rate limited; showing the last successful limits.".into(), - ); snapshot.notices = vec![rate_limit_notice(retry, true)]; snapshot.refreshed_at = now; return Ok(snapshot); } - warnings.push(format!( - "Claude live usage is rate limited; retrying in about {}.", - retry_minutes(retry) - )); return Ok(ProviderSnapshot { provider_id: self.provider_id().into(), plan: plan_name(credential), @@ -509,18 +512,10 @@ impl ClaudeProvider { } if let Some(mut snapshot) = self.last_good.lock().ok().and_then(|value| value.clone()) { snapshot.usage = usage; - snapshot.warnings.push(format!( - "Claude live usage is rate limited; retrying in about {}.", - retry_minutes(retry) - )); snapshot.notices = vec![rate_limit_notice(retry, true)]; snapshot.refreshed_at = now; return Ok(snapshot); } - warnings.push(format!( - "Claude live usage is rate limited; retrying in about {}.", - retry_minutes(retry) - )); return Ok(ProviderSnapshot { provider_id: self.provider_id().into(), plan: plan_name(credential), @@ -600,31 +595,16 @@ impl ClaudeProvider { } fn rate_limit_notice(retry_seconds: u64, showing_stale_limits: bool) -> ProviderNotice { - let retry = if retry_seconds == 0 { - "Ready to retry".to_owned() - } else { - format!("Retrying in about {}", retry_minutes(retry_seconds)) - }; ProviderNotice { id: "rateLimited".into(), title: "Live usage paused".into(), - message: if showing_stale_limits { - format!("Showing the last successful limits · {retry}") - } else { - retry - }, + message: String::new(), tone: ProviderNoticeTone::Warning, + retry_seconds: Some(retry_seconds), + showing_stale_limits: Some(showing_stale_limits), } } -fn retry_minutes(retry_seconds: u64) -> String { - let minutes = retry_seconds.div_ceil(60); - format!( - "{minutes} {}", - if minutes == 1 { "minute" } else { "minutes" } - ) -} - fn refresh_credential( client: &ClaudeClient, credential: &mut ClaudeCredential, @@ -751,7 +731,7 @@ mod tests { use super::{ accounts::{self, ClaudeAccount, ClaudeAccountDiscovery}, - auth::{ClaudeCredentialScope, ClaudeOAuthConfig}, + auth::{self, ClaudeCredentialGeneration, ClaudeCredentialScope, ClaudeOAuthConfig}, client::ClaudeClient, definition, definition_for, rate_limit_notice, runtime_configs, ClaudeError, ClaudeProvider, ClaudeRuntimeConfig, @@ -864,14 +844,79 @@ mod tests { fn rate_limit_notice_distinguishes_empty_and_stale_live_usage() { let empty = rate_limit_notice(301, false); assert_eq!(empty.title, "Live usage paused"); - assert_eq!(empty.message, "Retrying in about 6 minutes"); + assert_eq!(empty.message, ""); + assert_eq!(empty.retry_seconds, Some(301)); + assert_eq!(empty.showing_stale_limits, Some(false)); assert_eq!(empty.tone, ProviderNoticeTone::Warning); let stale = rate_limit_notice(60, true); - assert_eq!( - stale.message, - "Showing the last successful limits · Retrying in about 1 minute" + assert_eq!(stale.message, ""); + assert_eq!(stale.retry_seconds, Some(60)); + assert_eq!(stale.showing_stale_limits, Some(true)); + } + + #[test] + fn cooldown_rate_limit_keeps_only_typed_notice_dynamic_content() { + let directory = tempdir().unwrap(); + let account_root = directory.path().join("account"); + fs::create_dir_all(&account_root).unwrap(); + fs::write( + account_root.join(".credentials.json"), + credential_json("access", "refresh", "pro"), + ) + .unwrap(); + let scope = ClaudeCredentialScope::ConfigDir { + path: account_root, + keychain_literal: "test".into(), + }; + let mut candidate = auth::load_candidates(&scope).pop().unwrap(); + let provider = ClaudeProvider::new_scoped( + ClaudeRuntimeConfig { + definition: definition(), + credential_scope: scope, + account_identity: None, + log_roots: Vec::new(), + include_standard_logs: false, + include_pi: false, + }, + Arc::new(Storage::open(&directory.path().join("openquota.db")).unwrap()), + Arc::new(PricingStore::new(directory.path().join("pricing")).unwrap()), + ClaudeClient::new().unwrap(), ); + let now = Utc::now(); + provider.activate_live_usage_cache(candidate.fingerprint()); + *provider.last_good.lock().unwrap() = Some(ProviderSnapshot { + provider_id: "claude".into(), + plan: Some("pro".into()), + quotas: Vec::new(), + value_metrics: Vec::new(), + status_metrics: Vec::new(), + notices: Vec::new(), + usage: UsageHistory::default(), + warnings: vec!["keep this warning".into()], + refreshed_at: now, + }); + *provider.rate_limited_until.lock().unwrap() = Some(now + Duration::minutes(5)); + let mut generation = ClaudeCredentialGeneration::from_candidates(&[candidate.clone()]); + let config = ClaudeOAuthConfig { + usage_url: "https://example.test/usage".into(), + refresh_url: "https://example.test/refresh".into(), + client_id: "test".into(), + }; + + let snapshot = provider + .refresh_candidate( + &mut candidate, + &config, + now, + &crate::pricing::test_bundled_pricing(), + &mut generation, + ) + .unwrap(); + assert_eq!(snapshot.warnings, ["keep this warning"]); + assert_eq!(snapshot.notices.len(), 1); + assert_eq!(snapshot.notices[0].retry_seconds, Some(300)); + assert_eq!(snapshot.notices[0].showing_stale_limits, Some(true)); } #[test] diff --git a/src-tauri/src/providers/codex/local_usage.rs b/src-tauri/src/providers/codex/local_usage.rs index 4976540..921a9ba 100644 --- a/src-tauri/src/providers/codex/local_usage.rs +++ b/src-tauri/src/providers/codex/local_usage.rs @@ -64,12 +64,18 @@ pub fn scan_local_usage( false } }; - let source_note = if includes_pi { - "From your Codex logs and pi (estimated)" + let (source_note, source_kind) = if includes_pi { + ( + "From your Codex logs and pi (estimated)", + crate::models::UsageSourceKind::EstimatedLogsWithPi, + ) } else { - "From your Codex logs (estimated)" + ( + "From your Codex logs (estimated)", + crate::models::UsageSourceKind::EstimatedLogs, + ) }; - Ok(accumulator.build(now, source_note)) + Ok(accumulator.build_with_source_kind(now, source_note, Some(source_kind))) } fn scan_codex_events( @@ -489,7 +495,11 @@ fn auto_review_fallback(timestamp: &str) -> &'static str { fn aggregate(events: Vec, now: DateTime, pricing: &ModelPricing) -> UsageHistory { let mut accumulator = DailyUsageAccumulator::default(); aggregate_into(events, now, pricing, &mut accumulator); - accumulator.build(now, "From your Codex logs (estimated)") + accumulator.build_with_source_kind( + now, + "From your Codex logs (estimated)", + Some(crate::models::UsageSourceKind::EstimatedLogs), + ) } fn aggregate_into( diff --git a/src-tauri/src/providers/codex/mod.rs b/src-tauri/src/providers/codex/mod.rs index c8bf0a1..936f298 100644 --- a/src-tauri/src/providers/codex/mod.rs +++ b/src-tauri/src/providers/codex/mod.rs @@ -13,8 +13,8 @@ use thiserror::Error; use crate::{ hashing::sha256_hex, models::{ - MetricDefinition, MetricSection, ProviderDefinition, ProviderLink, ProviderSnapshot, - UsagePeriodSelection, + MetricDefinition, MetricLabelKind, MetricSection, ProviderDefinition, ProviderLink, + ProviderLinkKind, ProviderSnapshot, UsagePeriodSelection, UsageSourceKind, }, pricing::PricingStore, storage::Storage, @@ -32,9 +32,12 @@ pub(crate) fn definition() -> ProviderDefinition { short_name: "Cx".into(), fallback_enabled: true, local_usage_source_note: Some("From your Codex logs (estimated)".into()), + local_usage_source_kind: Some(UsageSourceKind::EstimatedLogs), links: vec![ - ProviderLink::new("Status", "https://status.openai.com/"), - ProviderLink::new("Dashboard", "https://chatgpt.com/codex/settings/usage"), + ProviderLink::new("Status", "https://status.openai.com/") + .with_kind(ProviderLinkKind::Status), + ProviderLink::new("Dashboard", "https://chatgpt.com/codex/settings/usage") + .with_kind(ProviderLinkKind::Dashboard), ], metrics: vec![ MetricDefinition::quota( @@ -46,7 +49,8 @@ pub(crate) fn definition() -> ProviderDefinition { MetricSection::AlwaysVisible, true, "S", - ), + ) + .with_label_kind(MetricLabelKind::Session), MetricDefinition::quota( "codex.weekly", "Weekly", @@ -56,7 +60,8 @@ pub(crate) fn definition() -> ProviderDefinition { MetricSection::AlwaysVisible, true, "W", - ), + ) + .with_label_kind(MetricLabelKind::Weekly), MetricDefinition::quota( "codex.spark", "Spark", @@ -76,8 +81,9 @@ pub(crate) fn definition() -> ProviderDefinition { MetricSection::OnDemand, false, "SW", - ), - MetricDefinition::trend("codex.trend"), + ) + .with_label_kind(MetricLabelKind::SparkWeekly), + MetricDefinition::trend("codex.trend").with_label_kind(MetricLabelKind::UsageTrend), MetricDefinition::value( "codex.credits", "Extra Usage", @@ -87,7 +93,8 @@ pub(crate) fn definition() -> ProviderDefinition { false, "E", None, - ), + ) + .with_label_kind(MetricLabelKind::ExtraUsage), MetricDefinition::value( "codex.rateLimitResets", "Rate Limit Resets", @@ -97,28 +104,32 @@ pub(crate) fn definition() -> ProviderDefinition { false, "R", Some("resets"), - ), + ) + .with_label_kind(MetricLabelKind::RateLimitResets), MetricDefinition::usage( "codex.today", "Today", UsagePeriodSelection::Today, MetricSection::OnDemand, "T", - ), + ) + .with_label_kind(MetricLabelKind::Today), MetricDefinition::usage( "codex.yesterday", "Yesterday", UsagePeriodSelection::Yesterday, MetricSection::OnDemand, "Y", - ), + ) + .with_label_kind(MetricLabelKind::Yesterday), MetricDefinition::usage( "codex.last30", "Last 30 Days", UsagePeriodSelection::Last30Days, MetricSection::OnDemand, "M", - ), + ) + .with_label_kind(MetricLabelKind::Last30Days), ], } } diff --git a/src-tauri/src/providers/copilot/mod.rs b/src-tauri/src/providers/copilot/mod.rs index ab1e448..06d039d 100644 --- a/src-tauri/src/providers/copilot/mod.rs +++ b/src-tauri/src/providers/copilot/mod.rs @@ -13,8 +13,8 @@ use reqwest::StatusCode; use thiserror::Error; use crate::models::{ - MetricDefinition, MetricSection, ProviderDefinition, ProviderErrorKind, ProviderLink, - ProviderSnapshot, UsageHistory, ValueMetric, + MetricDefinition, MetricLabelKind, MetricSection, ProviderDefinition, ProviderErrorKind, + ProviderLink, ProviderLinkKind, ProviderSnapshot, UsageHistory, ValueMetric, }; use self::{ @@ -36,9 +36,12 @@ pub(crate) fn definition() -> ProviderDefinition { short_name: "Co".into(), fallback_enabled: false, local_usage_source_note: None, + local_usage_source_kind: None, links: vec![ - ProviderLink::new("Status", "https://www.githubstatus.com/"), - ProviderLink::new("Dashboard", "https://github.com/settings/billing"), + ProviderLink::new("Status", "https://www.githubstatus.com/") + .with_kind(ProviderLinkKind::Status), + ProviderLink::new("Dashboard", "https://github.com/settings/billing") + .with_kind(ProviderLinkKind::Dashboard), ], metrics: vec![ MetricDefinition::quota( @@ -50,7 +53,8 @@ pub(crate) fn definition() -> ProviderDefinition { MetricSection::AlwaysVisible, true, "C", - ), + ) + .with_label_kind(MetricLabelKind::Credits), MetricDefinition::value( "copilot.extra", "Extra Usage", @@ -60,7 +64,8 @@ pub(crate) fn definition() -> ProviderDefinition { false, "E", None, - ), + ) + .with_label_kind(MetricLabelKind::ExtraUsage), MetricDefinition::value( "copilot.orgCredits", "Org Credits", @@ -70,7 +75,8 @@ pub(crate) fn definition() -> ProviderDefinition { false, "OC", None, - ), + ) + .with_label_kind(MetricLabelKind::OrgCredits), MetricDefinition::value( "copilot.orgSpend", "Org Spend", @@ -80,7 +86,8 @@ pub(crate) fn definition() -> ProviderDefinition { false, "OS", None, - ), + ) + .with_label_kind(MetricLabelKind::OrgSpend), MetricDefinition::quota( "copilot.chat", "Chat", @@ -90,7 +97,8 @@ pub(crate) fn definition() -> ProviderDefinition { MetricSection::OnDemand, false, "Ch", - ), + ) + .with_label_kind(MetricLabelKind::Chat), MetricDefinition::quota( "copilot.completions", "Completions", @@ -100,7 +108,8 @@ pub(crate) fn definition() -> ProviderDefinition { MetricSection::OnDemand, false, "Cm", - ), + ) + .with_label_kind(MetricLabelKind::Completions), ], } } diff --git a/src-tauri/src/providers/cursor/mapper.rs b/src-tauri/src/providers/cursor/mapper.rs index 8946c0f..e0d8775 100644 --- a/src-tauri/src/providers/cursor/mapper.rs +++ b/src-tauri/src/providers/cursor/mapper.rs @@ -513,7 +513,11 @@ pub fn usage_history( None => {} } } - accumulator.build(now, "From your Cursor usage export") + accumulator.build_with_source_kind( + now, + "From your Cursor usage export", + Some(crate::models::UsageSourceKind::CursorExport), + ) } fn quota( diff --git a/src-tauri/src/providers/cursor/mod.rs b/src-tauri/src/providers/cursor/mod.rs index 24218f5..d0ee471 100644 --- a/src-tauri/src/providers/cursor/mod.rs +++ b/src-tauri/src/providers/cursor/mod.rs @@ -12,8 +12,8 @@ use thiserror::Error; use crate::{ models::{ - MetricDefinition, MetricSection, ProviderDefinition, ProviderLink, ProviderSnapshot, - UsageHistory, UsagePeriodSelection, + MetricDefinition, MetricLabelKind, MetricSection, ProviderDefinition, ProviderLink, + ProviderLinkKind, ProviderSnapshot, UsageHistory, UsagePeriodSelection, UsageSourceKind, }, pricing::PricingStore, }; @@ -35,9 +35,12 @@ pub(crate) fn definition() -> ProviderDefinition { short_name: "Cu".into(), fallback_enabled: true, local_usage_source_note: Some("From your Cursor usage export".into()), + local_usage_source_kind: Some(UsageSourceKind::CursorExport), links: vec![ - ProviderLink::new("Status", "https://status.cursor.com/"), - ProviderLink::new("Dashboard", "https://www.cursor.com/dashboard"), + ProviderLink::new("Status", "https://status.cursor.com/") + .with_kind(ProviderLinkKind::Status), + ProviderLink::new("Dashboard", "https://www.cursor.com/dashboard") + .with_kind(ProviderLinkKind::Dashboard), ], metrics: vec![ MetricDefinition::quota( @@ -49,7 +52,8 @@ pub(crate) fn definition() -> ProviderDefinition { MetricSection::AlwaysVisible, false, "U", - ), + ) + .with_label_kind(MetricLabelKind::TotalUsage), MetricDefinition::quota( "cursor.auto", "Auto Usage", @@ -59,7 +63,8 @@ pub(crate) fn definition() -> ProviderDefinition { MetricSection::AlwaysVisible, true, "A", - ), + ) + .with_label_kind(MetricLabelKind::AutoUsage), MetricDefinition::quota( "cursor.api", "API Usage", @@ -69,7 +74,8 @@ pub(crate) fn definition() -> ProviderDefinition { MetricSection::AlwaysVisible, true, "AP", - ), + ) + .with_label_kind(MetricLabelKind::ApiUsage), MetricDefinition::quota_or_value( "cursor.onDemand", "Extra Usage", @@ -78,7 +84,8 @@ pub(crate) fn definition() -> ProviderDefinition { MetricSection::OnDemand, false, "E", - ), + ) + .with_label_kind(MetricLabelKind::ExtraUsage), MetricDefinition::quota( "cursor.requests", "Requests", @@ -88,7 +95,8 @@ pub(crate) fn definition() -> ProviderDefinition { MetricSection::OnDemand, false, "R", - ), + ) + .with_label_kind(MetricLabelKind::Requests), MetricDefinition::value( "cursor.credits", "Credits", @@ -98,29 +106,33 @@ pub(crate) fn definition() -> ProviderDefinition { false, "C", None, - ), - MetricDefinition::trend("cursor.trend"), + ) + .with_label_kind(MetricLabelKind::Credits), + MetricDefinition::trend("cursor.trend").with_label_kind(MetricLabelKind::UsageTrend), MetricDefinition::usage( "cursor.today", "Today", UsagePeriodSelection::Today, MetricSection::OnDemand, "T", - ), + ) + .with_label_kind(MetricLabelKind::Today), MetricDefinition::usage( "cursor.yesterday", "Yesterday", UsagePeriodSelection::Yesterday, MetricSection::OnDemand, "Y", - ), + ) + .with_label_kind(MetricLabelKind::Yesterday), MetricDefinition::usage( "cursor.last30", "Last 30 Days", UsagePeriodSelection::Last30Days, MetricSection::OnDemand, "M", - ), + ) + .with_label_kind(MetricLabelKind::Last30Days), ], } } diff --git a/src-tauri/src/providers/daily_usage.rs b/src-tauri/src/providers/daily_usage.rs index 059a621..421fec2 100644 --- a/src-tauri/src/providers/daily_usage.rs +++ b/src-tauri/src/providers/daily_usage.rs @@ -7,6 +7,7 @@ use chrono::{DateTime, Days, Local, NaiveDate, Utc}; use crate::models::{ DailyUsage, ModelUsageBreakdown, ModelUsageEntry, ModelUsageVariant, UsageHistory, UsagePeriod, + UsageSourceKind, }; /// Provider-neutral accumulator for priced local-log usage. Claude and Codex keep their own parsing @@ -154,7 +155,17 @@ impl DailyUsageAccumulator { /// Builds the three spend periods. Idle or unknown-only periods stay unbacked (`None`), while /// the trend receives only active days and fills calendar gaps in the UI layer. + #[cfg(test)] pub fn build(self, now: DateTime, source_note: &str) -> UsageHistory { + self.build_with_source_kind(now, source_note, None) + } + + pub fn build_with_source_kind( + self, + now: DateTime, + source_note: &str, + source_kind: Option, + ) -> UsageHistory { let today = now.with_timezone(&Local).date_naive(); let yesterday = today.checked_sub_days(Days::new(1)); let daily = self @@ -173,9 +184,9 @@ impl DailyUsageAccumulator { }) .collect(); - let today_period = self.period_for_days(&[today], source_note); + let today_period = self.period_for_days(&[today], source_note, source_kind); let yesterday_period = - yesterday.and_then(|date| self.period_for_days(&[date], source_note)); + yesterday.and_then(|date| self.period_for_days(&[date], source_note, source_kind)); let active_days = self .days .iter() @@ -191,7 +202,7 @@ impl DailyUsageAccumulator { .collect::>(); unknown_models.sort(); let last_30_days = self - .period_for_days(&active_days, source_note) + .period_for_days(&active_days, source_note, source_kind) .map(|mut period| { period.estimate_complete = unknown_models.is_empty(); period.unknown_models.clone_from(&unknown_models); @@ -207,7 +218,12 @@ impl DailyUsageAccumulator { } } - fn period_for_days(&self, dates: &[NaiveDate], source_note: &str) -> Option { + fn period_for_days( + &self, + dates: &[NaiveDate], + source_note: &str, + source_kind: Option, + ) -> Option { let mut total = DayAccumulator::default(); let mut unknown_models = HashSet::new(); for date in dates { @@ -233,7 +249,7 @@ impl DailyUsageAccumulator { estimated_cost_usd: Some(total.cost), cost_estimated: total.cost_estimated, estimate_complete: unknown_models.is_empty(), - model_breakdown: model_breakdown(&total, source_note), + model_breakdown: model_breakdown(&total, source_note, source_kind), unknown_models, }) } @@ -252,7 +268,11 @@ fn has_usage(day: &DayAccumulator) -> bool { day.tokens > 0 || day.cost > 0.0 } -fn model_breakdown(day: &DayAccumulator, source_note: &str) -> Option { +fn model_breakdown( + day: &DayAccumulator, + source_note: &str, + source_kind: Option, +) -> Option { let mut entries = day .models .values() @@ -340,6 +360,7 @@ fn model_breakdown(day: &DayAccumulator, source_note: &str) -> Option ProviderDefinition { short_name: "D".into(), fallback_enabled: false, local_usage_source_note: None, - links: vec![ProviderLink::new( - "Dashboard", - "https://app.devin.ai/settings/plans", - )], + local_usage_source_kind: None, + links: vec![ + ProviderLink::new("Dashboard", "https://app.devin.ai/settings/plans") + .with_kind(ProviderLinkKind::Dashboard), + ], metrics: vec![ MetricDefinition::quota( "devin.daily", @@ -40,7 +41,8 @@ pub(crate) fn definition() -> ProviderDefinition { MetricSection::AlwaysVisible, false, "D", - ), + ) + .with_label_kind(MetricLabelKind::Daily), MetricDefinition::quota( "devin.weekly", "Weekly", @@ -50,7 +52,8 @@ pub(crate) fn definition() -> ProviderDefinition { MetricSection::AlwaysVisible, false, "W", - ), + ) + .with_label_kind(MetricLabelKind::Weekly), MetricDefinition::value( "devin.extra", "Extra Balance", @@ -60,7 +63,8 @@ pub(crate) fn definition() -> ProviderDefinition { false, "E", None, - ), + ) + .with_label_kind(MetricLabelKind::ExtraBalance), ], } } diff --git a/src-tauri/src/providers/grok/local_usage.rs b/src-tauri/src/providers/grok/local_usage.rs index 92ff5ee..aee884d 100644 --- a/src-tauri/src/providers/grok/local_usage.rs +++ b/src-tauri/src/providers/grok/local_usage.rs @@ -224,7 +224,11 @@ fn aggregate(events: Vec, now: DateTime, pricing: &ModelPricing accumulator.add_unknown_model(date, model); } } - accumulator.build(now, SOURCE_NOTE) + accumulator.build_with_source_kind( + now, + SOURCE_NOTE, + Some(crate::models::UsageSourceKind::EstimatedLogs), + ) } fn integer_u64(value: &Value) -> Option { diff --git a/src-tauri/src/providers/grok/mapper.rs b/src-tauri/src/providers/grok/mapper.rs index 6d44df6..8010a49 100644 --- a/src-tauri/src/providers/grok/mapper.rs +++ b/src-tauri/src/providers/grok/mapper.rs @@ -2,7 +2,7 @@ use chrono::{DateTime, Utc}; use reqwest::StatusCode; use serde_json::Value; -use crate::models::{QuotaFormat, QuotaWindow, StatusMetric, StatusTone}; +use crate::models::{QuotaFormat, QuotaWindow, StatusMetric, StatusMetricUnit, StatusTone}; use super::{client::GrokResponse, GrokError}; @@ -26,20 +26,19 @@ struct CreditsConfig { pub fn map_credits(response: &GrokResponse) -> Result { require_success(response.status)?; let config = decode_credits(&response.body)?; + let has_cap = config.on_demand_cap > 0.0; let status_metrics = vec![StatusMetric { id: "payAsYouGo".into(), label: "Pay as you go".into(), - text: if config.on_demand_cap > 0.0 { - format!("{} cap", format_units(config.on_demand_cap)) - } else { - "Disabled".into() - }, - tone: if config.on_demand_cap > 0.0 { + text: String::new(), + tone: if has_cap { StatusTone::Positive } else { StatusTone::Neutral }, subtitle: None, + value: has_cap.then_some(config.on_demand_cap), + unit: has_cap.then_some(StatusMetricUnit::Cap), }]; let quotas = (config.period_type == WEEKLY_PERIOD_TYPE) .then(|| QuotaWindow { @@ -148,14 +147,6 @@ fn finite_number(value: &Value) -> Option { .filter(|value: &f64| value.is_finite()) } -fn format_units(value: f64) -> String { - if value.fract() == 0.0 { - format!("{value:.0}") - } else { - value.to_string() - } -} - #[cfg(test)] mod tests { use reqwest::StatusCode; @@ -205,7 +196,9 @@ mod tests { weekly.resets_at.unwrap().to_rfc3339(), "2026-07-07T21:36:52.140114+00:00" ); - assert_eq!(mapped.status_metrics[0].text, "Disabled"); + assert_eq!(mapped.status_metrics[0].text, ""); + assert_eq!(mapped.status_metrics[0].value, None); + assert_eq!(mapped.status_metrics[0].unit, None); assert_eq!(mapped.status_metrics[0].tone, StatusTone::Neutral); } @@ -218,7 +211,12 @@ mod tests { ))) .unwrap(); - assert_eq!(mapped.status_metrics[0].text, "2500 cap"); + assert_eq!(mapped.status_metrics[0].text, ""); + assert_eq!(mapped.status_metrics[0].value, Some(2500.0)); + assert_eq!( + mapped.status_metrics[0].unit, + Some(crate::models::StatusMetricUnit::Cap) + ); assert_eq!(mapped.status_metrics[0].tone, StatusTone::Positive); } @@ -232,7 +230,7 @@ mod tests { .unwrap(); assert!(mapped.quotas.is_empty()); - assert_eq!(mapped.status_metrics[0].text, "Disabled"); + assert_eq!(mapped.status_metrics[0].text, ""); } #[test] diff --git a/src-tauri/src/providers/grok/mod.rs b/src-tauri/src/providers/grok/mod.rs index 082fa0e..99af990 100644 --- a/src-tauri/src/providers/grok/mod.rs +++ b/src-tauri/src/providers/grok/mod.rs @@ -11,8 +11,8 @@ use thiserror::Error; use crate::{ models::{ - MetricDefinition, MetricSection, ProviderDefinition, ProviderErrorKind, ProviderLink, - ProviderSnapshot, UsagePeriodSelection, + MetricDefinition, MetricLabelKind, MetricSection, ProviderDefinition, ProviderErrorKind, + ProviderLink, ProviderLinkKind, ProviderSnapshot, UsagePeriodSelection, UsageSourceKind, }, pricing::PricingStore, providers::log_usage::scan_or_cached_usage, @@ -35,7 +35,9 @@ pub(crate) fn definition() -> ProviderDefinition { short_name: "G".into(), fallback_enabled: false, local_usage_source_note: Some("From your Grok logs (estimated)".into()), - links: vec![ProviderLink::new("Usage", "https://grok.com/?_s=usage")], + local_usage_source_kind: Some(UsageSourceKind::EstimatedLogs), + links: vec![ProviderLink::new("Usage", "https://grok.com/?_s=usage") + .with_kind(ProviderLinkKind::Usage)], metrics: vec![ MetricDefinition::quota( "grok.weekly", @@ -46,7 +48,8 @@ pub(crate) fn definition() -> ProviderDefinition { MetricSection::AlwaysVisible, false, "W", - ), + ) + .with_label_kind(MetricLabelKind::Weekly), MetricDefinition::status( "grok.payAsYouGo", "Extra Usage", @@ -55,29 +58,33 @@ pub(crate) fn definition() -> ProviderDefinition { MetricSection::OnDemand, false, "E", - ), - MetricDefinition::trend("grok.trend"), + ) + .with_label_kind(MetricLabelKind::ExtraUsage), + MetricDefinition::trend("grok.trend").with_label_kind(MetricLabelKind::UsageTrend), MetricDefinition::usage( "grok.today", "Today", UsagePeriodSelection::Today, MetricSection::OnDemand, "T", - ), + ) + .with_label_kind(MetricLabelKind::Today), MetricDefinition::usage( "grok.yesterday", "Yesterday", UsagePeriodSelection::Yesterday, MetricSection::OnDemand, "Y", - ), + ) + .with_label_kind(MetricLabelKind::Yesterday), MetricDefinition::usage( "grok.last30", "Last 30 Days", UsagePeriodSelection::Last30Days, MetricSection::OnDemand, "M", - ), + ) + .with_label_kind(MetricLabelKind::Last30Days), ], } } diff --git a/src-tauri/src/providers/grok/tests.rs b/src-tauri/src/providers/grok/tests.rs index 7c37f32..d0ba5e8 100644 --- a/src-tauri/src/providers/grok/tests.rs +++ b/src-tauri/src/providers/grok/tests.rs @@ -127,7 +127,8 @@ fn weekly_status_plan_and_local_history_form_one_snapshot() { assert_eq!(snapshot.plan.as_deref(), Some("SuperGrok Heavy")); assert_eq!(snapshot.quotas[0].id, "weekly"); assert_eq!(snapshot.quotas[0].used_percent, 99.0); - assert_eq!(snapshot.status_metrics[0].text, "Disabled"); + assert_eq!(snapshot.status_metrics[0].text, ""); + assert_eq!(snapshot.status_metrics[0].value, None); assert_eq!(snapshot.status_metrics[0].tone, StatusTone::Neutral); assert_eq!(snapshot.usage.today.unwrap().tokens, 2_000_000); assert!(snapshot.warnings.is_empty()); @@ -267,7 +268,7 @@ fn monthly_accounts_keep_extra_usage_without_a_fake_weekly_meter() { let snapshot = provider.refresh_inner().unwrap(); assert!(snapshot.quotas.is_empty()); - assert_eq!(snapshot.status_metrics[0].text, "Disabled"); + assert_eq!(snapshot.status_metrics[0].text, ""); server.finish(); } diff --git a/src-tauri/src/providers/kimi/mod.rs b/src-tauri/src/providers/kimi/mod.rs index 5ece087..e18ed81 100644 --- a/src-tauri/src/providers/kimi/mod.rs +++ b/src-tauri/src/providers/kimi/mod.rs @@ -9,8 +9,8 @@ use reqwest::StatusCode; use thiserror::Error; use crate::models::{ - ApiKeyStatus, MetricDefinition, MetricSection, ProviderDefinition, ProviderErrorKind, - ProviderLink, ProviderSnapshot, UsageHistory, + ApiKeyStatus, MetricDefinition, MetricLabelKind, MetricSection, ProviderDefinition, + ProviderErrorKind, ProviderLink, ProviderLinkKind, ProviderSnapshot, UsageHistory, }; use self::{ @@ -28,9 +28,12 @@ pub(crate) fn definition() -> ProviderDefinition { short_name: "K".into(), fallback_enabled: false, local_usage_source_note: None, + local_usage_source_kind: None, links: vec![ - ProviderLink::new("Dashboard", "https://www.kimi.com/code/console"), - ProviderLink::new("API Keys", "https://www.kimi.com/code/console"), + ProviderLink::new("Dashboard", "https://www.kimi.com/code/console") + .with_kind(ProviderLinkKind::Dashboard), + ProviderLink::new("API Keys", "https://www.kimi.com/code/console") + .with_kind(ProviderLinkKind::ApiKeys), ], metrics: vec![ MetricDefinition::quota( @@ -42,7 +45,8 @@ pub(crate) fn definition() -> ProviderDefinition { MetricSection::AlwaysVisible, true, "S", - ), + ) + .with_label_kind(MetricLabelKind::Session), MetricDefinition::quota( "kimi.weekly", "Weekly", @@ -52,7 +56,8 @@ pub(crate) fn definition() -> ProviderDefinition { MetricSection::AlwaysVisible, true, "W", - ), + ) + .with_label_kind(MetricLabelKind::Weekly), ], } } diff --git a/src-tauri/src/providers/minimax/mod.rs b/src-tauri/src/providers/minimax/mod.rs index 3c7a62a..c01e64c 100644 --- a/src-tauri/src/providers/minimax/mod.rs +++ b/src-tauri/src/providers/minimax/mod.rs @@ -9,8 +9,8 @@ use reqwest::StatusCode; use thiserror::Error; use crate::models::{ - ApiKeyStatus, MetricDefinition, MetricSection, ProviderDefinition, ProviderErrorKind, - ProviderLink, ProviderSnapshot, UsageHistory, + ApiKeyStatus, MetricDefinition, MetricLabelKind, MetricSection, ProviderDefinition, + ProviderErrorKind, ProviderLink, ProviderLinkKind, ProviderSnapshot, UsageHistory, }; use self::{ @@ -28,9 +28,12 @@ pub(crate) fn definition() -> ProviderDefinition { short_name: "M".into(), fallback_enabled: false, local_usage_source_note: None, + local_usage_source_kind: None, links: vec![ - ProviderLink::new("Dashboard", "https://platform.minimax.io/console/plan"), - ProviderLink::new("API Keys", "https://platform.minimax.io/console/access"), + ProviderLink::new("Dashboard", "https://platform.minimax.io/console/plan") + .with_kind(ProviderLinkKind::Dashboard), + ProviderLink::new("API Keys", "https://platform.minimax.io/console/access") + .with_kind(ProviderLinkKind::ApiKeys), ], metrics: vec![ MetricDefinition::quota( @@ -42,7 +45,8 @@ pub(crate) fn definition() -> ProviderDefinition { MetricSection::AlwaysVisible, true, "S", - ), + ) + .with_label_kind(MetricLabelKind::Session), MetricDefinition::quota( "minimax.weekly", "Weekly", @@ -52,7 +56,8 @@ pub(crate) fn definition() -> ProviderDefinition { MetricSection::AlwaysVisible, true, "W", - ), + ) + .with_label_kind(MetricLabelKind::Weekly), ], } } diff --git a/src-tauri/src/providers/opencode/mod.rs b/src-tauri/src/providers/opencode/mod.rs index f745e49..167be08 100644 --- a/src-tauri/src/providers/opencode/mod.rs +++ b/src-tauri/src/providers/opencode/mod.rs @@ -11,8 +11,9 @@ use thiserror::Error; use crate::{ models::{ - MetricDefinition, MetricSection, ProviderDefinition, ProviderErrorKind, ProviderLink, - ProviderSnapshot, UsageHistory, UsagePeriodSelection, + MetricDefinition, MetricLabelKind, MetricSection, ProviderDefinition, ProviderErrorKind, + ProviderLink, ProviderLinkKind, ProviderSnapshot, UsageHistory, UsagePeriodSelection, + UsageSourceKind, }, pricing::PricingStore, }; @@ -32,7 +33,9 @@ pub(crate) fn definition() -> ProviderDefinition { short_name: "OC".into(), fallback_enabled: false, local_usage_source_note: Some(USAGE_SOURCE_NOTE.into()), - links: vec![ProviderLink::new("Dashboard", "https://opencode.ai/auth")], + local_usage_source_kind: Some(UsageSourceKind::OpenCodeDatabase), + links: vec![ProviderLink::new("Dashboard", "https://opencode.ai/auth") + .with_kind(ProviderLinkKind::Dashboard)], metrics: vec![ MetricDefinition::quota( "opencode.session", @@ -43,7 +46,8 @@ pub(crate) fn definition() -> ProviderDefinition { MetricSection::AlwaysVisible, false, "S", - ), + ) + .with_label_kind(MetricLabelKind::Session), MetricDefinition::quota( "opencode.weekly", "Weekly", @@ -53,7 +57,8 @@ pub(crate) fn definition() -> ProviderDefinition { MetricSection::AlwaysVisible, false, "W", - ), + ) + .with_label_kind(MetricLabelKind::Weekly), MetricDefinition::quota( "opencode.monthly", "Monthly", @@ -63,29 +68,33 @@ pub(crate) fn definition() -> ProviderDefinition { MetricSection::AlwaysVisible, false, "M", - ), - MetricDefinition::trend("opencode.trend"), + ) + .with_label_kind(MetricLabelKind::Monthly), + MetricDefinition::trend("opencode.trend").with_label_kind(MetricLabelKind::UsageTrend), MetricDefinition::usage( "opencode.today", "Today", UsagePeriodSelection::Today, MetricSection::OnDemand, "T", - ), + ) + .with_label_kind(MetricLabelKind::Today), MetricDefinition::usage( "opencode.yesterday", "Yesterday", UsagePeriodSelection::Yesterday, MetricSection::OnDemand, "Y", - ), + ) + .with_label_kind(MetricLabelKind::Yesterday), MetricDefinition::usage( "opencode.last30", "Last 30 Days", UsagePeriodSelection::Last30Days, MetricSection::OnDemand, "30", - ), + ) + .with_label_kind(MetricLabelKind::Last30Days), ], } } diff --git a/src-tauri/src/providers/opencode/scanner.rs b/src-tauri/src/providers/opencode/scanner.rs index 4acc6e7..f181670 100644 --- a/src-tauri/src/providers/opencode/scanner.rs +++ b/src-tauri/src/providers/opencode/scanner.rs @@ -233,7 +233,11 @@ fn aggregate_history(records: &[UsageRecord], now: DateTime) -> UsageHistor accumulator.add_unknown_model(date, &record.model); } } - accumulator.build(now, USAGE_SOURCE_NOTE) + accumulator.build_with_source_kind( + now, + USAGE_SOURCE_NOTE, + Some(crate::models::UsageSourceKind::OpenCodeDatabase), + ) } #[cfg(test)] diff --git a/src-tauri/src/providers/openrouter/mod.rs b/src-tauri/src/providers/openrouter/mod.rs index a48ef11..c740087 100644 --- a/src-tauri/src/providers/openrouter/mod.rs +++ b/src-tauri/src/providers/openrouter/mod.rs @@ -9,8 +9,8 @@ use thiserror::Error; use crate::{ models::{ - ApiKeyStatus, MetricDefinition, MetricSection, ProviderDefinition, ProviderErrorKind, - ProviderLink, ProviderSnapshot, UsageHistory, + ApiKeyStatus, MetricDefinition, MetricLabelKind, MetricSection, ProviderDefinition, + ProviderErrorKind, ProviderLink, ProviderLinkKind, ProviderSnapshot, UsageHistory, }, providers::api_key::ApiKeyStore, }; @@ -32,9 +32,12 @@ pub(crate) fn definition() -> ProviderDefinition { short_name: "OR".into(), fallback_enabled: false, local_usage_source_note: None, + local_usage_source_kind: None, links: vec![ - ProviderLink::new("Activity", "https://openrouter.ai/activity"), - ProviderLink::new("Credits", "https://openrouter.ai/settings/credits"), + ProviderLink::new("Activity", "https://openrouter.ai/activity") + .with_kind(ProviderLinkKind::Activity), + ProviderLink::new("Credits", "https://openrouter.ai/settings/credits") + .with_kind(ProviderLinkKind::Credits), ], metrics: vec![ MetricDefinition::quota( @@ -46,7 +49,8 @@ pub(crate) fn definition() -> ProviderDefinition { MetricSection::AlwaysVisible, true, "C", - ), + ) + .with_label_kind(MetricLabelKind::Credits), MetricDefinition::value( "openrouter.balance", "Balance", @@ -56,7 +60,8 @@ pub(crate) fn definition() -> ProviderDefinition { false, "B", None, - ), + ) + .with_label_kind(MetricLabelKind::Balance), MetricDefinition::value( "openrouter.today", "Today", @@ -66,7 +71,8 @@ pub(crate) fn definition() -> ProviderDefinition { false, "T", None, - ), + ) + .with_label_kind(MetricLabelKind::Today), MetricDefinition::value( "openrouter.week", "This Week", @@ -76,7 +82,8 @@ pub(crate) fn definition() -> ProviderDefinition { false, "W", None, - ), + ) + .with_label_kind(MetricLabelKind::ThisWeek), MetricDefinition::value( "openrouter.month", "This Month", @@ -86,7 +93,8 @@ pub(crate) fn definition() -> ProviderDefinition { false, "M", None, - ), + ) + .with_label_kind(MetricLabelKind::ThisMonth), MetricDefinition::quota( "openrouter.keyLimit", "Key Limit", @@ -96,7 +104,8 @@ pub(crate) fn definition() -> ProviderDefinition { MetricSection::OnDemand, false, "K", - ), + ) + .with_label_kind(MetricLabelKind::KeyLimit), ], } } diff --git a/src-tauri/src/providers/registry.rs b/src-tauri/src/providers/registry.rs index c92f4c0..8d1b754 100644 --- a/src-tauri/src/providers/registry.rs +++ b/src-tauri/src/providers/registry.rs @@ -306,6 +306,7 @@ mod tests { short_name: "P".into(), fallback_enabled: true, local_usage_source_note: None, + local_usage_source_kind: None, links: vec![], metrics: vec![MetricDefinition::new( format!("{id}.session"), @@ -350,6 +351,24 @@ mod tests { assert!(registry.metric("first.session").is_some()); } + #[test] + fn metric_labels_use_explicit_keys_without_id_suffix_inference() { + let mut custom = definition("custom"); + custom.metrics[0].label = "Custom Session".into(); + let custom_registry = ProviderRegistry::new(vec![runtime(custom)]).unwrap(); + assert_eq!( + custom_registry.metric("custom.session").unwrap().label_kind, + None + ); + + let builtin = + ProviderRegistry::new(vec![runtime(crate::providers::codex::definition())]).unwrap(); + assert_eq!( + builtin.metric("codex.session").unwrap().label_kind, + Some(crate::models::MetricLabelKind::Session) + ); + } + #[test] fn registry_exposes_only_trimmed_http_provider_links() { let mut provider = definition("links"); @@ -470,7 +489,8 @@ mod tests { #[test] fn builtin_provider_catalog_keeps_the_product_defaults() { use crate::providers::{ - antigravity, claude, codex, copilot, cursor, devin, grok, opencode, openrouter, zai, + antigravity, claude, codex, copilot, cursor, devin, grok, kimi, minimax, opencode, + openrouter, zai, }; let registry = ProviderRegistry::new(vec![ @@ -481,6 +501,8 @@ mod tests { runtime(copilot::definition()), runtime(devin::definition()), runtime(grok::definition()), + runtime(kimi::definition()), + runtime(minimax::definition()), runtime(opencode::definition()), runtime(openrouter::definition()), runtime(zai::definition()), @@ -502,11 +524,18 @@ mod tests { "copilot", "devin", "grok", + "kimi", + "minimax", "opencode", "openrouter", "zai", ] ); + assert!(catalog + .providers + .iter() + .flat_map(|provider| &provider.links) + .all(|link| link.kind.is_some())); assert_eq!( registry .definition("codex") diff --git a/src-tauri/src/providers/zai/mod.rs b/src-tauri/src/providers/zai/mod.rs index 909b61b..6c08669 100644 --- a/src-tauri/src/providers/zai/mod.rs +++ b/src-tauri/src/providers/zai/mod.rs @@ -9,8 +9,8 @@ use reqwest::StatusCode; use thiserror::Error; use crate::models::{ - ApiKeyStatus, MetricDefinition, MetricSection, ProviderDefinition, ProviderErrorKind, - ProviderLink, ProviderSnapshot, UsageHistory, + ApiKeyStatus, MetricDefinition, MetricLabelKind, MetricSection, ProviderDefinition, + ProviderErrorKind, ProviderLink, ProviderLinkKind, ProviderSnapshot, UsageHistory, }; use self::{ @@ -28,12 +28,15 @@ pub(crate) fn definition() -> ProviderDefinition { short_name: "Z".into(), fallback_enabled: false, local_usage_source_note: None, + local_usage_source_kind: None, links: vec![ ProviderLink::new( "Dashboard", "https://z.ai/manage-apikey/coding-plan/personal/my-plan", - ), - ProviderLink::new("API Keys", "https://z.ai/manage-apikey/apikey-list"), + ) + .with_kind(ProviderLinkKind::Dashboard), + ProviderLink::new("API Keys", "https://z.ai/manage-apikey/apikey-list") + .with_kind(ProviderLinkKind::ApiKeys), ], metrics: vec![ MetricDefinition::quota( @@ -45,7 +48,8 @@ pub(crate) fn definition() -> ProviderDefinition { MetricSection::AlwaysVisible, true, "S", - ), + ) + .with_label_kind(MetricLabelKind::Session), MetricDefinition::quota( "zai.weekly", "Weekly", @@ -55,7 +59,8 @@ pub(crate) fn definition() -> ProviderDefinition { MetricSection::AlwaysVisible, true, "W", - ), + ) + .with_label_kind(MetricLabelKind::Weekly), MetricDefinition::quota( "zai.webSearches", "Web Searches", @@ -65,7 +70,8 @@ pub(crate) fn definition() -> ProviderDefinition { MetricSection::OnDemand, false, "Search", - ), + ) + .with_label_kind(MetricLabelKind::WebSearches), ], } } diff --git a/src-tauri/src/service.rs b/src-tauri/src/service.rs index 9fec27e..5b334fa 100644 --- a/src-tauri/src/service.rs +++ b/src-tauri/src/service.rs @@ -9,6 +9,7 @@ use chrono::Utc; use crate::{ models::{ MetricSource, ProviderErrorKind, ProviderSnapshot, ProviderViewState, SnapshotSource, + StatusMetric, StatusMetricUnit, StatusTone, }, policy::{FAILURE_RETRY_BACKOFF, REFRESH_INTERVAL, STALE_AFTER}, providers::{ProviderError, ProviderRefresh, ProviderRegistry}, @@ -611,13 +612,27 @@ fn validate_snapshot( || snapshot .status_metrics .iter() - .any(|metric| metric.text.trim().is_empty() || metric.label.trim().is_empty()) + .any(|metric| metric.label.trim().is_empty() || !status_metric_has_content(metric)) { return Err(snapshot_contract_error()); } Ok(snapshot) } +fn status_metric_has_content(metric: &StatusMetric) -> bool { + if !metric.text.trim().is_empty() { + return true; + } + + match (metric.id.as_str(), metric.tone, metric.value, metric.unit) { + ("payAsYouGo", StatusTone::Positive, Some(value), Some(StatusMetricUnit::Cap)) => { + value.is_finite() && value > 0.0 + } + ("payAsYouGo", StatusTone::Neutral, None, None) => true, + _ => false, + } +} + fn has_duplicate_ids<'a>(mut ids: impl Iterator) -> bool { let mut seen = std::collections::HashSet::new(); ids.any(|id| !seen.insert(id)) @@ -674,8 +689,8 @@ mod tests { use crate::{ models::{ MetricDefinition, MetricSection, MetricSource, ProviderDefinition, ProviderErrorKind, - ProviderSnapshot, ProviderViewState, QuotaFormat, QuotaWindow, SnapshotSource, - StatusMetric, StatusTone, UsageHistory, + ProviderNotice, ProviderNoticeTone, ProviderSnapshot, ProviderViewState, QuotaFormat, + QuotaWindow, SnapshotSource, StatusMetric, StatusMetricUnit, StatusTone, UsageHistory, }, policy::{FAILURE_RETRY_BACKOFF, STALE_AFTER}, providers::{ @@ -811,6 +826,7 @@ mod tests { short_name: "T".into(), fallback_enabled: true, local_usage_source_note: None, + local_usage_source_kind: None, links: vec![], metrics: vec![MetricDefinition::new( format!("{id}.session"), @@ -1012,6 +1028,7 @@ mod tests { short_name: "D".into(), fallback_enabled: true, local_usage_source_note: None, + local_usage_source_kind: None, links: Vec::new(), metrics: vec![ MetricDefinition::quota( @@ -1025,9 +1042,9 @@ mod tests { "S", ), MetricDefinition::status( - "dynamic.extra", + "dynamic.payAsYouGo", "Extra Usage", - "extra", + "payAsYouGo", true, MetricSection::OnDemand, false, @@ -1051,15 +1068,55 @@ mod tests { source_note: None, }); snapshot.status_metrics.push(StatusMetric { - id: "extra".into(), + id: "payAsYouGo".into(), label: "Extra Usage".into(), text: "2500 cap".into(), tone: StatusTone::Positive, subtitle: None, + value: None, + unit: None, }); assert!(validate_snapshot(®istry, "dynamic", snapshot.clone()).is_ok()); + let mut typed_cap = snapshot.clone(); + typed_cap.status_metrics[0].text.clear(); + typed_cap.status_metrics[0].value = Some(2500.0); + typed_cap.status_metrics[0].unit = Some(StatusMetricUnit::Cap); + assert!(validate_snapshot(®istry, "dynamic", typed_cap.clone()).is_ok()); + + let mut disabled = typed_cap.clone(); + disabled.status_metrics[0].tone = StatusTone::Neutral; + disabled.status_metrics[0].value = None; + disabled.status_metrics[0].unit = None; + assert!(validate_snapshot(®istry, "dynamic", disabled).is_ok()); + + let mut missing_value = typed_cap.clone(); + missing_value.status_metrics[0].value = None; + assert!(validate_snapshot(®istry, "dynamic", missing_value).is_err()); + + let mut nan_value = typed_cap.clone(); + nan_value.status_metrics[0].value = Some(f64::NAN); + assert!(validate_snapshot(®istry, "dynamic", nan_value).is_err()); + + let mut unknown_empty = typed_cap.clone(); + unknown_empty.status_metrics[0].id = "unknown".into(); + unknown_empty.status_metrics[0].text.clear(); + unknown_empty.status_metrics[0].value = None; + unknown_empty.status_metrics[0].unit = None; + assert!(validate_snapshot(®istry, "dynamic", unknown_empty).is_err()); + + let mut typed_notice = test_snapshot("dynamic"); + typed_notice.notices.push(ProviderNotice { + id: "rateLimited".into(), + title: "Live usage paused".into(), + message: String::new(), + tone: ProviderNoticeTone::Warning, + retry_seconds: Some(60), + showing_stale_limits: Some(false), + }); + assert!(validate_snapshot(®istry, "dynamic", typed_notice).is_ok()); + let mut missing_unit = snapshot.clone(); missing_unit.quotas[0].unit = Some(" ".into()); assert!(validate_snapshot(®istry, "dynamic", missing_unit).is_err()); @@ -1427,12 +1484,15 @@ mod tests { let registry = Arc::new(ProviderRegistry::new(vec![provider]).unwrap()); let service = Arc::new(ProviderService::new(registry, storage)); - tauri::async_runtime::block_on(service.refresh("failing", false)); - tauri::async_runtime::block_on(service.refresh("failing", false)); + refresh_with_test_timeout(&service, "failing", false); + refresh_with_test_timeout(&service, "failing", false); assert_eq!(calls.load(Ordering::SeqCst), 1); - tauri::async_runtime::block_on(service.refresh("failing", true)); + refresh_with_test_timeout(&service, "failing", true); assert_eq!(calls.load(Ordering::SeqCst), 2); + wait_until("forced refresh runner should become idle", || { + refresh_runner_is_idle(&service, "failing") + }); service.last_failed_refresh.lock().unwrap().insert( "failing".into(), @@ -1440,7 +1500,7 @@ mod tests { .checked_sub(FAILURE_RETRY_BACKOFF) .unwrap(), ); - tauri::async_runtime::block_on(service.refresh("failing", false)); + refresh_with_test_timeout(&service, "failing", false); assert_eq!(calls.load(Ordering::SeqCst), 3); } diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index ffca4fc..d7485b4 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -532,8 +532,12 @@ impl SettingsService { } } } + let resolved_language = crate::native_i18n::Locale::for_preference(&settings.language) + .language_tag() + .to_owned(); SettingsViewState { settings, + resolved_language, account_revision, renamable_provider_ids, notification_permission: notification_permission.into(), @@ -605,6 +609,7 @@ fn normalize_with_persisted_accounts( let catalog = registry.catalog(); let migrating_to_multi_provider = settings.schema_version < 3; settings.schema_version = 6; + settings.language = crate::models::normalize_language_preference(&settings.language).to_owned(); settings.dismissed_update_version = settings .dismissed_update_version .take() diff --git a/src-tauri/src/storage.rs b/src-tauri/src/storage.rs index a442466..216aa4d 100644 --- a/src-tauri/src/storage.rs +++ b/src-tauri/src/storage.rs @@ -513,6 +513,7 @@ mod tests { variants: None, }], source_note: "From your Codex logs (estimated)".into(), + source_kind: Some(crate::models::UsageSourceKind::EstimatedLogs), }), unknown_models: Vec::new(), }), diff --git a/src-tauri/src/tray_presentation.rs b/src-tauri/src/tray_presentation.rs index 52b8bde..5a928da 100644 --- a/src-tauri/src/tray_presentation.rs +++ b/src-tauri/src/tray_presentation.rs @@ -208,6 +208,7 @@ fn resolved_groups( settings: &AppSettings, registry: &ProviderRegistry, ) -> Vec { + let locale = crate::native_i18n::Locale::for_preference(&settings.language); settings .providers .iter() @@ -225,7 +226,7 @@ fn resolved_groups( .filter_map(|metric| { let metric_definition = registry.metric(&metric.id)?; let mut resolved = - tray_metric(metric_definition, snapshot, settings.usage_display)?; + tray_metric(metric_definition, snapshot, settings.usage_display, locale)?; resolved.detail = format!( "{} {}", settings.provider_display_name(definition), @@ -247,8 +248,11 @@ fn tray_metric( definition: &MetricDefinition, snapshot: &ProviderSnapshot, display: UsageDisplay, + locale: crate::native_i18n::Locale, ) -> Option { let tray = definition.tray.as_ref()?; + let localized_label = + crate::native_i18n::metric_label(locale, definition.label_kind, &definition.label); let quota = |id: &str| { snapshot .quotas @@ -262,14 +266,15 @@ fn tray_metric( UsageDisplay::Used => used, UsageDisplay::Left => (limit - used).max(0.0), }; - let word = match display { - UsageDisplay::Used => "used", - UsageDisplay::Left => "left", - }; - let unit = quota.unit.as_deref().unwrap_or("requests"); + let word = + crate::native_i18n::usage_word(locale, display == UsageDisplay::Used); + let unit = crate::native_i18n::count_unit( + locale, + quota.unit.as_deref().unwrap_or("requests"), + ); return TrayMetric { value: format!("{value:.0}"), - detail: format!("{} {value:.0} {unit} {word}", quota.label), + detail: format!("{localized_label} {value:.0} {unit} {word}"), gauge: used_fraction.map(|used_fraction| TrayGauge { display_fraction: match display { UsageDisplay::Used => used_fraction, @@ -287,13 +292,10 @@ fn tray_metric( UsageDisplay::Left => 1.0 - used_fraction, }; let percent = display_fraction * 100.0; - let word = match display { - UsageDisplay::Used => "used", - UsageDisplay::Left => "left", - }; + let word = crate::native_i18n::usage_word(locale, display == UsageDisplay::Used); TrayMetric { value: format!("{percent:.0}%"), - detail: format!("{} {percent:.0}% {word}", quota.label), + detail: format!("{localized_label} {percent:.0}% {word}"), gauge: Some(TrayGauge { display_fraction, #[cfg(any(not(target_os = "macos"), test))] @@ -304,15 +306,25 @@ fn tray_metric( }; match &definition.source { MetricSource::Quota { source_id, .. } => quota(source_id), - MetricSource::QuotaOrValue { source_id, .. } => { - quota(source_id).or_else(|| value_metric(snapshot, source_id, tray.suffix.as_deref())) - } - MetricSource::Value { source_id } => { - value_metric(snapshot, source_id, tray.suffix.as_deref()) + MetricSource::QuotaOrValue { source_id, .. } => quota(source_id).or_else(|| { + value_metric( + snapshot, + source_id, + tray.suffix.as_deref(), + &localized_label, + ) + }), + MetricSource::Value { source_id } => value_metric( + snapshot, + source_id, + tray.suffix.as_deref(), + &localized_label, + ), + MetricSource::Status { source_id } => { + status_metric(snapshot, source_id, &localized_label, locale) } - MetricSource::Status { source_id } => status_metric(snapshot, source_id), MetricSource::Usage { period } => { - usage_metric(&definition.label, usage_period(snapshot, *period)) + usage_metric(&localized_label, usage_period(snapshot, *period)) } MetricSource::Trend => None, } @@ -326,14 +338,27 @@ fn usage_period(snapshot: &ProviderSnapshot, period: UsagePeriodSelection) -> Op } } -fn status_metric(snapshot: &ProviderSnapshot, source_id: &str) -> Option { +fn status_metric( + snapshot: &ProviderSnapshot, + source_id: &str, + localized_label: &str, + locale: crate::native_i18n::Locale, +) -> Option { let metric = snapshot .status_metrics .iter() .find(|metric| metric.id == source_id)?; + let value = crate::native_i18n::status_metric_text( + locale, + &metric.id, + metric.tone, + metric.value, + metric.unit, + &metric.text, + ); Some(TrayMetric { - value: metric.text.clone(), - detail: format!("{} {}", metric.label, metric.text), + value: value.clone(), + detail: format!("{localized_label} {value}"), gauge: None, }) } @@ -342,6 +367,7 @@ fn value_metric( snapshot: &ProviderSnapshot, source_id: &str, tray_suffix: Option<&str>, + localized_label: &str, ) -> Option { let metric = snapshot .value_metrics @@ -364,7 +390,7 @@ fn value_metric( .unwrap_or(value); Some(TrayMetric { value, - detail: format!("{} {detail}", metric.label), + detail: format!("{localized_label} {detail}"), gauge: None, }) } @@ -699,15 +725,33 @@ mod tests { let catalog = ProviderRegistry::from_definitions(vec![cursor::definition()]).unwrap(); let definition = catalog.metric("cursor.requests").unwrap(); - let left = - super::tray_metric(definition, &snapshot, crate::models::UsageDisplay::Left).unwrap(); - let used = - super::tray_metric(definition, &snapshot, crate::models::UsageDisplay::Used).unwrap(); + let left = super::tray_metric( + definition, + &snapshot, + crate::models::UsageDisplay::Left, + crate::native_i18n::Locale::En, + ) + .unwrap(); + let used = super::tray_metric( + definition, + &snapshot, + crate::models::UsageDisplay::Used, + crate::native_i18n::Locale::En, + ) + .unwrap(); + let simplified_chinese = super::tray_metric( + definition, + &snapshot, + crate::models::UsageDisplay::Left, + crate::native_i18n::Locale::ZhCn, + ) + .unwrap(); assert_eq!(left.value, "75"); assert_eq!(left.detail, "Requests 75 searches left"); assert_eq!(used.value, "25"); assert_eq!(used.detail, "Requests 25 searches used"); + assert_eq!(simplified_chinese.detail, "请求 75 次搜索 剩余"); assert_eq!( left.gauge, Some(TrayGauge { @@ -811,6 +855,7 @@ mod tests { catalog.metric("codex.credits").unwrap(), &snapshot, crate::models::UsageDisplay::Left, + crate::native_i18n::Locale::En, ) .unwrap(); assert_eq!(metric.value, "$33 · 821 credits"); @@ -828,9 +873,11 @@ mod tests { status_metrics: vec![StatusMetric { id: "payAsYouGo".into(), label: "Extra Usage".into(), - text: "2500 cap".into(), + text: String::new(), tone: StatusTone::Positive, subtitle: None, + value: Some(2500.0), + unit: Some(crate::models::StatusMetricUnit::Cap), }], notices: Vec::new(), usage: UsageHistory::default(), @@ -847,8 +894,13 @@ mod tests { "E", ); - let metric = - super::tray_metric(&definition, &snapshot, crate::models::UsageDisplay::Left).unwrap(); + let metric = super::tray_metric( + &definition, + &snapshot, + crate::models::UsageDisplay::Left, + crate::native_i18n::Locale::En, + ) + .unwrap(); assert_eq!(metric.value, "2500 cap"); assert_eq!(metric.detail, "Extra Usage 2500 cap"); diff --git a/src/App.customization.test.ts b/src/App.customization.test.ts index 1c228c0..b28a46e 100644 --- a/src/App.customization.test.ts +++ b/src/App.customization.test.ts @@ -97,7 +97,7 @@ describe('OpenQuota customization persistence and reorder', () => { await screen.findByText('Plus'); await fireEvent.click(screen.getByLabelText('Open options')); await fireEvent.click(screen.getByRole('button', { name: 'Customize' })); - const toggle = screen.getByRole('checkbox', { name: 'Enable codex' }); + const toggle = screen.getByRole('checkbox', { name: 'Enable Codex' }); await fireEvent.click(toggle); await waitFor(() => expect(mocks.invoke).toHaveBeenCalledWith( @@ -142,7 +142,7 @@ describe('OpenQuota customization persistence and reorder', () => { await fireEvent.click(launchAtLogin); await waitFor(() => expect(launchAtLogin).not.toBeChecked()); - expect(screen.getByRole('alert')).toHaveTextContent('Launch at login is unavailable.'); + expect(screen.getByRole('alert')).toHaveTextContent('Settings could not be saved.'); expect(mocks.invoke).toHaveBeenCalledWith('get_app_settings'); }); diff --git a/src/App.svelte b/src/App.svelte index 412e8f7..d900295 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -34,6 +34,7 @@ import { restoreCustomization } from './lib/customizationHistory'; import Dashboard from './lib/Dashboard.svelte'; import Icon from './lib/Icon.svelte'; + import { getUiLanguage, setUiLanguage, t, uiLanguage } from './lib/i18n'; import { createListenerRegistry } from './lib/listenerRegistry'; import { emptyProviderCatalog, ProviderCatalogIndex } from './lib/metrics'; import { springMotion } from './lib/motion'; @@ -115,6 +116,8 @@ if (settingsState.settings.theme === 'system') delete root.dataset.theme; else root.dataset.theme = settingsState.settings.theme; root.dataset.density = settingsState.settings.density; + setUiLanguage(settingsState.settings.language, settingsState.resolvedLanguage); + root.lang = getUiLanguage(); }); $effect(() => { @@ -282,7 +285,7 @@ ]), ), }; - settingsError = 'OpenQuota could not start a provider refresh.'; + settingsError = t('providerRefreshStartFailed'); } } async function refreshProvider(providerId: string) { @@ -306,7 +309,7 @@ }, }; } - settingsError = `${providerDisplayName(providerId)} usage could not be refreshed.`; + settingsError = t('providerRefreshFailed', { provider: providerDisplayName(providerId) }); } } function openProviderLink(providerId: string, linkIndex: number) { @@ -323,7 +326,7 @@ try { settingsController.setState(await resetCustomizationCommand()); } catch { - settingsError = 'Customization could not be reset.'; + settingsError = t('customizationResetFailed'); } finally { resettingCustomization = false; resetConfirmationOpen = false; @@ -338,7 +341,9 @@ try { settingsController.setState(await resetProviderCustomizationCommand(providerId)); } catch { - settingsError = `${providerDisplayName(providerId)} customization could not be reset.`; + settingsError = t('providerCustomizationResetFailed', { + provider: providerDisplayName(providerId), + }); } } async function copyCanvas(canvas: HTMLCanvasElement, fallback: string) { @@ -353,7 +358,7 @@ } else { await navigator.clipboard.writeText(fallback); } - showConfirmation('Copied to clipboard'); + showConfirmation(t('copiedToClipboard')); } async function shareProvider(providerId: string) { const current = settingsState; @@ -374,7 +379,7 @@ }); await copyCanvas(canvas, snapshot); } catch { - settingsError = 'Provider screenshot could not be copied.'; + settingsError = t('providerScreenshotCopyFailed'); } } async function shareTotalSpend(projection: SpendProjection) { @@ -392,21 +397,21 @@ await copyCanvas(canvas, card.innerText.trim()); return true; } catch { - settingsError = 'Total Spend screenshot could not be copied.'; + settingsError = t('totalSpendScreenshotCopyFailed'); return false; } } async function copyLogPath() { const path = await getLogPath(); await navigator.clipboard.writeText(path); - showConfirmation('Log path copied'); + showConfirmation(t('logPathCopied')); } async function openLogFolder() { await openSystemLogFolder(); } function topBarTitle() { if (screen.startsWith('provider:')) return providerDisplayName(screen.slice(9)); - return screen === 'settings' ? 'Settings' : 'Customize'; + return screen === 'settings' ? t('settings') : t('customize'); } function closeAboutFromBackdrop(event: MouseEvent) { if (event.target === event.currentTarget) showAbout = false; @@ -499,7 +504,7 @@ // upstream support is still unavailable. await getCurrentWindow().startResizeDragging(edge === 'top' ? 'North' : 'South'); } catch { - settingsError = 'OpenQuota panel resize could not be started.'; + settingsError = t('panelResizeFailed'); } finally { await lockPanelResizeAxis().catch(() => undefined); updatePanelHeightMode(); @@ -515,7 +520,7 @@ event.preventDefault(); void getCurrentWindow() .startDragging() - .catch(() => (settingsError = 'OpenQuota window could not be moved.')); + .catch(() => (settingsError = t('windowMoveFailed'))); } async function changePanelHeightMode(mode: PanelHeightMode) { if (!('__TAURI_INTERNALS__' in window)) return; @@ -526,7 +531,7 @@ acceptPanelHeightMode(mode); if (mode === 'automatic') scheduleWindowFit(); } catch { - settingsError = 'OpenQuota could not change the panel height mode.'; + settingsError = t('panelHeightModeFailed'); updatePanelHeightMode(); } } @@ -538,14 +543,14 @@ const permissionState = await requestNotificationPermission(); settingsController.setState({ ...permissionState, settings: currentSettings }); } catch { - settingsError = 'Notification permission could not be requested.'; + settingsError = t('notificationPermissionFailed'); } } async function openNotificationSettings() { try { await openSystemNotificationSettings(); } catch { - settingsError = 'Notification settings could not be opened on this system.'; + settingsError = t('notificationSettingsFailed'); } } async function checkForUpdates(manual = false) { @@ -635,7 +640,7 @@ document.addEventListener('keydown', handleKeydown); const clock = window.setInterval(() => (now = Date.now()), 30_000); const listeners = createListenerRegistry(() => { - settingsError ??= 'OpenQuota event bridge is unavailable.'; + settingsError ??= t('eventBridgeUnavailable'); }); listeners.add(onUsageState((state) => (viewState = state))); listeners.add( @@ -664,7 +669,7 @@ settingsController.setState(state.settings); automaticUpdatesReady = true; }) - .catch(() => (settingsError = 'OpenQuota backend is unavailable.')); + .catch(() => (settingsError = t('backendUnavailable'))); return () => { document.removeEventListener('keydown', handleKeydown); window.clearInterval(clock); @@ -686,23 +691,24 @@ class="popover" class:popover--floating={floatingWindow} class:popover--macos={floatingWindow && platform === 'macos'} - aria-label="OpenQuota usage dashboard" + aria-label={t('dashboard')} + data-language={$uiLanguage} oncontextmenu={(event) => event.preventDefault()} >

- Drag to reorder. With a keyboard, use Alt plus Up Arrow or Alt plus Down Arrow. + {t('reorderInstructions')}

{#if renderedResizeEdge === 'top'} {/if} {#if floatingWindow} -
+
OpenQuota @@ -710,7 +716,7 @@

{topBarTitle()}

@@ -729,8 +735,8 @@ class="text-button" type="button" onclick={requestCustomizationReset} - aria-label="Reset all customization" - data-tooltip="Reset All Customization" + aria-label={t('resetCustomization')} + data-tooltip={t('resetCustomization')} > {:else if screen.startsWith('provider:')} @@ -738,8 +744,8 @@ class="text-button" type="button" onclick={() => resetProviderCustomization(screen.slice(9))} - aria-label={`Reset ${topBarTitle()}`} - data-tooltip={`Reset ${topBarTitle()}`} + aria-label={t('reset', { label: topBarTitle() })} + data-tooltip={t('reset', { label: topBarTitle() })} > {:else} @@ -844,10 +850,10 @@ type="button" onclick={refresh} disabled={anyRefreshing} - aria-label="Refresh all provider usage" + aria-label={t('refreshAllProviderUsage')} > OpenQuota {appVersion}{anyRefreshing ? 'Updating…' : nextUpdateLabel(lastFullRefresh, now)}{anyRefreshing ? t('updating') : nextUpdateLabel(lastFullRefresh, now)} {#if screen === 'dashboard'} @@ -857,17 +863,17 @@ class="window-mode-toggle" class:window-mode-toggle--active={floatingWindow} type="button" - aria-label={floatingWindow ? 'Return to Tray Popup' : 'Keep Window Open'} + aria-label={floatingWindow ? t('returnToTrayPopup') : t('keepWindowOpen')} aria-pressed={floatingWindow} - data-tooltip={floatingWindow ? 'Return to Tray Popup' : 'Keep Window Open'} + data-tooltip={floatingWindow ? t('returnToTrayPopup') : t('keepWindowOpen')} onclick={toggleFloatingWindow} > {/if}
- Options{t('options')} { @@ -888,16 +894,17 @@ {t('customize')} {t('settings')}{shortcuts.settings}
Share Screenshot
{t('shareScreenshot')}
{#if shareMenuOpen} @@ -921,19 +928,18 @@
{t('checkUpdates')}
{t('about')} {t('quit')}{shortcuts.quit}
@@ -950,9 +956,9 @@ {#if resetConfirmationOpen} void confirmCustomizationReset()} onCancel={() => (resetConfirmationOpen = false)} @@ -974,19 +980,19 @@ role="dialog" tabindex="-1" aria-modal="true" - aria-label="About OpenQuota" + aria-label={t('aboutOpenQuota')} >

OpenQuota

-

Version {appVersion}

- Private, local usage monitoring for your AI coding tools. +

{t('version', { version: appVersion })}

+ {t('openQuotaDescription')} {/if} @@ -995,7 +1001,7 @@ {#if settingsError} {:else} -

Loading OpenQuota…

+

{t('loading')}

{/if} {/if} @@ -1003,7 +1009,7 @@ diff --git a/src/App.test.ts b/src/App.test.ts index 1d53eb6..b183e33 100644 --- a/src/App.test.ts +++ b/src/App.test.ts @@ -1,6 +1,7 @@ import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/svelte'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import App from './App.svelte'; +import { setUiLanguage } from './lib/i18n'; import type { AppSettings, ProviderCatalog, @@ -110,7 +111,10 @@ describe('OpenQuota dashboard', () => { return Promise.reject(new Error(`unexpected command ${command}`)); }); }); - afterEach(cleanup); + afterEach(() => { + cleanup(); + setUiLanguage('en'); + }); it('renders quota, total spend, and the 30-day trend from backend data', async () => { const { container } = render(App); @@ -253,6 +257,118 @@ describe('OpenQuota dashboard', () => { } }); + it('switches language immediately, persists it, and preserves temporary Settings state', async () => { + render(App); + await screen.findByText('Plus'); + const appRoot = document.querySelector('main'); + await fireEvent.click(screen.getByLabelText('Open options')); + await fireEvent.click(screen.getByRole('button', { name: 'Settings' })); + + const shortcut = screen.getByRole('button', { name: 'Record Shortcut' }); + await fireEvent.click(shortcut); + expect(shortcut).toHaveTextContent('Type Shortcut…'); + + await fireEvent.click(screen.getByRole('combobox', { name: 'Language' })); + await fireEvent.click(screen.getByRole('option', { name: '简体中文' })); + + expect(await screen.findByRole('region', { name: '设置' })).toBeInTheDocument(); + expect(document.querySelector('main')).toBe(appRoot); + expect(screen.getByRole('button', { name: '输入快捷键…' })).toBe(shortcut); + await waitFor(() => + expect(mocks.invoke).toHaveBeenCalledWith( + 'save_app_settings', + expect.objectContaining({ settings: expect.objectContaining({ language: 'zh-CN' }) }), + ), + ); + }); + + it('translates the Total Spend accessible name without changing its meaning', async () => { + let language: AppSettings['language'] = 'zh-CN'; + mockInvoke((command: string) => { + if (command === 'get_usage_state') return Promise.resolve(liveState); + if (command === 'get_app_settings') + return Promise.resolve({ + ...settingsState, + settings: { ...settingsState.settings, language }, + }); + if (command === 'get_panel_resize_edge') return Promise.resolve('bottom'); + if (command === 'get_panel_height_mode') return Promise.resolve('automatic'); + if (command === 'fit_panel_to_content') return Promise.resolve(true); + if (command === 'check_for_updates') + return Promise.resolve({ + available: false, + currentVersion: '0.3.3', + version: null, + body: null, + installable: true, + releaseUrl: '', + }); + return Promise.resolve(); + }); + + const simplified = render(App); + expect(await screen.findByRole('combobox', { name: '总消费指标' })).toBeInTheDocument(); + simplified.unmount(); + + language = 'zh-TW'; + render(App); + expect(await screen.findByRole('combobox', { name: '總消費指標' })).toBeInTheDocument(); + }); + + it('loads a persisted language and safely renders invalid saved values in English', async () => { + mockInvoke((command: string) => { + if (command === 'get_usage_state') return Promise.resolve(liveState); + if (command === 'get_app_settings') + return Promise.resolve({ + ...settingsState, + settings: { ...settingsState.settings, language: 'zh-TW' }, + }); + if (command === 'get_panel_resize_edge') return Promise.resolve('bottom'); + if (command === 'get_panel_height_mode') return Promise.resolve('automatic'); + if (command === 'fit_panel_to_content') return Promise.resolve(true); + if (command === 'check_for_updates') + return Promise.resolve({ + available: false, + currentVersion: '0.3.3', + version: null, + body: null, + installable: true, + releaseUrl: '', + }); + return Promise.resolve(); + }); + const first = render(App); + expect(await screen.findByRole('combobox', { name: '總消費指標' })).toBeInTheDocument(); + first.unmount(); + + mockInvoke((command: string) => { + if (command === 'get_usage_state') return Promise.resolve(liveState); + if (command === 'get_app_settings') + return Promise.resolve({ + ...settingsState, + settings: { + ...settingsState.settings, + language: 'invalid-locale' as AppSettings['language'], + }, + }); + if (command === 'get_panel_resize_edge') return Promise.resolve('bottom'); + if (command === 'get_panel_height_mode') return Promise.resolve('automatic'); + if (command === 'fit_panel_to_content') return Promise.resolve(true); + if (command === 'check_for_updates') + return Promise.resolve({ + available: false, + currentVersion: '0.3.3', + version: null, + body: null, + installable: true, + releaseUrl: '', + }); + return Promise.resolve(); + }); + render(App); + expect(await screen.findByRole('combobox', { name: 'Total Spend Metric' })).toBeInTheDocument(); + }); + it('renders Claude and Antigravity independently with provider-specific quota formats', async () => { const multiProviderSettings = { ...settingsState, @@ -328,7 +444,7 @@ describe('OpenQuota dashboard', () => { expect(screen.getAllByRole('progressbar')).toHaveLength(6); expect( within(screen.getByRole('region', { name: 'Total Spend' })).getByRole('img', { - name: 'Only includes Claude and Codex', + name: 'Only includes Claude and Codex.', }), ).toBeInTheDocument(); }); @@ -676,7 +792,7 @@ describe('OpenQuota dashboard', () => { await fireEvent.click(screen.getByLabelText('Open options')); await fireEvent.click(screen.getByRole('button', { name: 'Customize' })); expect(screen.getByRole('heading', { name: 'Customize' })).toBeInTheDocument(); - await fireEvent.click(screen.getByRole('button', { name: 'Customize codex' })); + await fireEvent.click(screen.getByRole('button', { name: 'Customize Codex' })); expect(screen.getByRole('group', { name: 'Always Visible metrics' })).toBeInTheDocument(); expect(screen.getByRole('group', { name: 'On Demand metrics' })).toBeInTheDocument(); }); @@ -686,7 +802,7 @@ describe('OpenQuota dashboard', () => { await screen.findByText('Plus'); await fireEvent.click(screen.getByLabelText('Open options')); await fireEvent.click(screen.getByRole('button', { name: 'Customize' })); - await fireEvent.click(screen.getByRole('button', { name: 'Customize codex' })); + await fireEvent.click(screen.getByRole('button', { name: 'Customize Codex' })); await fireEvent.click(screen.getByRole('button', { name: 'Reset Codex' })); expect(mocks.invoke).toHaveBeenCalledWith('reset_provider_customization', { @@ -699,7 +815,7 @@ describe('OpenQuota dashboard', () => { await screen.findByText('Plus'); await fireEvent.click(screen.getByLabelText('Open options')); await fireEvent.click(screen.getByRole('button', { name: 'Customize' })); - await fireEvent.click(screen.getByRole('button', { name: 'Customize codex' })); + await fireEvent.click(screen.getByRole('button', { name: 'Customize Codex' })); await fireEvent.click(screen.getByRole('button', { name: 'Pin Today' })); expect(screen.getByText('Up to 2 stars per provider')).toBeInTheDocument(); }); diff --git a/src/App.update.test.ts b/src/App.update.test.ts index 303ee54..0126744 100644 --- a/src/App.update.test.ts +++ b/src/App.update.test.ts @@ -256,7 +256,7 @@ describe('OpenQuota update lifecycle', () => { 'GitHub refused the update download.', ); expect(screen.getByRole('alert')).toHaveTextContent( - 'Try again or download it from the release page.', + 'Try again or download the installer from the release page.', ); expect(screen.getByRole('button', { name: 'Try Again' })).toBeInTheDocument(); await fireEvent.click(screen.getByRole('button', { name: 'View Release' })); diff --git a/src/lib/ConfirmationSheet.svelte b/src/lib/ConfirmationSheet.svelte index bc8976a..99ea1a3 100644 --- a/src/lib/ConfirmationSheet.svelte +++ b/src/lib/ConfirmationSheet.svelte @@ -1,5 +1,6 @@ -
+
{#each settings.providers.filter( (provider) => catalog.provider(provider.id) ) as provider (provider.id)}
onOpen(provider.id)} >{providerDisplayName(provider.id)}{provider.metrics.length} metrics{t('metricCount', { count: provider.metrics.length })}
{/each}
-
diff --git a/src/lib/Dashboard.svelte b/src/lib/Dashboard.svelte index c7b8b7d..4da4daa 100644 --- a/src/lib/Dashboard.svelte +++ b/src/lib/Dashboard.svelte @@ -1,4 +1,5 @@ @@ -314,20 +317,20 @@ /> {#if updateStatus?.available && updateStatus.version !== settings.dismissedUpdateVersion} -
+
- Update Available - OpenQuota {updateStatus.version} is ready to download. + {t('updateAvailable')} + {t('versionReadyToDownload', { version: updateStatus.version ?? '' })} {#if updateStatus.body}
- What’s new + {t('whatsNew')}

{updateStatus.body}

{/if} {#if installingUpdate && updateProgress}
{updateProgress.phase === 'installing' - ? 'Installing update…' + ? t('installingUpdate') : updateProgress.phase === 'retrying' - ? 'Download interrupted. Retrying…' + ? t('downloadInterrupted') : updateProgress.percent === null - ? 'Downloading update…' - : `Downloading update… ${updateProgress.percent}%`} + ? t('downloadingUpdate') + : t('downloadingUpdatePercent', { percent: updateProgress.percent })} {/if} {#if updateError} - +
@@ -421,7 +422,7 @@ data-reorder-group="dashboard-providers" data-reorder-id={provider.id} role="group" - aria-label={`${providerDisplayName(provider.id)} provider`} + aria-label={t('providerGroup', { provider: providerDisplayName(provider.id) })} use:pointerReorder={{ id: provider.id, group: 'dashboard-providers', @@ -438,7 +439,7 @@ class="provider-header" data-reorder-handle role="group" - aria-label={`Drag ${providerDisplayName(provider.id)} to reorder`} + aria-label={t('dragProviderToReorder', { provider: providerDisplayName(provider.id) })} > @@ -454,14 +455,14 @@ {#if snapshot.plan}{snapshot.plan}{/if} {#if state?.snapshot && state.stale}Outdated{t('outdated')}{/if} 0)} > {#if state?.refreshing} - {:else if state?.error} @@ -490,7 +491,7 @@
{#each snapshot.notices as notice (notice.id)} @@ -503,7 +504,9 @@ data-reorder-group={`dashboard-metrics:${provider.id}`} data-reorder-id={metric.id} role="group" - aria-label={`${metricDefinition(metric.id)?.label ?? metric.id} options`} + aria-label={t('metricOptions', { + metric: metricDefinition(metric.id)?.label ?? metric.id, + })} use:pointerReorder={{ id: metric.id, group: `dashboard-metrics:${provider.id}`, @@ -521,7 +524,7 @@ data-reorder-handle data-reorder-touch-handle type="button" - aria-label={`Move ${metricDefinition(metric.id)?.label ?? metric.id}`} + aria-label={t('move', { label: metricDefinition(metric.id)?.label ?? metric.id })} aria-describedby="reorder-instructions" aria-keyshortcuts="Alt+ArrowUp Alt+ArrowDown" > toggleDemandMetrics(provider)} > hideProvider(menuProvider.id)} - >Hide {providerDisplayName(menuProvider.id)}{t('hideProvider', { + provider: providerDisplayName(menuProvider.id), + })}
{t('refreshProvider', { + provider: providerDisplayName(menuProvider.id), + })} {#if canRenameProvider(menuProvider.id, renamableProviderIds)} {t('rename')}… {/if} {t('customizeMenu')}
{t('shareScreenshot')} {/if} @@ -664,7 +675,7 @@ type="button" role="menuitem" onclick={() => patchMetric(metricProvider.id, menuMetric.id, { enabled: false })} - >Hide{t('hide')} {#if metricDefinition(menuMetric.id)?.pinnable} {/if}
{t('refreshProvider', { + provider: providerDisplayName(metricProvider.id), + })} {t('customizeMenu')} {/if} @@ -697,7 +710,7 @@ {#if enabledProviders.length === 0}
- Turn on Customize to choose what to show. + {t('customizeHint')}
{/if} diff --git a/src/lib/MetricRenderer.svelte b/src/lib/MetricRenderer.svelte index 8c3f493..fc69ce0 100644 --- a/src/lib/MetricRenderer.svelte +++ b/src/lib/MetricRenderer.svelte @@ -1,4 +1,5 @@ {#if supported} -
-

API Key

+
+

{t('apiKey')}

{providerName} {open ? t('done') : status === 'notSet' ? t('add') : t('edit')}
{#if availabilityError} @@ -131,14 +132,14 @@ bind:value={apiKey} autocomplete="off" spellcheck="false" - placeholder="Paste API key" - aria-label={`${providerName} API key`} + placeholder={t('pasteApiKey')} + aria-label={t('apiKeyFor', { provider: providerName })} disabled={saving} /> {saving ? t('saving') : t('save')} {#if overrideExternal} - + {/if}
{:else} @@ -162,8 +165,8 @@ class="field-icon clear-icon" type="button" disabled={saving} - aria-label={saving ? 'Removing saved API key…' : 'Remove saved API key'} - title="Remove saved API key" + aria-label={saving ? t('removingSavedApiKey') : t('removeSavedApiKey')} + title={t('removeSavedApiKey')} onclick={remove} > @@ -173,14 +176,14 @@ class="api-key-source-field" type="text" use:displayValue={sourceLabel} - aria-label={`${providerName} API key source`} + aria-label={t('apiKeySourceFor', { provider: providerName })} disabled /> {#if status === 'fromEnvironment' || status === 'fromConfig'} {/if} {/if} diff --git a/src/lib/ProviderLinks.svelte b/src/lib/ProviderLinks.svelte index 30642e6..33a5005 100644 --- a/src/lib/ProviderLinks.svelte +++ b/src/lib/ProviderLinks.svelte @@ -1,6 +1,8 @@
-

Name

+

{t('name')}

(focused = true)} onblur={() => { diff --git a/src/lib/ProviderNoticeRow.svelte b/src/lib/ProviderNoticeRow.svelte index 7c3f3f0..e42a78d 100644 --- a/src/lib/ProviderNoticeRow.svelte +++ b/src/lib/ProviderNoticeRow.svelte @@ -1,5 +1,6 @@ -
+

- {quota.label} + {localizedLabel} {#if quota.estimated} {/if} @@ -164,7 +210,7 @@ {paceLabel ?? ''} {#if freshSession} - Not started + {t('notStarted')} {:else} {recording + ? t('typeShortcut') + : (settings.globalShortcut ?? t('recordShortcut'))}{#if settings.globalShortcut}{/if} @@ -167,75 +171,88 @@

-

Appearance

+

{t('appearance')}

+
+ {t('language')} patch({ language: value as AppSettings['language'] })} + /> +
{#if platform === 'macos'}
- Icon Style{t('iconStyle')} patch({ menuBarStyle: value as AppSettings['menuBarStyle'] })} />
{/if}
- Theme{t('theme')} patch({ theme: value as AppSettings['theme'] })} />
- Density{t('density')} patch({ density: value as AppSettings['density'] })} />
{#if settingsView.trayAvailable}
- Window Mode{t('windowMode')} patch({ windowMode: value as AppSettings['windowMode'] })} />
{/if}
- Panel Height{t('panelHeight')} onPanelHeightModeChange(value as PanelHeightMode)} />
- Time Format{t('timeFormat')} patch({ timeFormat: value as AppSettings['timeFormat'] })} /> @@ -243,36 +260,35 @@
-

Usage Display

+

{t('usageDisplay')}

- Show Usage As{t('showUsageAs')} patch({ usageDisplay: value as AppSettings['usageDisplay'] })} />
- Reset Times{t('resetTimes')} patch({ resetDisplay: value as AppSettings['resetDisplay'] })} />
@@ -356,23 +372,23 @@
-

Advanced

+

{t('advanced')}

- Log Level{t('logLevel')} patch({ logLevel: value as AppSettings['logLevel'] })} />
{t('copyLogPath')}
@@ -386,9 +402,9 @@
-

Updates

+

{t('updates')}

{#if updateError}{/if}
-
@@ -483,7 +499,7 @@ color: var(--text); } - .shortcut-field button[aria-label='Clear global shortcut'] { + .shortcut-field .clear-shortcut-button { display: grid; width: 24px; height: 24px; @@ -493,8 +509,8 @@ place-items: center; } - .shortcut-field button[aria-label='Clear global shortcut']:hover, - .shortcut-field button[aria-label='Clear global shortcut']:focus-visible { + .shortcut-field .clear-shortcut-button:hover, + .shortcut-field .clear-shortcut-button:focus-visible { outline: none; color: var(--text); background: var(--button-hover); diff --git a/src/lib/StatusMetric.svelte b/src/lib/StatusMetric.svelte index f1ed71d..cd1b700 100644 --- a/src/lib/StatusMetric.svelte +++ b/src/lib/StatusMetric.svelte @@ -1,4 +1,6 @@ -
- Usage Trend +
+ {t('usageTrend')} {#if total > 0}
@@ -78,22 +80,27 @@ class="trend-bars" class:trend-bars--active={detailVisible} role="img" - aria-label={`30-day token chart. Peak ${compact(peak.tokens)} tokens on ${peak.date}.`} + aria-label={t('tokenChartPeak', { tokens: compact(peak.tokens), date: peak.date })} > {#each points as point (point.date)} 0 ? 18 : 2, (point.tokens / max) * 100)}%`} - title={`${point.date}: ${compact(point.tokens)} tokens`} + title={t('tokenChartPoint', { date: point.date, tokens: compact(point.tokens) })} > {/each}
{#if detailVisible}
diff --git a/src/lib/ValueMetric.svelte b/src/lib/ValueMetric.svelte index 3cd85aa..2b5526c 100644 --- a/src/lib/ValueMetric.svelte +++ b/src/lib/ValueMetric.svelte @@ -1,7 +1,8 @@