diff --git a/apps/desktop-tauri/src-tauri/src/commands/bridge.rs b/apps/desktop-tauri/src-tauri/src/commands/bridge.rs index ab5efbef8a..e10b94a0ab 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/bridge.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/bridge.rs @@ -716,6 +716,9 @@ pub struct SettingsSnapshot { cost_summary_display_style: &'static str, open_codex_usage_logs_enabled: bool, hide_native_codex_cost_when_open_codex_present: bool, + /// History window as its persisted raw form (`rolling:N`, + /// `month-to-date`, `all`). + cost_reporting_period: String, provider_accent_colors: std::collections::HashMap, } @@ -842,6 +845,7 @@ impl From for SettingsSnapshot { open_codex_usage_logs_enabled: settings.open_codex_usage_logs_enabled, hide_native_codex_cost_when_open_codex_present: settings .hide_native_codex_cost_when_open_codex_present, + cost_reporting_period: settings.cost_reporting_period.raw(), provider_accent_colors: settings .provider_configs .iter() diff --git a/apps/desktop-tauri/src-tauri/src/commands/chart.rs b/apps/desktop-tauri/src-tauri/src/commands/chart.rs index 4561dd9129..9cdc0e7dfe 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/chart.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/chart.rs @@ -9,6 +9,7 @@ use crate::commands::bridge::RateWindowSnapshot; use crate::state::AppState; use chrono::{DateTime, Utc}; use codexbar::core::{OpenAIDashboardCacheStore, RateWindow}; +use codexbar::cost_reporting_period::{CostReportingPeriod, CostTimeZone}; use codexbar::cost_scanner::{ CostScanner, CostSummary, get_daily_cost_history, get_daily_token_history, }; @@ -63,8 +64,18 @@ pub struct DailyUsageBreakdown { #[serde(rename_all = "camelCase")] pub struct ProviderLocalUsageSummary { pub today_cost: Option, + /// Always the trailing 30 days (the published PowerToys pipe reads it). pub thirty_day_cost: Option, pub thirty_day_tokens: Option, + /// Cost over the selected History window (`reporting_period`). + #[serde(default)] + pub period_cost: Option, + /// Tokens over the selected History window. + #[serde(default)] + pub period_tokens: Option, + /// Raw reporting period the `period_*` fields cover; empty when unknown. + #[serde(default)] + pub reporting_period: String, pub latest_tokens: Option, pub top_model: Option, pub estimate_note: String, @@ -194,10 +205,17 @@ fn build_provider_chart_data_with_cancel( .cloned() .map(|(date, tokens)| DailyTokenPoint { date, tokens }) .collect(); - let local_usage = snapshot - .local_summary - .as_ref() - .and_then(|summary| local_usage_summary_from_cost_summary(&provider_id, summary)); + let period = current_reporting_period(); + let local_usage = snapshot.local_summary.as_ref().and_then(|summary| { + let period_summary = + period_summary_for(&provider_id, period, summary, cancel.as_deref()); + local_usage_summary_from_cost_summary( + &provider_id, + summary, + period, + period_summary.as_ref(), + ) + }); store_local_usage_summary(&provider_id, local_usage.clone()); ( cost_history, @@ -221,7 +239,14 @@ fn build_provider_chart_data_with_cancel( tokens: day.total_tokens, }) .collect(); - let local_usage = muse_local_usage_summary(&report, locale::current_language()); + let period = current_reporting_period(); + let period_report = muse_period_report(period, &report, cancel.as_deref()); + let local_usage = muse_local_usage_summary( + &report, + &period_report, + period, + locale::current_language(), + ); ( Vec::new(), tokens_history, @@ -331,16 +356,40 @@ fn map_quota_window_history( } } +/// The History window the desktop is configured to report. +fn current_reporting_period() -> CostReportingPeriod { + codexbar::settings::Settings::load().cost_reporting_period +} + +/// The period summary for `provider_id`, reusing the 30-day scan when the +/// selected window is exactly the trailing 30 days. +fn period_summary_for( + provider_id: &str, + period: CostReportingPeriod, + thirty_day: &CostSummary, + cancel: Option<&AtomicBool>, +) -> Option { + if period == CostReportingPeriod::Rolling(30) { + return Some(thirty_day.clone()); + } + scan_local_cost(provider_id, period, cancel) +} + fn local_usage_summary_from_cost_summary( provider_id: &str, summary: &CostSummary, + period: CostReportingPeriod, + period_summary: Option<&CostSummary>, ) -> Option { - let total_tokens = total_tokens(summary); - let has_usage = summary.sessions_count > 0 || summary.total_cost_usd > 0.0 || total_tokens > 0; + let thirty_tokens = total_tokens(summary); + let has_usage = summary.sessions_count > 0 || summary.total_cost_usd > 0.0 || thirty_tokens > 0; has_usage.then(|| ProviderLocalUsageSummary { today_cost: None, thirty_day_cost: non_zero_f64(summary.total_cost_usd), - thirty_day_tokens: non_zero_u64(total_tokens), + thirty_day_tokens: non_zero_u64(thirty_tokens), + period_cost: period_summary.and_then(|s| non_zero_f64(s.total_cost_usd)), + period_tokens: period_summary.and_then(|s| non_zero_u64(total_tokens(s))), + reporting_period: period.raw(), latest_tokens: None, top_model: top_model(summary), estimate_note: localized_estimate_note(provider_id, locale::current_language()), @@ -388,27 +437,35 @@ fn load_local_usage_summary_with_unknown_models( provider_id: &str, cancel: Option<&AtomicBool>, ) -> (Option, HashSet) { + let period = current_reporting_period(); if provider_id == "muse" { let summary = if cancel.is_some_and(|flag| flag.load(Ordering::Relaxed)) { None } else { let report = muse_local_usage::scan(30, cancel); - muse_local_usage_summary(&report, locale::current_language()) + let period_report = muse_period_report(period, &report, cancel); + muse_local_usage_summary(&report, &period_report, period, locale::current_language()) }; return (summary, HashSet::new()); } - let Some(thirty_day) = scan_local_cost(provider_id, 30, cancel) else { + let Some(thirty_day) = scan_local_cost(provider_id, CostReportingPeriod::Rolling(30), cancel) + else { return (None, HashSet::new()); }; if cancel.is_some_and(|flag| flag.load(Ordering::Relaxed)) { return (None, HashSet::new()); } - let today = scan_local_cost(provider_id, 1, cancel).unwrap_or_default(); - let unknown_models = thirty_day + let today = + scan_local_cost(provider_id, CostReportingPeriod::Rolling(1), cancel).unwrap_or_default(); + let period_summary = period_summary_for(provider_id, period, &thirty_day, cancel); + let mut unknown_models: HashSet = thirty_day .unknown_models .union(&today.unknown_models) .cloned() .collect(); + if let Some(period_summary) = period_summary.as_ref() { + unknown_models.extend(period_summary.unknown_models.iter().cloned()); + } let thirty_day_tokens = total_tokens(&thirty_day); let latest_tokens = total_tokens(&today); @@ -424,6 +481,13 @@ fn load_local_usage_summary_with_unknown_models( today_cost: non_zero_f64(today.total_cost_usd), thirty_day_cost: non_zero_f64(thirty_day.total_cost_usd), thirty_day_tokens: non_zero_u64(thirty_day_tokens), + period_cost: period_summary + .as_ref() + .and_then(|s| non_zero_f64(s.total_cost_usd)), + period_tokens: period_summary + .as_ref() + .and_then(|s| non_zero_u64(total_tokens(s))), + reporting_period: period.raw(), latest_tokens: non_zero_u64(latest_tokens), top_model: top_model(&thirty_day), estimate_note: localized_estimate_note(provider_id, lang), @@ -433,8 +497,23 @@ fn load_local_usage_summary_with_unknown_models( ) } +/// The Muse report for the selected period, reusing the 30-day scan when the +/// selection is exactly the trailing 30 days. +fn muse_period_report( + period: CostReportingPeriod, + thirty_day: &muse_local_usage::Report, + cancel: Option<&AtomicBool>, +) -> muse_local_usage::Report { + if period == CostReportingPeriod::Rolling(30) { + return thirty_day.clone(); + } + muse_local_usage::scan(period.scan_days(Utc::now()), cancel) +} + fn muse_local_usage_summary( report: &muse_local_usage::Report, + period_report: &muse_local_usage::Report, + period: CostReportingPeriod, lang: codexbar::settings::Language, ) -> Option { if !report.is_available() || !report.is_complete() { @@ -445,6 +524,12 @@ fn muse_local_usage_summary( today_cost: None, thirty_day_cost: None, thirty_day_tokens: Some(total_tokens), + period_cost: None, + period_tokens: period_report + .is_complete() + .then_some(period_report.total_tokens) + .flatten(), + reporting_period: period.raw(), latest_tokens: report.today_tokens, top_model: report.top_model.clone(), estimate_note: locale::get_text(lang, LocaleKey::PanelEstimatedFromLocalLogsMuse), @@ -460,9 +545,24 @@ pub(crate) fn load_provider_local_usage_summary( struct CachedLocalUsage { loaded_at: Instant, + /// `CostReportingPeriod::identity` the entry was built for; a period or + /// month change makes the entry stale before its TTL. + period_identity: String, summary: Option, } +fn local_usage_period_identity() -> String { + current_reporting_period().identity(Utc::now(), CostTimeZone::Local) +} + +/// Cache a completed scan under the reporting window it actually used. The +/// summary timestamp preserves the month for month-to-date identities. +fn local_usage_summary_period_identity(summary: &ProviderLocalUsageSummary) -> Option { + let period = CostReportingPeriod::parse(&summary.reporting_period)?; + let scanned_at = DateTime::::from_timestamp_millis(summary.token_cost_updated_at_ms)?; + Some(period.identity(scanned_at, CostTimeZone::Local)) +} + fn local_usage_cache() -> &'static Mutex> { static CACHE: OnceLock>> = OnceLock::new(); CACHE.get_or_init(|| Mutex::new(HashMap::new())) @@ -547,8 +647,10 @@ fn load_local_usage_summary_cached( cancel: Option<&AtomicBool>, ) -> Option { let cache = local_usage_cache(); + let period_identity = local_usage_period_identity(); if let Ok(guard) = cache.lock() && let Some(entry) = guard.get(provider_id) + && entry.period_identity == period_identity && token_cost_cache_is_fresh(Some(entry.loaded_at), Instant::now(), LOCAL_USAGE_TTL) { return entry.summary.clone(); @@ -568,11 +670,22 @@ fn load_local_usage_summary_cached( } fn store_local_usage_summary(provider_id: &str, summary: Option) { + let period_identity = match summary.as_ref() { + Some(summary) => { + let Some(period_identity) = local_usage_summary_period_identity(summary) else { + tracing::warn!("Skipping local usage cache entry with invalid period metadata"); + return; + }; + period_identity + } + None => local_usage_period_identity(), + }; if let Ok(mut guard) = local_usage_cache().lock() { guard.insert( provider_id.to_string(), CachedLocalUsage { loaded_at: Instant::now(), + period_identity, summary, }, ); @@ -585,11 +698,13 @@ fn record_local_usage_fetch_failure(provider_id: &str, failure: CostFetchFailure } else { Instant::now() }; + let period_identity = local_usage_period_identity(); if let Ok(mut guard) = local_usage_cache().lock() { guard.insert( provider_id.to_string(), CachedLocalUsage { loaded_at, + period_identity, summary: None, }, ); @@ -637,10 +752,10 @@ fn localized_estimate_note(provider_id: &str, lang: codexbar::settings::Language fn scan_local_cost( provider_id: &str, - days: u32, + period: CostReportingPeriod, cancel: Option<&AtomicBool>, ) -> Option { - let scanner = CostScanner::new(days); + let scanner = CostScanner::for_period(period); match provider_id { "codex" => Some(scanner.scan_codex_with_cancel(cancel)), "claude" => Some(scanner.scan_claude_with_cancel(cancel)), @@ -744,120 +859,4 @@ pub(crate) fn load_openai_dashboard_chart_data_for_test( } #[cfg(test)] -mod tests { - use super::{ - CostFetchFailure, ProviderLocalUsageSummary, cost_fetch_failure_allows_early_retry, - localized_estimate_note, muse_local_usage_summary, token_cost_cache_is_fresh, - }; - use crate::commands::is_provider_cache_fresh; - use codexbar::providers::muse::local_usage::{DailyUsage, Report}; - use codexbar::settings::Language; - use codexbar::spend_contract::LocalHistoryCoverage; - use std::time::{Duration, Instant}; - - #[test] - fn token_cost_age_does_not_use_provider_quota_age() { - let now = Instant::now(); - let token_loaded = now - Duration::from_secs(31); - let provider_updated = now; - assert!(!token_cost_cache_is_fresh( - Some(token_loaded), - now, - Duration::from_secs(30) - )); - assert!(is_provider_cache_fresh( - Some(provider_updated), - Duration::from_secs(30) - )); - } - - #[test] - fn fast_cost_failures_allow_the_next_pass_to_retry() { - assert!(cost_fetch_failure_allows_early_retry( - CostFetchFailure::Failed - )); - assert!(!cost_fetch_failure_allows_early_retry( - CostFetchFailure::TimedOut - )); - } - - #[test] - fn local_usage_summary_serializes_token_cost_timestamp() { - let summary = ProviderLocalUsageSummary { - today_cost: Some(1.0), - thirty_day_cost: Some(2.0), - thirty_day_tokens: Some(300), - latest_tokens: Some(40), - top_model: Some("gpt-5".to_string()), - estimate_note: "estimated".to_string(), - token_cost_updated_at_ms: 1234, - }; - - let json = serde_json::to_value(summary).expect("serialize summary"); - assert_eq!( - json.get("tokenCostUpdatedAtMs").and_then(|v| v.as_i64()), - Some(1234) - ); - } - - #[test] - fn muse_local_usage_summary_exposes_complete_tokens_without_cost() { - let report = Report { - daily: vec![DailyUsage { - day: "2026-09-20".to_string(), - input_tokens: 10, - output_tokens: 2, - cache_read_tokens: 0, - cache_write_tokens: 0, - reasoning_tokens: 0, - total_tokens: 12, - request_count: 1, - models: vec![("muse-spark-1.3".to_string(), 12)], - }], - total_tokens: Some(12), - today_tokens: Some(12), - session_count: 1, - top_model: Some("muse-spark-1.3".to_string()), - coverage: LocalHistoryCoverage::Complete, - }; - let summary = muse_local_usage_summary(&report, codexbar::settings::Language::default()) - .expect("complete history is visible"); - assert_eq!(summary.today_cost, None); - assert_eq!(summary.thirty_day_cost, None); - assert_eq!(summary.thirty_day_tokens, Some(12)); - assert_eq!(summary.latest_tokens, Some(12)); - assert_eq!(summary.top_model.as_deref(), Some("muse-spark-1.3")); - - let partial = Report { - coverage: LocalHistoryCoverage::Partial, - ..report - }; - assert!( - muse_local_usage_summary(&partial, codexbar::settings::Language::default()).is_none() - ); - } - - #[test] - fn japanese_estimate_note_is_localized() { - assert_eq!( - localized_estimate_note("codex", Language::Japanese), - "ローカルログから推定したもので、請求書と異なる場合があります" - ); - assert_eq!( - localized_estimate_note("claude", Language::Japanese), - "ClaudeのローカルログからAPIレートで推定したもので、トークン総数が請求書と異なる場合があります" - ); - } - - #[test] - fn english_estimate_note_is_localized() { - assert_eq!( - localized_estimate_note("codex", Language::English), - "Estimated from local logs; may differ from your bill" - ); - assert_eq!( - localized_estimate_note("claude", Language::English), - "Estimated from local Claude logs at API rates; token totals may differ from your bill" - ); - } -} +mod tests; diff --git a/apps/desktop-tauri/src-tauri/src/commands/chart/tests.rs b/apps/desktop-tauri/src-tauri/src/commands/chart/tests.rs new file mode 100644 index 0000000000..f0d19e9adf --- /dev/null +++ b/apps/desktop-tauri/src-tauri/src/commands/chart/tests.rs @@ -0,0 +1,187 @@ +use super::{ + CostFetchFailure, ProviderLocalUsageSummary, cost_fetch_failure_allows_early_retry, + local_usage_summary_from_cost_summary, local_usage_summary_period_identity, + localized_estimate_note, muse_local_usage_summary, token_cost_cache_is_fresh, +}; +use crate::commands::is_provider_cache_fresh; +use codexbar::cost_reporting_period::{CostReportingPeriod, CostTimeZone}; +use codexbar::cost_scanner::CostSummary; +use codexbar::providers::muse::local_usage::{DailyUsage, Report}; +use codexbar::settings::Language; +use codexbar::spend_contract::LocalHistoryCoverage; +use std::time::{Duration, Instant}; + +#[test] +fn token_cost_age_does_not_use_provider_quota_age() { + let now = Instant::now(); + let token_loaded = now - Duration::from_secs(31); + let provider_updated = now; + assert!(!token_cost_cache_is_fresh( + Some(token_loaded), + now, + Duration::from_secs(30) + )); + assert!(is_provider_cache_fresh( + Some(provider_updated), + Duration::from_secs(30) + )); +} + +#[test] +fn fast_cost_failures_allow_the_next_pass_to_retry() { + assert!(cost_fetch_failure_allows_early_retry( + CostFetchFailure::Failed + )); + assert!(!cost_fetch_failure_allows_early_retry( + CostFetchFailure::TimedOut + )); +} + +#[test] +fn local_usage_summary_serializes_token_cost_timestamp() { + let summary = ProviderLocalUsageSummary { + today_cost: Some(1.0), + thirty_day_cost: Some(2.0), + thirty_day_tokens: Some(300), + period_cost: Some(3.0), + period_tokens: Some(400), + reporting_period: "month-to-date".to_string(), + latest_tokens: Some(40), + top_model: Some("gpt-5".to_string()), + estimate_note: "estimated".to_string(), + token_cost_updated_at_ms: 1234, + }; + + let json = serde_json::to_value(summary).expect("serialize summary"); + assert_eq!( + json.get("tokenCostUpdatedAtMs").and_then(|v| v.as_i64()), + Some(1234) + ); +} + +#[test] +fn local_usage_cache_identity_uses_the_summary_period_and_timestamp() { + let scanned_at = chrono::DateTime::parse_from_rfc3339("2026-09-30T23:00:00Z") + .expect("valid timestamp") + .with_timezone(&chrono::Utc); + let summary = ProviderLocalUsageSummary { + today_cost: None, + thirty_day_cost: None, + thirty_day_tokens: None, + period_cost: None, + period_tokens: None, + reporting_period: "month-to-date".to_string(), + latest_tokens: None, + top_model: None, + estimate_note: String::new(), + token_cost_updated_at_ms: scanned_at.timestamp_millis(), + }; + + assert_eq!( + local_usage_summary_period_identity(&summary), + Some(CostReportingPeriod::MonthToDate.identity(scanned_at, CostTimeZone::Local)) + ); +} + +#[test] +fn muse_local_usage_summary_exposes_complete_tokens_without_cost() { + let report = Report { + daily: vec![DailyUsage { + day: "2026-09-20".to_string(), + input_tokens: 10, + output_tokens: 2, + cache_read_tokens: 0, + cache_write_tokens: 0, + reasoning_tokens: 0, + total_tokens: 12, + request_count: 1, + models: vec![("muse-spark-1.3".to_string(), 12)], + }], + total_tokens: Some(12), + today_tokens: Some(12), + session_count: 1, + top_model: Some("muse-spark-1.3".to_string()), + coverage: LocalHistoryCoverage::Complete, + }; + let period = CostReportingPeriod::Rolling(30); + let summary = muse_local_usage_summary( + &report, + &report, + period, + codexbar::settings::Language::default(), + ) + .expect("complete history is visible"); + assert_eq!(summary.period_tokens, Some(12)); + assert_eq!(summary.reporting_period, "rolling:30"); + assert_eq!(summary.today_cost, None); + assert_eq!(summary.thirty_day_cost, None); + assert_eq!(summary.thirty_day_tokens, Some(12)); + assert_eq!(summary.latest_tokens, Some(12)); + assert_eq!(summary.top_model.as_deref(), Some("muse-spark-1.3")); + + let partial = Report { + coverage: LocalHistoryCoverage::Partial, + ..report + }; + assert!( + muse_local_usage_summary( + &partial, + &partial, + period, + codexbar::settings::Language::default() + ) + .is_none() + ); +} + +#[test] +fn local_usage_keeps_thirty_day_fixed_while_period_follows_selection() { + let thirty = CostSummary { + total_cost_usd: 30.0, + input_tokens: 3_000, + output_tokens: 0, + ..CostSummary::default() + }; + let month = CostSummary { + total_cost_usd: 5.0, + input_tokens: 400, + output_tokens: 100, + ..CostSummary::default() + }; + let summary = local_usage_summary_from_cost_summary( + "claude", + &thirty, + CostReportingPeriod::MonthToDate, + Some(&month), + ) + .expect("usage is visible"); + assert_eq!(summary.thirty_day_cost, Some(30.0)); + assert_eq!(summary.thirty_day_tokens, Some(3_000)); + assert_eq!(summary.period_cost, Some(5.0)); + assert_eq!(summary.period_tokens, Some(500)); + assert_eq!(summary.reporting_period, "month-to-date"); +} + +#[test] +fn japanese_estimate_note_is_localized() { + assert_eq!( + localized_estimate_note("codex", Language::Japanese), + "ローカルログから推定したもので、請求書と異なる場合があります" + ); + assert_eq!( + localized_estimate_note("claude", Language::Japanese), + "ClaudeのローカルログからAPIレートで推定したもので、トークン総数が請求書と異なる場合があります" + ); +} + +#[test] +fn english_estimate_note_is_localized() { + assert_eq!( + localized_estimate_note("codex", Language::English), + "Estimated from local logs; may differ from your bill" + ); + assert_eq!( + localized_estimate_note("claude", Language::English), + "Estimated from local Claude logs at API rates; token totals may differ from your bill" + ); +} diff --git a/apps/desktop-tauri/src-tauri/src/commands/settings.rs b/apps/desktop-tauri/src-tauri/src/commands/settings.rs index df0e6621a9..9cc8715582 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/settings.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/settings.rs @@ -85,6 +85,8 @@ pub struct SettingsUpdate { pub cost_summary_display_style: Option, pub open_codex_usage_logs_enabled: Option, pub hide_native_codex_cost_when_open_codex_present: Option, + /// History window: `rolling:N` (1..=365), `month-to-date`, or `all`. + pub cost_reporting_period: Option, } impl SettingsUpdate { @@ -103,6 +105,7 @@ impl SettingsUpdate { || self.low_power_mode_preference.is_some() || self.adaptive_refresh.is_some() || self.codex_custom_sessions_dirs.is_some() + || self.cost_reporting_period.is_some() || self.high_usage_threshold.is_some() || self.critical_usage_threshold.is_some() || self.provider_usage_thresholds.is_some() @@ -132,6 +135,7 @@ impl SettingsUpdate { || self.provider_hidden_usage_item_ids.is_some() || self.codex_spark_usage_visible.is_some() || self.copilot_seat_credit_entitlement.is_some() + || self.cost_reporting_period.is_some() || self.enabled_providers.is_some() || self.ui_language.is_some() } @@ -167,6 +171,13 @@ impl SettingsUpdate { if let Some(v) = self.refresh_all_providers_on_menu_open { settings.refresh_all_providers_on_menu_open = v; } + if let Some(v) = self + .cost_reporting_period + .as_deref() + .and_then(codexbar::cost_reporting_period::CostReportingPeriod::parse) + { + settings.cost_reporting_period = v; + } if let Some(v) = self.open_codex_usage_logs_enabled { settings.open_codex_usage_logs_enabled = v; } @@ -417,6 +428,11 @@ impl SettingsUpdate { { return Err(format!("Invalid low power mode preference: {value}")); } + if let Some(value) = self.cost_reporting_period.as_deref() + && codexbar::cost_reporting_period::CostReportingPeriod::parse(value).is_none() + { + return Err(format!("Invalid cost reporting period: {value}")); + } if let Some(value) = self.copilot_seat_credit_entitlement { settings.set_seat_credit_entitlement(codexbar::core::ProviderId::Copilot, value)?; } @@ -506,7 +522,8 @@ pub async fn update_settings( let mut settings = Settings::load(); let notify_float_bar = patch.notifies_float_bar(); let refresh_provider_data = patch.refreshes_provider_data(); - let clear_local_usage_cache = patch.codex_custom_sessions_dirs.is_some(); + let clear_local_usage_cache = + patch.codex_custom_sessions_dirs.is_some() || patch.cost_reporting_period.is_some(); let rebuild_tray_menu = patch.rebuilds_tray_menu(); let refresh_tray_presentation = patch.refreshes_tray_presentation(); let tray_promotion_changed = patch.changes_tray_promotion(); @@ -684,6 +701,27 @@ mod tests { assert_eq!(settings.seat_credit_entitlement(ProviderId::Copilot), None); } + #[test] + fn cost_reporting_period_update_is_validated_and_applied() { + use codexbar::cost_reporting_period::CostReportingPeriod; + + let mut settings = Settings::default(); + let bad: SettingsUpdate = + serde_json::from_str(r#"{"costReportingPeriod":"rolling:0"}"#).unwrap(); + let error = bad + .apply_to(&mut settings) + .expect_err("zero-day window must be rejected"); + assert_eq!(error, "Invalid cost reporting period: rolling:0"); + + let good: SettingsUpdate = + serde_json::from_str(r#"{"costReportingPeriod":"month-to-date"}"#).unwrap(); + good.apply_to(&mut settings).expect("valid period applies"); + assert_eq!( + settings.cost_reporting_period, + CostReportingPeriod::MonthToDate + ); + } + #[test] fn display_settings_that_affect_tray_trigger_presentation_refresh() { assert!( diff --git a/apps/desktop-tauri/src-tauri/src/commands/spend_contract.rs b/apps/desktop-tauri/src-tauri/src/commands/spend_contract.rs index 4403ba7ad0..02ae85c413 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/spend_contract.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/spend_contract.rs @@ -1,13 +1,16 @@ //! Upstream 0.53 Usage & Spend accounting bridge. +use codexbar::cost_reporting_period::CostReportingPeriod; use codexbar::cost_scanner::CostScanner; use codexbar::settings::Settings; -use codexbar::spend_contract::{SpendContract, build_local_spend_contract_from_summary}; +use codexbar::spend_contract::{SpendContract, build_contract_from_period_summary}; +/// `period` is a raw reporting period (`rolling:N`, `month-to-date`, `all`); +/// without a valid one the saved `cost_reporting_period` applies. #[tauri::command] pub async fn get_spend_contract( provider_id: String, - history_days: Option, + period: Option, include_open_codex: Option, ) -> Result { let provider = provider_id.trim().to_ascii_lowercase(); @@ -16,11 +19,12 @@ pub async fn get_spend_contract( "Spend contract is unavailable for provider: {provider}" )); } - let days = history_days.unwrap_or(30); let include_import = include_open_codex.unwrap_or(false) && provider == "codex"; tauri::async_runtime::spawn_blocking(move || { - let history_days = if days == 0 { 365 } else { days.clamp(1, 365) }; - let scanner = CostScanner::new(history_days); + let settings = Settings::load(); + let period = + CostReportingPeriod::resolve_request(period.as_deref(), settings.cost_reporting_period); + let scanner = CostScanner::for_period(period); let summary = match provider.as_str() { "codex" => scanner.scan_codex(), "claude" => scanner.scan_claude(), @@ -28,10 +32,9 @@ pub async fn get_spend_contract( "opencodego" => scanner.scan_opencodego_with_cancel(None), _ => unreachable!(), }; - let settings = Settings::load(); - build_local_spend_contract_from_summary( + build_contract_from_period_summary( &provider, - history_days, + period, include_import, settings.hide_native_codex_cost_when_open_codex_present && provider == "codex", settings.hide_personal_info, diff --git a/apps/desktop-tauri/src-tauri/src/commands/usage_spend.rs b/apps/desktop-tauri/src-tauri/src/commands/usage_spend.rs index 01f562e111..3b4dcebdc3 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/usage_spend.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/usage_spend.rs @@ -1,8 +1,12 @@ -//! Usage & Spend settings tab: 7-day / 30-day local cost aggregates. +//! Usage & Spend settings tab: 7-day / 30-day compat columns plus the +//! selected reporting period (History window). -use codexbar::cost_scanner::{CostScanner, CostSummary}; +use chrono::Utc; +use codexbar::cost_reporting_period::{CostReportingPeriod, CostTimeZone}; +use codexbar::cost_scanner::CostScanner; use codexbar::spend_contract::{ - SpendContract, build_local_spend_contract, build_local_spend_contract_from_summary, + SpendContract, build_contract_from_period_summary, build_local_spend_contract, + build_local_spend_contract_for_period, build_local_spend_contract_from_summary, }; use serde::Serialize; use tauri::State; @@ -28,6 +32,10 @@ pub struct UsageSpendRow { pub thirty_day: Option, pub seven_day_tokens: Option, pub thirty_day_tokens: Option, + /// Cost over the selected reporting period (History window). + pub period_cost: Option, + /// Tokens over the selected reporting period. + pub period_tokens: Option, pub currency: String, pub source: String, /// Included in the shared Overview spend denominator. @@ -50,6 +58,8 @@ struct SpendValues { thirty_day: Option, seven_day_tokens: Option, thirty_day_tokens: Option, + period_cost: Option, + period_tokens: Option, source: String, refreshing: bool, stale_updated_at: Option, @@ -60,6 +70,8 @@ struct SpendValues { pub struct UsageSpendSummary { pub rows: Vec, pub contract: SpendContract, + /// Raw reporting period the rows' `period*` columns were built for. + pub reporting_period: String, pub reporting_day: String, pub dashboard_timezone: String, } @@ -181,7 +193,7 @@ struct BuiltUsageSpendSummary { #[tauri::command] pub async fn get_usage_spend_summary( state: State<'_, Mutex>, - history_days: Option, + period: Option, force_refresh: Option, ) -> Result { let cached = { @@ -189,10 +201,14 @@ pub async fn get_usage_spend_summary( guard.provider_cache.clone() }; - let selected_days = history_days.unwrap_or(30); + // An explicit `period` wins; otherwise use the saved History window. + let period = CostReportingPeriod::resolve_request( + period.as_deref(), + codexbar::settings::Settings::load().cost_reporting_period, + ); let force_refresh = force_refresh.unwrap_or(false); let built = tauri::async_runtime::spawn_blocking(move || { - build_usage_spend_summary_cached(&cached, selected_days, force_refresh) + build_usage_spend_summary_cached(&cached, period, force_refresh) }) .await .map_err(|e| format!("usage spend worker failed: {e}"))??; @@ -202,7 +218,7 @@ pub async fn get_usage_spend_summary( .map(|guard| guard.provider_cache.clone())?; let current_key = usage_spend_cache_key( ¤t_cached, - selected_days, + period, &codexbar::settings::Settings::load(), ); if current_key != built.key { @@ -231,11 +247,11 @@ pub fn write_usage_spend_export(path: String, payload: String) -> Result<(), Str fn build_usage_spend_summary_cached( cached: &[ProviderUsageSnapshot], - selected_days: u32, + period: CostReportingPeriod, force_refresh: bool, ) -> Result { let settings = codexbar::settings::Settings::load(); - let key = usage_spend_cache_key(cached, selected_days, &settings); + let key = usage_spend_cache_key(cached, period, &settings); { let guard = usage_spend_coordinator() .lock() @@ -257,7 +273,7 @@ fn build_usage_spend_summary_cached( .map_err(|error| error.to_string())?; coordinator.begin(key.clone()) }; - let summary = build_usage_spend_summary(cached, selected_days, &settings, force_refresh); + let summary = build_usage_spend_summary(cached, period, &settings, force_refresh); let refreshing = summary_is_refreshing(&summary); let codex_scan_pause_reason = codexbar::core::JsonlScanner::load_cache_status(codexbar::core::ProviderId::Codex, None) @@ -299,12 +315,12 @@ fn build_usage_spend_summary_cached( fn usage_spend_cache_key( cached: &[ProviderUsageSnapshot], - selected_days: u32, + period: CostReportingPeriod, settings: &codexbar::settings::Settings, ) -> String { usage_spend_cache_key_with_privacy( cached, - selected_days, + &period.identity(Utc::now(), CostTimeZone::Local), settings.open_codex_usage_logs_enabled, settings.hide_native_codex_cost_when_open_codex_present, settings.hide_personal_info, @@ -313,7 +329,7 @@ fn usage_spend_cache_key( fn usage_spend_cache_key_with_privacy( cached: &[ProviderUsageSnapshot], - selected_days: u32, + period_identity: &str, include_opencodex: bool, hide_native: bool, hide_personal_info: bool, @@ -347,7 +363,7 @@ fn usage_spend_cache_key_with_privacy( format!( "{}|{}|{}|{}|{}|{}", chrono::Local::now().date_naive(), - selected_days, + period_identity, include_opencodex, hide_native, hide_personal_info, @@ -357,10 +373,12 @@ fn usage_spend_cache_key_with_privacy( fn build_usage_spend_summary( cached: &[ProviderUsageSnapshot], - selected_days: u32, + period: CostReportingPeriod, settings: &codexbar::settings::Settings, force_refresh: bool, ) -> UsageSpendSummary { + let now = Utc::now(); + let period_scan_days = period.scan_days(now); let include_opencodex = settings.open_codex_usage_logs_enabled; let hide_native = settings.hide_native_codex_cost_when_open_codex_present; let pi_selected = settings.enabled_providers.iter().any(|id| id == "pi") @@ -377,34 +395,44 @@ fn build_usage_spend_summary( }; let mut codex_scan_options = codex_scan_options; codex_scan_options.include_pi_sessions = include_pi_in_native; + // Any period other than 7d/30d costs one extra scan per provider. let ( - (codex_7_summary, codex_30_summary), - (claude_7_summary, claude_30_summary), - (pi_7_summary, pi_30_summary), + (codex_7_summary, codex_30_summary, codex_period_summary), + (claude_7_summary, claude_30_summary, claude_period_summary), + (pi_7_summary, pi_30_summary, pi_period_summary), ) = std::thread::scope(|scope| { let codex = scope.spawn(move || { - ( - CostScanner::new(7) - .with_options(codex_scan_options) - .scan_codex(), - CostScanner::new(30) + let seven = CostScanner::new(7) + .with_options(codex_scan_options) + .scan_codex(); + let thirty = CostScanner::new(30) + .with_options(codex_scan_options) + .scan_codex(); + let selected = selected_period_scan(period, &seven, &thirty, || { + CostScanner::for_period(period) .with_options(codex_scan_options) - .scan_codex(), - ) + .scan_codex() + }); + (seven, thirty, selected) }); let claude = scope.spawn(|| { - ( - CostScanner::new(7) - .scan_claude_with_cancel_and_pi_sessions(None, include_pi_in_native), - CostScanner::new(30) - .scan_claude_with_cancel_and_pi_sessions(None, include_pi_in_native), - ) + let seven = CostScanner::new(7) + .scan_claude_with_cancel_and_pi_sessions(None, include_pi_in_native); + let thirty = CostScanner::new(30) + .scan_claude_with_cancel_and_pi_sessions(None, include_pi_in_native); + let selected = selected_period_scan(period, &seven, &thirty, || { + CostScanner::for_period(period) + .scan_claude_with_cancel_and_pi_sessions(None, include_pi_in_native) + }); + (seven, thirty, selected) }); let pi = scope.spawn(|| { - ( - CostScanner::new(7).scan_pi(), - CostScanner::new(30).scan_pi(), - ) + let seven = CostScanner::new(7).scan_pi(); + let thirty = CostScanner::new(30).scan_pi(); + let selected = selected_period_scan(period, &seven, &thirty, || { + CostScanner::for_period(period).scan_pi() + }); + (seven, thirty, selected) }); ( codex.join().expect("Codex spend scan worker panicked"), @@ -438,6 +466,22 @@ fn build_usage_spend_summary( settings.hide_personal_info, codex_30_summary.clone(), ); + let codex_period_contract = build_contract_from_period_summary( + "codex", + period, + include_opencodex, + hide_native, + settings.hide_personal_info, + codex_period_summary, + ); + let pi_period_contract = build_contract_from_period_summary( + "pi", + period, + false, + false, + settings.hide_personal_info, + pi_period_summary, + ); let pi_7_contract = build_local_spend_contract_from_summary( "pi", 7, @@ -502,6 +546,8 @@ fn build_usage_spend_summary( thirty_day: codex_30_contract.known_cost_usd, seven_day_tokens: total_token_mix(&codex_7_contract.token_mix), thirty_day_tokens: total_token_mix(&codex_30_contract.token_mix), + period_cost: codex_period_contract.known_cost_usd, + period_tokens: total_token_mix(&codex_period_contract.token_mix), source: if include_opencodex && !codex_30_contract.imports.is_empty() { "local logs + OpenCodex".to_string() } else { @@ -523,6 +569,12 @@ fn build_usage_spend_summary( .input_tokens .saturating_add(claude_30_summary.output_tokens), ), + period_cost: Some(claude_period_summary.total_cost_usd), + period_tokens: Some( + claude_period_summary + .input_tokens + .saturating_add(claude_period_summary.output_tokens), + ), source: "local logs".to_string(), refreshing: false, stale_updated_at: None, @@ -532,6 +584,8 @@ fn build_usage_spend_summary( thirty_day: pi_30_contract.known_cost_usd, seven_day_tokens: total_token_mix(&pi_7_contract.token_mix), thirty_day_tokens: total_token_mix(&pi_30_contract.token_mix), + period_cost: pi_period_contract.known_cost_usd, + period_tokens: total_token_mix(&pi_period_contract.token_mix), source: "local Pi/OMP history".to_string(), refreshing: !pi_30_summary.history_coverage_established, stale_updated_at: None, @@ -540,11 +594,15 @@ fn build_usage_spend_summary( let seven = build_local_spend_contract(&provider_id, 7, true); let thirty = build_local_spend_contract(&provider_id, 30, true); if !thirty.imports.is_empty() { + let selected = + build_local_spend_contract_for_period(&provider_id, period, true); SpendValues { seven_day: seven.known_cost_usd, thirty_day: thirty.known_cost_usd, seven_day_tokens: total_token_mix(&seven.token_mix), thirty_day_tokens: total_token_mix(&thirty.token_mix), + period_cost: selected.known_cost_usd, + period_tokens: total_token_mix(&selected.token_mix), source: if provider_id == "opencodego" { "local logs + OpenCodex".to_string() } else { @@ -554,32 +612,39 @@ fn build_usage_spend_summary( stale_updated_at: None, } } else { - cached_spend(cached_snapshot) + cached_spend(cached_snapshot, period, now) } } "cursor" => { let seven = codexbar::providers::cursor::local_csv::summarize(7); let thirty = codexbar::providers::cursor::local_csv::summarize(30); if thirty.row_count > 0 { + let selected = + codexbar::providers::cursor::local_csv::summarize(period_scan_days); SpendValues { seven_day: (seven.row_count > 0).then_some(seven.total_cost_usd), thirty_day: Some(thirty.total_cost_usd), seven_day_tokens: (seven.row_count > 0).then_some(seven.total_tokens), thirty_day_tokens: Some(thirty.total_tokens), + period_cost: (selected.row_count > 0).then_some(selected.total_cost_usd), + period_tokens: (selected.row_count > 0).then_some(selected.total_tokens), source: "local Cursor tokscale cache".to_string(), refreshing: false, stale_updated_at: None, } } else { - cached_spend(cached_snapshot) + cached_spend(cached_snapshot, period, now) } } "grok" => { let seven = codexbar::providers::grok::local_sessions::summarize(7); let thirty = codexbar::providers::grok::local_sessions::summarize(30); - let mut spend = cached_spend(cached_snapshot); + let selected = + codexbar::providers::grok::local_sessions::summarize(period_scan_days); + let mut spend = cached_spend(cached_snapshot, period, now); spend.seven_day_tokens = (seven.session_count > 0).then_some(seven.total_tokens); spend.thirty_day_tokens = (thirty.session_count > 0).then_some(thirty.total_tokens); + spend.period_tokens = (selected.session_count > 0).then_some(selected.total_tokens); if thirty.session_count > 0 { spend.source = "local Grok sessions".to_string(); } @@ -589,17 +654,21 @@ fn build_usage_spend_summary( use codexbar::providers::antigravity::local_sessions::LocalHistoryCoverage; let seven = codexbar::providers::antigravity::local_sessions::summarize(7); let thirty = codexbar::providers::antigravity::local_sessions::summarize(30); - let mut spend = cached_spend(cached_snapshot); + let selected = + codexbar::providers::antigravity::local_sessions::summarize(period_scan_days); + let mut spend = cached_spend(cached_snapshot, period, now); spend.seven_day_tokens = matches!(seven.coverage, LocalHistoryCoverage::Complete) .then_some(seven.total_tokens); spend.thirty_day_tokens = matches!(thirty.coverage, LocalHistoryCoverage::Complete) .then_some(thirty.total_tokens); + spend.period_tokens = matches!(selected.coverage, LocalHistoryCoverage::Complete) + .then_some(selected.total_tokens); if matches!(thirty.coverage, LocalHistoryCoverage::Complete) { spend.source = "local Antigravity history".to_string(); } spend } - _ => cached_spend(cached_snapshot), + _ => cached_spend(cached_snapshot, period, now), }; let currency = cached_snapshot @@ -625,6 +694,8 @@ fn build_usage_spend_summary( thirty_day: spend.thirty_day, seven_day_tokens: spend.seven_day_tokens, thirty_day_tokens: spend.thirty_day_tokens, + period_cost: spend.period_cost, + period_tokens: spend.period_tokens, currency, source: spend.source, included_in_overview: include_in_shared_overview( @@ -638,36 +709,32 @@ fn build_usage_spend_summary( }); } - let history_days = if selected_days == 0 { - 365 - } else { - selected_days.clamp(1, 365) - }; - let selected_summary: CostSummary = match history_days { - 7 => codex_7_summary, - 30 => codex_30_summary, - days => CostScanner::new(days) - .with_options(codex_scan_options) - .scan_codex(), - }; - let contract = build_local_spend_contract_from_summary( - "codex", - history_days, - include_opencodex, - hide_native, - settings.hide_personal_info, - selected_summary, - ); + let contract = codex_period_contract; let reporting_day = last_included_reporting_day(&contract); let dashboard_timezone = codexbar::core::local_timezone_name(); UsageSpendSummary { rows, contract, + reporting_period: period.raw(), reporting_day, dashboard_timezone, } } +/// Reuse the fixed scan when it already covers the selected period. +fn selected_period_scan( + period: CostReportingPeriod, + seven: &T, + thirty: &T, + scan: impl FnOnce() -> T, +) -> T { + match period { + CostReportingPeriod::Rolling(7) => seven.clone(), + CostReportingPeriod::Rolling(30) => thirty.clone(), + _ => scan(), + } +} + /// Pi is an alternate local-history view over rows that may already be /// projected into Codex or Claude. Keep it out of the shared denominator so /// enabling Pi cannot double-count the same physical usage. @@ -701,13 +768,42 @@ fn total_token_mix(mix: &codexbar::spend_contract::SpendTokenMix) -> Option saw.then_some(total) } -fn cached_spend(snapshot: Option<&ProviderUsageSnapshot>) -> SpendValues { +/// Provider-reported daily costs bucket by UTC day, so the selected period is +/// resolved in UTC here. `None` when no day falls inside the window. +fn period_cost_from_daily( + daily: &[super::bridge::CostDailyPointBridge], + period: CostReportingPeriod, + now: chrono::DateTime, +) -> Option { + let earliest = daily + .iter() + .filter_map(|point| chrono::NaiveDate::parse_from_str(&point.day, "%Y-%m-%d").ok()) + .min(); + let bounds = period.bounds(now, CostTimeZone::UTC, earliest); + let mut total = 0.0; + let mut saw = false; + for point in daily { + if bounds.contains_day_key(&point.day) { + total += point.amount; + saw = true; + } + } + saw.then_some(total) +} + +fn cached_spend( + snapshot: Option<&ProviderUsageSnapshot>, + reporting_period: CostReportingPeriod, + now: chrono::DateTime, +) -> SpendValues { let Some(snapshot) = snapshot else { return SpendValues { seven_day: None, thirty_day: None, seven_day_tokens: None, thirty_day_tokens: None, + period_cost: None, + period_tokens: None, source: "unavailable".to_string(), refreshing: false, stale_updated_at: None, @@ -719,6 +815,8 @@ fn cached_spend(snapshot: Option<&ProviderUsageSnapshot>) -> SpendValues { thirty_day: None, seven_day_tokens: None, thirty_day_tokens: None, + period_cost: None, + period_tokens: None, source: if snapshot.error.is_some() { "unavailable".to_string() } else { @@ -739,6 +837,7 @@ fn cached_spend(snapshot: Option<&ProviderUsageSnapshot>) -> SpendValues { } else { let today = chrono::Utc::now().date_naive(); let seven_cutoff = today - chrono::Duration::days(6); + let thirty_cutoff = today - chrono::Duration::days(29); let mut seven = 0.0; let mut thirty = 0.0; let mut saw_seven = false; @@ -747,7 +846,9 @@ fn cached_spend(snapshot: Option<&ProviderUsageSnapshot>) -> SpendValues { let Ok(day) = chrono::NaiveDate::parse_from_str(&point.day, "%Y-%m-%d") else { continue; }; - if day > today { + // Providers with long daily history (Bedrock keeps 14 months) must + // not widen the fixed 30-day column. + if day > today || day < thirty_cutoff { continue; } thirty += point.amount; @@ -759,11 +860,21 @@ fn cached_spend(snapshot: Option<&ProviderUsageSnapshot>) -> SpendValues { } (saw_seven.then_some(seven), saw_thirty.then_some(thirty)) }; + let period_cost = if cost.daily.is_empty() { + // A provider-reported 30-day total only answers a 30-day selection. + (reporting_period == CostReportingPeriod::Rolling(30)) + .then_some(thirty_day) + .flatten() + } else { + period_cost_from_daily(&cost.daily, reporting_period, now) + }; SpendValues { seven_day, thirty_day, seven_day_tokens: None, thirty_day_tokens: None, + period_cost, + period_tokens: None, source: if period.is_empty() { snapshot.source_label.clone() } else { @@ -775,63 +886,4 @@ fn cached_spend(snapshot: Option<&ProviderUsageSnapshot>) -> SpendValues { } #[cfg(test)] -mod cache_key_tests { - use super::*; - - #[test] - fn invalidated_owner_clears_orphaned_indexing_activity() { - let mut coordinator = UsageSpendCoordinator::default(); - let owner = coordinator.begin("account:old".to_string()); - - assert!(coordinator.clear_if_indexing(&owner)); - assert!(!coordinator.is_current(&owner)); - } - - #[test] - fn old_owner_cleanup_cannot_clear_a_replacement() { - let mut coordinator = UsageSpendCoordinator::default(); - let old = coordinator.begin("account:old".to_string()); - let replacement = coordinator.begin("account:new".to_string()); - - assert!(!coordinator.clear_if_indexing(&old)); - assert!(coordinator.is_current(&replacement)); - } - - #[test] - fn settings_replacement_preserves_an_intentional_pause() { - let mut coordinator = UsageSpendCoordinator::default(); - let old = coordinator.begin("settings:old".to_string()); - let replacement = coordinator.begin("settings:new".to_string()); - let status = codexbar::core::CachedCostReadStatus { - codex_scan_pause_reason: Some(codexbar::core::CodexScanPauseReason::NoProgress), - ..Default::default() - }; - mark_refresh_paused_if_codex_scan_paused( - &mut coordinator, - &replacement, - true, - status.codex_scan_pause_reason.as_ref(), - ); - - assert!(!coordinator.clear_if_indexing(&old)); - assert_eq!( - coordinator.current.as_ref().map(|(_, phase)| *phase), - Some(UsageSpendRefreshPhase::Paused) - ); - } - - #[test] - fn privacy_mode_is_part_of_usage_spend_cache_identity() { - let public = usage_spend_cache_key_with_privacy(&[], 30, false, false, false); - let private = usage_spend_cache_key_with_privacy(&[], 30, false, false, true); - assert_ne!(public, private); - } - - #[test] - fn pi_history_is_an_alternate_view_not_a_shared_overview_source() { - assert!(!include_in_shared_overview("pi", true, true)); - assert!(include_in_shared_overview("codex", true, false)); - assert!(include_in_shared_overview("claude", false, true)); - assert!(!include_in_shared_overview("codex", false, false)); - } -} +mod tests; diff --git a/apps/desktop-tauri/src-tauri/src/commands/usage_spend/tests.rs b/apps/desktop-tauri/src-tauri/src/commands/usage_spend/tests.rs new file mode 100644 index 0000000000..7187b98187 --- /dev/null +++ b/apps/desktop-tauri/src-tauri/src/commands/usage_spend/tests.rs @@ -0,0 +1,182 @@ +use super::*; +use codexbar::cost_scanner::CostSummary; + +#[test] +fn invalidated_owner_clears_orphaned_indexing_activity() { + let mut coordinator = UsageSpendCoordinator::default(); + let owner = coordinator.begin("account:old".to_string()); + + assert!(coordinator.clear_if_indexing(&owner)); + assert!(!coordinator.is_current(&owner)); +} + +#[test] +fn old_owner_cleanup_cannot_clear_a_replacement() { + let mut coordinator = UsageSpendCoordinator::default(); + let old = coordinator.begin("account:old".to_string()); + let replacement = coordinator.begin("account:new".to_string()); + + assert!(!coordinator.clear_if_indexing(&old)); + assert!(coordinator.is_current(&replacement)); +} + +#[test] +fn settings_replacement_preserves_an_intentional_pause() { + let mut coordinator = UsageSpendCoordinator::default(); + let old = coordinator.begin("settings:old".to_string()); + let replacement = coordinator.begin("settings:new".to_string()); + let status = codexbar::core::CachedCostReadStatus { + codex_scan_pause_reason: Some(codexbar::core::CodexScanPauseReason::NoProgress), + ..Default::default() + }; + mark_refresh_paused_if_codex_scan_paused( + &mut coordinator, + &replacement, + true, + status.codex_scan_pause_reason.as_ref(), + ); + + assert!(!coordinator.clear_if_indexing(&old)); + assert_eq!( + coordinator.current.as_ref().map(|(_, phase)| *phase), + Some(UsageSpendRefreshPhase::Paused) + ); +} + +#[test] +fn privacy_mode_is_part_of_usage_spend_cache_identity() { + let public = usage_spend_cache_key_with_privacy(&[], "rolling:30", false, false, false); + let private = usage_spend_cache_key_with_privacy(&[], "rolling:30", false, false, true); + assert_ne!(public, private); +} + +#[test] +fn selected_period_scan_reuses_fixed_windows_and_scans_other_periods() { + let seven = CostSummary { + total_cost_usd: 7.0, + ..CostSummary::default() + }; + let thirty = CostSummary { + total_cost_usd: 30.0, + ..CostSummary::default() + }; + let mut scanned = false; + let selected = selected_period_scan(CostReportingPeriod::Rolling(7), &seven, &thirty, || { + scanned = true; + CostSummary::default() + }); + assert_eq!(selected.total_cost_usd, 7.0); + assert!(!scanned); + + let selected = selected_period_scan(CostReportingPeriod::Rolling(90), &seven, &thirty, || { + scanned = true; + CostSummary { + total_cost_usd: 90.0, + ..CostSummary::default() + } + }); + assert_eq!(selected.total_cost_usd, 90.0); + assert!(scanned); +} + +fn identity(period: CostReportingPeriod, now: &str) -> String { + let now = chrono::DateTime::parse_from_rfc3339(now) + .expect("valid instant") + .with_timezone(&Utc); + period.identity(now, CostTimeZone::UTC) +} + +#[test] +fn selected_period_is_part_of_usage_spend_cache_identity() { + let now = "2026-09-15T12:00:00Z"; + let month = identity(CostReportingPeriod::MonthToDate, now); + let rolling = identity(CostReportingPeriod::Rolling(30), now); + let all = identity(CostReportingPeriod::AllAvailable, now); + assert_ne!(month, rolling); + assert_ne!(rolling, all); + assert_ne!( + usage_spend_cache_key_with_privacy(&[], &month, false, false, false), + usage_spend_cache_key_with_privacy(&[], &rolling, false, false, false) + ); +} + +#[test] +fn month_to_date_identity_changes_across_a_month_boundary() { + let last_day = identity(CostReportingPeriod::MonthToDate, "2026-09-30T23:00:00Z"); + let first_day = identity(CostReportingPeriod::MonthToDate, "2026-10-01T01:00:00Z"); + assert_ne!(last_day, first_day); + assert_ne!( + usage_spend_cache_key_with_privacy(&[], &last_day, false, false, false), + usage_spend_cache_key_with_privacy(&[], &first_day, false, false, false) + ); +} + +#[test] +fn daily_period_cost_follows_the_selected_window() { + let daily = ["2026-09-01", "2026-09-10", "2026-08-31"] + .into_iter() + .map(|day| super::super::bridge::CostDailyPointBridge { + day: day.to_string(), + amount: 1.0, + }) + .collect::>(); + let now = chrono::DateTime::parse_from_rfc3339("2026-09-15T12:00:00Z") + .expect("valid instant") + .with_timezone(&Utc); + assert_eq!( + period_cost_from_daily(&daily, CostReportingPeriod::MonthToDate, now), + Some(2.0) + ); + assert_eq!( + period_cost_from_daily(&daily, CostReportingPeriod::AllAvailable, now), + Some(3.0) + ); + assert_eq!( + period_cost_from_daily(&daily, CostReportingPeriod::Rolling(3), now), + None + ); +} + +#[test] +fn long_provider_daily_history_does_not_widen_the_fixed_columns() { + // Bedrock reports 14 months of daily spend; the 7d / 30d columns keep + // their windows while the selected period still sees the whole history. + let today = Utc::now().date_naive(); + let day = |ago: i64| { + serde_json::json!({ + "day": (today - chrono::Duration::days(ago)).format("%Y-%m-%d").to_string(), + "amount": 1.0, + }) + }; + let cost: super::super::bridge::CostSnapshotBridge = + serde_json::from_value(serde_json::json!({ + "used": 99.0, + "period": "Monthly", + "daily": [day(0), day(6), day(7), day(29), day(30), day(200)], + })) + .expect("cost fixture"); + let mut snapshot = ProviderUsageSnapshot::from_error( + codexbar::core::ProviderId::Bedrock, + codexbar::core::instantiate_provider(codexbar::core::ProviderId::Bedrock).metadata(), + "unused".to_string(), + codexbar::core::ProviderStateKind::Unknown, + ); + snapshot.cost = Some(cost); + + let spend = cached_spend( + Some(&snapshot), + CostReportingPeriod::AllAvailable, + Utc::now(), + ); + assert_eq!(spend.seven_day, Some(2.0)); + assert_eq!(spend.thirty_day, Some(4.0)); + assert_eq!(spend.period_cost, Some(6.0)); +} + +#[test] +fn pi_history_is_an_alternate_view_not_a_shared_overview_source() { + assert!(!include_in_shared_overview("pi", true, true)); + assert!(include_in_shared_overview("codex", true, false)); + assert!(include_in_shared_overview("claude", false, true)); + assert!(!include_in_shared_overview("codex", false, false)); +} diff --git a/apps/desktop-tauri/src-tauri/src/powertoys.rs b/apps/desktop-tauri/src-tauri/src/powertoys.rs index 99012ae7c3..7e4865f8ac 100644 --- a/apps/desktop-tauri/src-tauri/src/powertoys.rs +++ b/apps/desktop-tauri/src-tauri/src/powertoys.rs @@ -33,8 +33,11 @@ pub struct PowerToysProviderSnapshot { secondary_label: Option, secondary: Option, today_cost: Option, + /// Published pipe contract: always the trailing 30 days, independent of + /// the History window setting (`cost_reporting_period`). thirty_day_cost: Option, latest_tokens: Option, + /// Published pipe contract: always the trailing 30 days. thirty_day_tokens: Option, top_model: Option, updated_at: String, @@ -227,6 +230,10 @@ mod tests { today_cost: Some(1.25), thirty_day_cost: Some(12.5), thirty_day_tokens: Some(42_000), + // A different History window must not change the pipe values. + period_cost: Some(99.0), + period_tokens: Some(99_000), + reporting_period: "month-to-date".to_string(), latest_tokens: Some(1_200), top_model: Some("gpt-5".to_string()), estimate_note: "cached".to_string(), diff --git a/apps/desktop-tauri/src/components/MenuCard.test.tsx b/apps/desktop-tauri/src/components/MenuCard.test.tsx index c8ec795546..75addfb86e 100644 --- a/apps/desktop-tauri/src/components/MenuCard.test.tsx +++ b/apps/desktop-tauri/src/components/MenuCard.test.tsx @@ -127,8 +127,10 @@ describe("MenuCard", () => { PanelFiveHours: "5h", PanelOnPaceBudget: "On-pace budget", PanelReserveSuffix: "in reserve", - PanelThirtyDayCost: "30d cost", - PanelThirtyDayTokens: "30d tokens", + PanelPeriodCost: "{} cost", + PanelPeriodTokens: "{} tokens", + CostPeriodShortMonthToDate: "MTD", + CostPeriodShortDays: "{}d", PanelTodayBudget: "today", PanelUsedSuffix: "used", ResetsInHoursMinutes: "Resets in {}h {}m", @@ -166,6 +168,9 @@ describe("MenuCard", () => { todayCost: null, thirtyDayCost: 1.23, thirtyDayTokens: 584_000, + periodCost: 1.23, + periodTokens: 584_000, + reportingPeriod: "rolling:30", latestTokens: null, topModel: "glim-4.6", estimateNote: "Estimated from local logs", @@ -601,6 +606,37 @@ describe("MenuCard", () => { expect(details.open).toBe(true); }); + it("labels local totals with the selected History window instead of 30 days", async () => { + tauriMocks.getProviderChartData.mockResolvedValue({ + providerId: "claude", + costHistory: [{ date: "2026-05-24", value: 1.23 }], + creditsHistory: [], + usageBreakdown: [], + localUsage: { + todayCost: null, + // The fixed 30-day fields must not leak into the period rows. + thirtyDayCost: 1.23, + thirtyDayTokens: 584_000, + periodCost: 7.5, + periodTokens: 2_000_000, + reportingPeriod: "month-to-date", + latestTokens: null, + topModel: "glim-4.6", + estimateNote: "Estimated from local logs", + tokenCostUpdatedAtMs: 1234, + }, + }); + + renderCard(provider(null)); + + expect(await screen.findByText("MTD cost")).toBeInTheDocument(); + expect(screen.getByText("MTD tokens")).toBeInTheDocument(); + expect(screen.getByText("$7.50")).toBeInTheDocument(); + expect(screen.getByText("2M")).toBeInTheDocument(); + expect(screen.queryByText("30d cost")).not.toBeInTheDocument(); + expect(screen.queryByText("584K")).not.toBeInTheDocument(); + }); + it("places Claude accounts above metrics and the collapsed usage details", async () => { tauriMocks.claudeAccountsList.mockResolvedValue([ { id: "a", email: "a@example.com", organization: "Personal", isActive: true, isSaved: true }, diff --git a/apps/desktop-tauri/src/components/MenuCardDetails.tsx b/apps/desktop-tauri/src/components/MenuCardDetails.tsx index 31634bd910..7ba3428353 100644 --- a/apps/desktop-tauri/src/components/MenuCardDetails.tsx +++ b/apps/desktop-tauri/src/components/MenuCardDetails.tsx @@ -18,6 +18,7 @@ import { type ResetTimeFormatMode, } from "../hooks/useFormattedResetTime"; import { formatEta } from "../lib/formatEta"; +import { periodCostLabel, periodTokensLabel } from "../lib/costPeriod"; import type { LocaleKey } from "../i18n/keys"; import { paceCategory } from "../surfaces/tray/paceCategory"; import { SimpleBarChart, StackedBarChart } from "./MiniBarChart"; @@ -114,6 +115,8 @@ function LocalUsageBlock({ const { t } = useLocale(); const isCodex = providerId === "codex"; const isMuse = providerId === "muse"; + // The selected History window; the histogram below stays a fixed 30 days. + const { reportingPeriod, periodCost, periodTokens } = summary; const visibleHistory = costHistory.slice(-30); const maxCost = Math.max( ...visibleHistory.flatMap((point) => (point.value == null ? [] : [point.value])), @@ -137,17 +140,19 @@ function LocalUsageBlock({ {!isMuse && (
- {t("PanelThirtyDayCost")} + + {periodCostLabel(reportingPeriod, t)} + - {summary.thirtyDayCost != null - ? formatCurrency(summary.thirtyDayCost, "USD") - : "—"} + {periodCost != null ? formatCurrency(periodCost, "USD") : "—"}
)}
- {t("PanelThirtyDayTokens")} - {formatCompactCount(summary.thirtyDayTokens)} + + {periodTokensLabel(reportingPeriod, t)} + + {formatCompactCount(periodTokens)}
{!isMuse && (
diff --git a/apps/desktop-tauri/src/floatbar/FloatBar.test.tsx b/apps/desktop-tauri/src/floatbar/FloatBar.test.tsx index 88860e087d..03d8addd1b 100644 --- a/apps/desktop-tauri/src/floatbar/FloatBar.test.tsx +++ b/apps/desktop-tauri/src/floatbar/FloatBar.test.tsx @@ -230,7 +230,8 @@ describe("FloatBar", () => { ProviderIssueSessionExpired: "Session expired", ProviderIssueLocalRuntimeOffline: "Local runtime offline", ProviderIssueUnknown: "Usage unavailable", - FloatBarThirtyDayShort: "30d", + CostPeriodShortMonthToDate: "MTD", + CostPeriodShortDays: "{}d", FloatBarNoProviders: "No providers", FloatBarRemainingSuffix: "remaining", }), @@ -388,6 +389,9 @@ describe("FloatBar", () => { todayCost: 1.25, thirtyDayCost: 12.5, thirtyDayTokens: 1000, + periodCost: 12.5, + periodTokens: 1000, + reportingPeriod: "rolling:30", latestTokens: 200, topModel: "gpt-5", estimateNote: "Estimated from local logs", @@ -409,6 +413,9 @@ describe("FloatBar", () => { todayCost: 1.25, thirtyDayCost: 12.5, thirtyDayTokens: 1000, + periodCost: 12.5, + periodTokens: 1000, + reportingPeriod: "rolling:30", latestTokens: 200, topModel: "gpt-5", estimateNote: "Estimated from local logs", @@ -427,6 +434,38 @@ describe("FloatBar", () => { ); }); + it("shows the selected History window cost with its short label", async () => { + tauriMocks.getCachedProviders.mockResolvedValue([snapshot("codex", "Codex", 75)]); + tauriMocks.getSettingsSnapshot.mockResolvedValue( + settings({ floatBarShowCost: true, costReportingPeriod: "month-to-date" }), + ); + tauriMocks.getProviderLocalUsageSummary.mockResolvedValue({ + todayCost: 1.25, + // Fixed 30-day compat field stays different from the selected window. + thirtyDayCost: 12.5, + thirtyDayTokens: 1000, + periodCost: 4.75, + periodTokens: 300, + reportingPeriod: "month-to-date", + latestTokens: 200, + topModel: "gpt-5", + estimateNote: "Estimated from local logs", + tokenCostUpdatedAtMs: 1234, + }); + + const { container } = renderFloatBar( + bootstrap({ floatBarShowCost: true, costReportingPeriod: "month-to-date" }), + ); + + await waitFor(() => { + const items = Array.from(container.querySelectorAll(".floatbar__cost-item")).map( + (item) => item.textContent, + ); + expect(items).toEqual(["Today$1.25", "MTD$4.75"]); + }); + expect(container.textContent).not.toContain("$12.50"); + }); + it("does not scan local costs by default", async () => { tauriMocks.getCachedProviders.mockResolvedValue([ snapshot("codex", "Codex", 75), diff --git a/apps/desktop-tauri/src/floatbar/FloatBar.tsx b/apps/desktop-tauri/src/floatbar/FloatBar.tsx index e8d71af8fe..f38f1eb3b2 100644 --- a/apps/desktop-tauri/src/floatbar/FloatBar.tsx +++ b/apps/desktop-tauri/src/floatbar/FloatBar.tsx @@ -19,6 +19,7 @@ import { } from "../lib/tauri"; import { ProviderIcon } from "../components/providers/ProviderIcon"; import { getProviderIcon } from "../components/providers/providerIcons"; +import { costPeriodShortLabel } from "../lib/costPeriod"; import { describeProviderState } from "../lib/providerState"; import type { BootstrapState, @@ -92,7 +93,10 @@ type FloatBarCostSummary = { providerId: string; displayName: string; todayCost: number | null; - thirtyDayCost: number | null; + /** Cost over the selected History window. */ + periodCost: number | null; + /** Raw History window `periodCost` covers. */ + period: string; }; type FloatBarCostTarget = { @@ -106,7 +110,7 @@ function providerCostKey(provider: ProviderUsageSnapshot): string { } function hasLocalCost(summary: ProviderLocalUsageSummary | null): summary is ProviderLocalUsageSummary { - return summary?.todayCost != null || summary?.thirtyDayCost != null; + return summary?.todayCost != null || summary?.periodCost != null; } function formatUsd(value: number | null): string | null { @@ -118,22 +122,22 @@ function CostPill({ summary, scale, todayLabel, - thirtyDayLabel, + periodLabel, estimateLabel, }: { summary: FloatBarCostSummary; scale: number; todayLabel: string; - thirtyDayLabel: string; + periodLabel: string; estimateLabel: string; }) { const today = formatUsd(summary.todayCost); - const thirtyDay = formatUsd(summary.thirtyDayCost); + const periodCost = formatUsd(summary.periodCost); const iconSize = Math.round(10 * scale); const brand = getProviderIcon(summary.providerId).brandColor; const title = [ today ? `${todayLabel} ${today}` : null, - thirtyDay ? `${thirtyDayLabel} ${thirtyDay}` : null, + periodCost ? `${periodLabel} ${periodCost}` : null, ] .filter(Boolean) .join(" / "); @@ -159,13 +163,13 @@ function CostPill({ )} - {thirtyDay && ( + {periodCost && ( - {thirtyDayLabel} + {periodLabel} - {thirtyDay} + {periodCost} )} @@ -382,7 +386,8 @@ export default function FloatBar({ state }: { state: BootstrapState }) { providerId: target.providerId, displayName: target.displayName, todayCost: localUsage.todayCost, - thirtyDayCost: localUsage.thirtyDayCost, + periodCost: localUsage.periodCost, + period: localUsage.reportingPeriod, } satisfies FloatBarCostSummary; }), ) @@ -403,13 +408,14 @@ export default function FloatBar({ state }: { state: BootstrapState }) { return () => { cancelled = true; }; - }, [visibleCostTargets]); + // A History window change re-reads the local usage summaries. + }, [visibleCostTargets, settings.costReportingPeriod]); const visibleCosts = visible .map((provider) => localCosts[providerCostKey(provider)]) .filter((summary): summary is FloatBarCostSummary => Boolean(summary)); const visibleCostValuesKey = visibleCosts - .map((summary) => `${summary.key}:${summary.todayCost ?? ""}:${summary.thirtyDayCost ?? ""}`) + .map((summary) => `${summary.key}:${summary.todayCost ?? ""}:${summary.period ?? ""}:${summary.periodCost ?? ""}`) .join("|"); // Keep the native floatbar window fitted when late data/fonts/icons change layout. const lastResizeRef = useRef<{ w: number; h: number } | null>(null); @@ -522,7 +528,7 @@ export default function FloatBar({ state }: { state: BootstrapState }) { summary={summary} scale={scale} todayLabel={t("PanelToday")} - thirtyDayLabel={t("FloatBarThirtyDayShort")} + periodLabel={costPeriodShortLabel(summary.period, t)} estimateLabel={t("OverviewSpendEstimate")} /> ))} diff --git a/apps/desktop-tauri/src/i18n/keys.ts b/apps/desktop-tauri/src/i18n/keys.ts index 195620e3bf..6c07f03044 100644 --- a/apps/desktop-tauri/src/i18n/keys.ts +++ b/apps/desktop-tauri/src/i18n/keys.ts @@ -396,7 +396,6 @@ export const ALL_LOCALE_KEYS = [ "UsageSpendTitle", "UsageSpendCaption", "UsageSpendModels", - "UsageSpendAllTime", "UsageSpendOpenCodexImport", "UsageSpendHideNativeCodex", "UsageSpendSpend", @@ -416,9 +415,7 @@ export const ALL_LOCALE_KEYS = [ "UsageSpendHourlyActivity", "UsageSpendRequests", "UsageSpendTokens", - "UsageSpendAllTimeHistory", "UsageSpendCustomPricing", - "OverviewSpendTitle", "OverviewSpendProviderCoverage", "OverviewSpendEstimate", "UsageSpendProjects", @@ -641,8 +638,6 @@ export const ALL_LOCALE_KEYS = [ "PanelMenu", "PanelCopied", "PanelToday", - "PanelThirtyDayCost", - "PanelThirtyDayTokens", "PanelLatestTokens", "PanelThirtyDayCostHistogram", "PanelTopModelPrefix", @@ -662,7 +657,6 @@ export const ALL_LOCALE_KEYS = [ "PanelReserveLastsUntilReset", "PanelReserveRunsOutInDaysHours", "PanelReserveRunsOutInHours", - "FloatBarThirtyDayShort", "FloatBarNoProviders", "FloatBarRemainingSuffix", "FloatBarShowCost", @@ -914,6 +908,24 @@ export const ALL_LOCALE_KEYS = [ "ProviderAutoResumeTitle", "ProviderAutoResumeAfterQuotaReset", "ProviderAutoResumeAfterQuotaResetHelper", + + // History window for local cost surfaces (upstream 0.67.0 reporting periods) + "CostPeriodHistoryWindow", + "CostPeriodHistoryWindowHelper", + "CostPeriodMonthToDate", + "CostPeriodAll", + "CostPeriodToday", + "CostPeriodLastDays", + "CostPeriodCustom", + "CostPeriodCustomDays", + "CostPeriodShortMonthToDate", + "CostPeriodShortAll", + "CostPeriodShortDays", + "PanelPeriodCost", + "PanelPeriodTokens", + "OverviewSpendPeriodTitle", + "UsageSpendModelsPeriodCaption", + "UsageSpendProjectsPeriodCaption", ] as const; export type LocaleKey = (typeof ALL_LOCALE_KEYS)[number]; diff --git a/apps/desktop-tauri/src/lib/costPeriod.test.ts b/apps/desktop-tauri/src/lib/costPeriod.test.ts new file mode 100644 index 0000000000..fd8547865f --- /dev/null +++ b/apps/desktop-tauri/src/lib/costPeriod.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it } from "vitest"; +import type { LocaleKey } from "../i18n/keys"; +import { + DEFAULT_COST_PERIOD, + PRESET_COST_PERIODS, + costPeriodLabel, + costPeriodShortLabel, + customPeriodRaw, + isPresetCostPeriod, + normalizeCostPeriod, + parseCostPeriod, + periodCostLabel, + periodTokensLabel, + rollingDays, +} from "./costPeriod"; + +const EN: Partial> = { + CostPeriodMonthToDate: "Month to date", + CostPeriodAll: "All available history", + CostPeriodToday: "Today", + CostPeriodLastDays: "Last {} days", + CostPeriodShortMonthToDate: "MTD", + CostPeriodShortAll: "All", + CostPeriodShortDays: "{}d", + PanelPeriodCost: "{} cost", + PanelPeriodTokens: "{} tokens", +}; +const t = (key: LocaleKey) => EN[key] ?? key; + +describe("parseCostPeriod", () => { + it("reads the persisted forms", () => { + expect(parseCostPeriod("month-to-date")).toEqual({ kind: "monthToDate" }); + expect(parseCostPeriod("all")).toEqual({ kind: "all" }); + expect(parseCostPeriod("rolling:7")).toEqual({ kind: "rolling", days: 7 }); + expect(parseCostPeriod(" rolling:90 ")).toEqual({ kind: "rolling", days: 90 }); + }); + + it("clamps counts above 365 and rejects zero and unreadable values", () => { + expect(parseCostPeriod("rolling:4000")).toEqual({ kind: "rolling", days: 365 }); + expect(parseCostPeriod("rolling:0")).toBeNull(); + expect(parseCostPeriod("rolling:-3")).toBeNull(); + expect(parseCostPeriod("rolling:seven")).toBeNull(); + expect(parseCostPeriod("weekly")).toBeNull(); + expect(parseCostPeriod("")).toBeNull(); + expect(parseCostPeriod(undefined)).toBeNull(); + }); +}); + +describe("normalizeCostPeriod", () => { + it("keeps valid periods and falls back to 30 days otherwise", () => { + expect(normalizeCostPeriod("month-to-date")).toBe("month-to-date"); + expect(normalizeCostPeriod("rolling:9999")).toBe("rolling:365"); + expect(normalizeCostPeriod("rolling:0")).toBe(DEFAULT_COST_PERIOD); + expect(normalizeCostPeriod(undefined)).toBe("rolling:30"); + }); +}); + +describe("customPeriodRaw", () => { + it("accepts integers in 1..=365 only", () => { + expect(customPeriodRaw("1")).toBe("rolling:1"); + expect(customPeriodRaw(" 14 ")).toBe("rolling:14"); + expect(customPeriodRaw("365")).toBe("rolling:365"); + expect(customPeriodRaw("0")).toBeNull(); + expect(customPeriodRaw("366")).toBeNull(); + expect(customPeriodRaw("1.5")).toBeNull(); + expect(customPeriodRaw("-2")).toBeNull(); + expect(customPeriodRaw("")).toBeNull(); + expect(customPeriodRaw("abc")).toBeNull(); + }); +}); + +describe("presets", () => { + it("lists month to date, 1/7/30/90/365 days, and all", () => { + expect([...PRESET_COST_PERIODS]).toEqual([ + "month-to-date", + "rolling:1", + "rolling:7", + "rolling:30", + "rolling:90", + "rolling:365", + "all", + ]); + expect(isPresetCostPeriod("rolling:7")).toBe(true); + expect(isPresetCostPeriod("rolling:14")).toBe(false); + expect(rollingDays("rolling:14")).toBe(14); + expect(rollingDays("all")).toBeNull(); + }); +}); + +describe("labels", () => { + it("builds long labels", () => { + expect(costPeriodLabel("month-to-date", t)).toBe("Month to date"); + expect(costPeriodLabel("all", t)).toBe("All available history"); + expect(costPeriodLabel("rolling:1", t)).toBe("Today"); + expect(costPeriodLabel("rolling:14", t)).toBe("Last 14 days"); + expect(costPeriodLabel(undefined, t)).toBe("Last 30 days"); + }); + + it("builds short and menu labels", () => { + expect(costPeriodShortLabel("month-to-date", t)).toBe("MTD"); + expect(costPeriodShortLabel("all", t)).toBe("All"); + expect(costPeriodShortLabel("rolling:7", t)).toBe("7d"); + expect(periodCostLabel("month-to-date", t)).toBe("MTD cost"); + expect(periodTokensLabel("rolling:90", t)).toBe("90d tokens"); + }); +}); diff --git a/apps/desktop-tauri/src/lib/costPeriod.ts b/apps/desktop-tauri/src/lib/costPeriod.ts new file mode 100644 index 0000000000..f03f1d8b64 --- /dev/null +++ b/apps/desktop-tauri/src/lib/costPeriod.ts @@ -0,0 +1,118 @@ +import type { LocaleKey } from "../i18n/keys"; + +/** + * History window ("cost reporting period") helpers. The persisted form mirrors + * `codexbar::cost_reporting_period::CostReportingPeriod::raw`: + * `rolling:N` (1..=365), `month-to-date`, or `all`. + */ + +export const DEFAULT_COST_PERIOD = "rolling:30"; +export const MAX_ROLLING_DAYS = 365; + +export type ParsedCostPeriod = + | { kind: "rolling"; days: number } + | { kind: "monthToDate" } + | { kind: "all" }; + +type Translate = (key: LocaleKey) => string; + +/** Presets offered by the picker, in display order. Custom day counts are separate. */ +export const PRESET_COST_PERIODS = [ + "month-to-date", + "rolling:1", + "rolling:7", + "rolling:30", + "rolling:90", + "rolling:365", + "all", +] as const; + +/** Parse a persisted period. Unknown forms and `rolling:0` return null; counts above 365 clamp. */ +export function parseCostPeriod(raw: string | null | undefined): ParsedCostPeriod | null { + const value = raw?.trim(); + if (!value) return null; + if (value === "month-to-date") return { kind: "monthToDate" }; + if (value === "all") return { kind: "all" }; + const match = /^rolling:(\d+)$/.exec(value); + if (!match) return null; + const days = Number.parseInt(match[1], 10); + if (!Number.isSafeInteger(days) || days <= 0) return null; + return { kind: "rolling", days: Math.min(days, MAX_ROLLING_DAYS) }; +} + +function toRaw(parsed: ParsedCostPeriod): string { + switch (parsed.kind) { + case "monthToDate": + return "month-to-date"; + case "all": + return "all"; + case "rolling": + return `rolling:${parsed.days}`; + } +} + +/** Canonical persisted form, or the 30-day default when the value is missing or unreadable. */ +export function normalizeCostPeriod(raw: string | null | undefined): string { + const parsed = parseCostPeriod(raw); + return parsed ? toRaw(parsed) : DEFAULT_COST_PERIOD; +} + +/** Persisted form for a typed custom day count; null unless it is an integer in 1..=365. */ +export function customPeriodRaw(input: string): string | null { + const text = input.trim(); + if (!/^\d+$/.test(text)) return null; + const days = Number.parseInt(text, 10); + if (!Number.isSafeInteger(days) || days < 1 || days > MAX_ROLLING_DAYS) return null; + return `rolling:${days}`; +} + +export function isPresetCostPeriod(raw: string): boolean { + return (PRESET_COST_PERIODS as readonly string[]).includes(raw); +} + +/** Day count of a rolling period, or null for month-to-date / all. */ +export function rollingDays(raw: string | null | undefined): number | null { + const parsed = parseCostPeriod(raw); + return parsed?.kind === "rolling" ? parsed.days : null; +} + +function fill(template: string, value: string): string { + return template.replace("{}", value); +} + +/** Long label, for headings and captions ("Month to date", "Last 7 days"). */ +export function costPeriodLabel(raw: string | null | undefined, t: Translate): string { + const parsed = parseCostPeriod(raw ?? DEFAULT_COST_PERIOD) ?? { kind: "rolling", days: 30 }; + switch (parsed.kind) { + case "monthToDate": + return t("CostPeriodMonthToDate"); + case "all": + return t("CostPeriodAll"); + case "rolling": + return parsed.days === 1 + ? t("CostPeriodToday") + : fill(t("CostPeriodLastDays"), String(parsed.days)); + } +} + +/** Compact label, for menu rows and the float bar ("MTD", "7d", "All"). */ +export function costPeriodShortLabel(raw: string | null | undefined, t: Translate): string { + const parsed = parseCostPeriod(raw ?? DEFAULT_COST_PERIOD) ?? { kind: "rolling", days: 30 }; + switch (parsed.kind) { + case "monthToDate": + return t("CostPeriodShortMonthToDate"); + case "all": + return t("CostPeriodShortAll"); + case "rolling": + return fill(t("CostPeriodShortDays"), String(parsed.days)); + } +} + +/** Menu row label for the selected window ("MTD cost"). */ +export function periodCostLabel(raw: string | null | undefined, t: Translate): string { + return fill(t("PanelPeriodCost"), costPeriodShortLabel(raw, t)); +} + +export function periodTokensLabel(raw: string | null | undefined, t: Translate): string { + return fill(t("PanelPeriodTokens"), costPeriodShortLabel(raw, t)); +} diff --git a/apps/desktop-tauri/src/lib/tauri.ts b/apps/desktop-tauri/src/lib/tauri.ts index 6ccd63c0de..4e1956fada 100644 --- a/apps/desktop-tauri/src/lib/tauri.ts +++ b/apps/desktop-tauri/src/lib/tauri.ts @@ -284,9 +284,10 @@ export function getProviderLocalUsageSummary( return invoke("get_provider_local_usage_summary", { providerId }); } -export function getUsageSpendSummary(options?: { historyDays?: number; forceRefresh?: boolean }): Promise { +/** `period` is a raw reporting period; omitted means the saved `costReportingPeriod`. */ +export function getUsageSpendSummary(options?: { period?: string; forceRefresh?: boolean }): Promise { return invoke("get_usage_spend_summary", { - historyDays: options?.historyDays ?? null, + period: options?.period ?? null, forceRefresh: options?.forceRefresh ?? null, }); } @@ -297,11 +298,11 @@ export function writeUsageSpendExport(path: string, payload: string): Promise { return invoke("get_spend_contract", { providerId, - historyDays: options?.historyDays ?? null, + period: options?.period ?? null, includeOpenCodex: options?.includeOpenCodex ?? null, }); } diff --git a/apps/desktop-tauri/src/lib/usageSpendSharing.test.ts b/apps/desktop-tauri/src/lib/usageSpendSharing.test.ts index bf693ce7b8..16f400a597 100644 --- a/apps/desktop-tauri/src/lib/usageSpendSharing.test.ts +++ b/apps/desktop-tauri/src/lib/usageSpendSharing.test.ts @@ -4,6 +4,7 @@ import { formatUsageSpendReportingDay, filterUsageSpendSummaryForOverview, renderUsageSpendSharePng, + usageSpendShareColumns, usageSpendShareFooter, usageSpendSubscriptionCaption, } from "./usageSpendSharing"; @@ -34,11 +35,14 @@ describe("usage spend sharing", () => { expect( usageSpendShareFooter({ contract: {} as SpendContract, + reportingPeriod: "rolling:30", rows: [{ providerId: "codex", displayName: "Codex", sevenDay: null, thirtyDay: null, + periodCost: null, + periodTokens: null, currency: "USD", source: "local", includedInOverview: true, @@ -52,6 +56,7 @@ describe("usage spend sharing", () => { it("keeps hidden sources out of the Overview share summary", () => { const summary: UsageSpendSummary = { contract: {} as SpendContract, + reportingPeriod: "rolling:30", reportingDay: "2026-09-19", dashboardTimezone: "UTC", rows: [ @@ -60,6 +65,8 @@ describe("usage spend sharing", () => { displayName: "Codex", sevenDay: 1, thirtyDay: 2, + periodCost: 1, + periodTokens: null, currency: "USD", source: "local", includedInOverview: true, @@ -69,6 +76,8 @@ describe("usage spend sharing", () => { displayName: "Claude", sevenDay: 3, thirtyDay: 4, + periodCost: 3, + periodTokens: null, currency: "USD", source: "hidden", includedInOverview: false, @@ -86,12 +95,22 @@ describe("usage spend sharing", () => { // account emails or provider-identity secrets. The renderer draws exactly // these cells; keep the invariant comment on renderUsageSpendSharePng in // sync if this list ever grows. - const renderedCells = ["displayName", "sevenDay", "thirtyDay", "currency", "source"] as const; + const renderedCells = [ + "displayName", + "periodCost", + "periodTokens", + "sevenDay", + "thirtyDay", + "currency", + "source", + ] as const; const row: UsageSpendRow = { providerId: "codex", displayName: "Codex", sevenDay: 1, thirtyDay: 2, + periodCost: 2, + periodTokens: null, currency: "USD", source: "local", includedInOverview: true, @@ -101,7 +120,35 @@ describe("usage spend sharing", () => { ); // Nothing outside the drawn cells may be read by the renderer, and no // UsageSpendRow field may carry account-identity data (no email/org). - expect(Object.keys(row).filter((key) => /email|org|token|account/i.test(key))).toEqual([]); + expect(Object.keys(row).filter((key) => /email|org|account|secret|(access|auth|api|refresh)token/i.test(key))).toEqual([]); expect(unsafeKeys).toEqual(["providerId", "includedInOverview"]); }); + + it("adds a labelled History window column while keeping the fixed 7 and 30 day columns", () => { + const row: UsageSpendRow = { + providerId: "codex", + displayName: "Codex", + sevenDay: 1, + thirtyDay: 2, + periodCost: 9, + periodTokens: 900, + currency: "USD", + source: "local", + includedInOverview: true, + }; + const withPeriod = usageSpendShareColumns("Month to date"); + expect(withPeriod.headers).toEqual([ + "Provider", + "Month to date", + "7 days", + "30 days", + "Currency", + "Source", + ]); + const cells = withPeriod.cellsFor(row); + expect(cells).toHaveLength(withPeriod.headers.length); + expect(cells).toHaveLength(withPeriod.colW.length); + expect(cells[1]).toContain("900"); + expect(cells[2]).not.toContain("900"); + }); }); diff --git a/apps/desktop-tauri/src/lib/usageSpendSharing.ts b/apps/desktop-tauri/src/lib/usageSpendSharing.ts index 2246e98505..7f4957b0a8 100644 --- a/apps/desktop-tauri/src/lib/usageSpendSharing.ts +++ b/apps/desktop-tauri/src/lib/usageSpendSharing.ts @@ -139,6 +139,29 @@ export function formatSpendMetric( return parts.length > 0 ? parts.join(" · ") : "—"; } +/** + * Share-card columns: the selected History window (`periodCost` / `periodTokens`) + * follows the provider name, then the fixed 7 and 30 day compatibility columns. + */ +export function usageSpendShareColumns(periodLabel: string): { + headers: string[]; + colW: number[]; + cellsFor: (row: UsageSpendRow) => string[]; +} { + return { + headers: ["Provider", periodLabel, "7 days", "30 days", "Currency", "Source"], + colW: [150, 130, 100, 100, 70, 130], + cellsFor: (row) => [ + row.displayName, + formatSpendMetric(row.periodCost, row.periodTokens, row.currency, "tokens"), + formatSpendMetric(row.sevenDay, row.sevenDayTokens, row.currency, "tokens"), + formatSpendMetric(row.thirtyDay, row.thirtyDayTokens, row.currency, "tokens"), + row.currency || "USD", + row.source, + ], + }; +} + /** * Render the sanitized share-card PNG. * @@ -148,12 +171,16 @@ export function formatSpendMetric( * states the guarantee. Keep it that way — do not add account fields to the * drawn cells or the footer. */ -export function renderUsageSpendSharePng(summary: UsageSpendSummary, title: string): string { +export function renderUsageSpendSharePng( + summary: UsageSpendSummary, + title: string, + periodLabel: string, +): string { const rows = summary.rows; const pad = 24; const rowH = 28; const headerH = 48; - const colW = [160, 100, 100, 80, 160]; + const { headers, cellsFor, colW } = usageSpendShareColumns(periodLabel); const width = pad * 2 + colW.reduce((a, b) => a + b, 0); const height = pad * 2 + headerH + Math.max(1, rows.length) * rowH + 52; const canvas = document.createElement("canvas"); @@ -177,7 +204,6 @@ export function renderUsageSpendSharePng(summary: UsageSpendSummary, title: stri ctx.font = "12px system-ui,Segoe UI,sans-serif"; ctx.fillText("Win-CodexBar · local estimates · no account emails", pad, pad + 36); - const headers = ["Provider", "7 days", "30 days", "Currency", "Source"]; let x = pad; const y0 = pad + headerH; ctx.fillStyle = "#9fb0c8"; @@ -200,13 +226,7 @@ export function renderUsageSpendSharePng(summary: UsageSpendSummary, title: stri } else { rows.forEach((row, index) => { const y = y0 + (index + 1) * rowH; - const cells = [ - row.displayName, - formatSpendMetric(row.sevenDay, row.sevenDayTokens, row.currency, "tokens"), - formatSpendMetric(row.thirtyDay, row.thirtyDayTokens, row.currency, "tokens"), - row.currency || "USD", - row.source, - ]; + const cells = cellsFor(row); let cellX = pad; cells.forEach((cell, cellIndex) => { ctx.fillStyle = cellIndex === 0 ? "#e7ecf3" : "#c5d0e0"; @@ -250,10 +270,11 @@ export function shareUsageSpendPng( summary: UsageSpendSummary | null, title: string, filename: string, + periodLabel: string, ): string | null { if (!summary) return "UsageSpendShareEmpty"; try { - const dataUrl = renderUsageSpendSharePng(summary, title); + const dataUrl = renderUsageSpendSharePng(summary, title, periodLabel); if (!dataUrl) return "UsageSpendShareFailed"; downloadPng(dataUrl, filename); return null; diff --git a/apps/desktop-tauri/src/surfaces/TrayPanel.period.test.tsx b/apps/desktop-tauri/src/surfaces/TrayPanel.period.test.tsx new file mode 100644 index 0000000000..0f6263c089 --- /dev/null +++ b/apps/desktop-tauri/src/surfaces/TrayPanel.period.test.tsx @@ -0,0 +1,193 @@ +import { render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const tauriMocks = vi.hoisted(() => ({ + getCachedProviders: vi.fn(), + refreshProviders: vi.fn(), + refreshProvidersIfStale: vi.fn(), + getSettingsSnapshot: vi.fn(), + updateSettings: vi.fn(), + getUpdateState: vi.fn(), + checkForUpdates: vi.fn(), + downloadUpdate: vi.fn(), + applyUpdate: vi.fn(), + dismissUpdate: vi.fn(), + openReleasePage: vi.fn(), + setSurfaceMode: vi.fn(), + dismissTrayPanel: vi.fn(), + beginFlyoutGesture: vi.fn().mockResolvedValue(undefined), + endFlyoutGesture: vi.fn().mockResolvedValue(undefined), + openSettingsWindow: vi.fn(), + quitApp: vi.fn(), + getWorkAreaRect: vi.fn(), + reanchorTrayPanel: vi.fn(), + revealTrayPanelWindow: vi.fn(), + flyoutStoredSize: vi.fn().mockResolvedValue(null), + setFlyoutSize: vi.fn().mockResolvedValue(undefined), + openProviderDashboard: vi.fn(), + openProviderStatusPage: vi.fn(), + getProviderChartData: vi.fn(), + getCurrentSurfaceState: vi.fn(), + getLocaleStrings: vi.fn(), + setUiLanguage: vi.fn(), + getDeepSeekPricingStatus: vi.fn().mockResolvedValue(null), + getUsageSpendSummary: vi.fn(), + claudeReconciliationState: vi.fn().mockResolvedValue(null), +})); + +const eventMocks = vi.hoisted(() => ({ + listen: vi.fn(), +})); + +const windowMocks = vi.hoisted(() => ({ + getCurrentWindow: vi.fn(() => ({ + setSize: vi.fn().mockResolvedValue(undefined), + close: vi.fn().mockResolvedValue(undefined), + scaleFactor: vi.fn().mockResolvedValue(1), + onResized: vi.fn().mockResolvedValue(() => {}), + innerSize: vi.fn().mockResolvedValue({ width: 328, height: 200 }), + })), + LogicalSize: vi.fn((width: number, height: number) => ({ width, height })), + PhysicalSize: vi.fn((width: number, height: number) => ({ width, height })), +})); + +vi.mock("../lib/tauri", () => tauriMocks); +vi.mock("@tauri-apps/api/event", () => eventMocks); +vi.mock("@tauri-apps/api/window", () => windowMocks); + +import TrayPanel from "./TrayPanel"; +import { LocaleProvider } from "../i18n/LocaleProvider"; +import { buildBundle } from "../test/localeHarness"; +import type { ProviderUsageSnapshot, SettingsSnapshot } from "../types/bridge"; + +function rateWindow(used: number) { + return { + usedPercent: used, + remainingPercent: 100 - used, + windowMinutes: null, + resetsAt: null, + resetDescription: null, + isExhausted: false, + reservePercent: null, + reserveDescription: null, + }; +} + +const codex: ProviderUsageSnapshot = { + providerId: "codex", + displayName: "Codex", + primary: rateWindow(35), + selectedMetric: rateWindow(35), + primaryLabel: "Monthly", + secondary: null, + modelSpecific: null, + tertiary: null, + extraRateWindows: [], + cost: null, + planName: null, + accountEmail: null, + sourceLabel: "auto", + updatedAt: "2026-05-24T00:00:00Z", + error: null, + errorState: "ready", + pace: null, + accountOrganization: null, + trayStatusLabel: null, + fetchDurationMs: null, +}; + +const settings = { + enabledProviders: ["codex"], + refreshIntervalSecs: 300, + adaptiveRefresh: false, + refreshAllProvidersOnMenuOpen: false, + lowPowerMode: false, + highUsageThreshold: 70, + criticalUsageThreshold: 90, + trayIconMode: "single", + switcherShowsIcons: true, + showAsUsed: true, + resetTimeRelative: true, + overviewLayout: "detailed", + hidePersonalInfo: false, + uiLanguage: "english", + theme: "dark", + windowScalePercent: 125, + trayScalePercent: 100, + trayPanelAlwaysOnTop: false, + providerMetrics: {}, + costSummaryDisplayStyle: "compact", + costReportingPeriod: "rolling:7", + providerAccentColors: {}, +} as unknown as SettingsSnapshot; + +describe("TrayPanel History window", () => { + beforeEach(() => { + vi.clearAllMocks(); + tauriMocks.getCachedProviders.mockResolvedValue([codex]); + tauriMocks.getSettingsSnapshot.mockResolvedValue(settings); + tauriMocks.updateSettings.mockResolvedValue(settings); + tauriMocks.refreshProviders.mockResolvedValue(undefined); + tauriMocks.refreshProvidersIfStale.mockResolvedValue(undefined); + tauriMocks.dismissTrayPanel.mockResolvedValue(undefined); + tauriMocks.reanchorTrayPanel.mockResolvedValue(undefined); + tauriMocks.getWorkAreaRect.mockResolvedValue({ x: 0, y: 0, width: 1440, height: 900 }); + tauriMocks.getCurrentSurfaceState.mockResolvedValue({ + mode: "trayPanel", + target: { kind: "summary" }, + }); + tauriMocks.getUpdateState.mockResolvedValue({ + status: "idle", + version: null, + error: null, + progress: null, + releaseUrl: null, + canDownload: false, + canApply: false, + lastCheckedAt: null, + }); + tauriMocks.getProviderChartData.mockResolvedValue({ + providerId: "codex", + costHistory: [], + creditsHistory: [], + usageBreakdown: [], + localUsage: null, + }); + tauriMocks.getLocaleStrings.mockResolvedValue(buildBundle({})); + eventMocks.listen.mockResolvedValue(() => {}); + }); + + it("totals the Overview spend over the selected History window", async () => { + tauriMocks.getUsageSpendSummary.mockResolvedValue({ + contract: {}, + reportingPeriod: "rolling:7", + reportingDay: "2026-09-29", + dashboardTimezone: "UTC", + rows: [ + { + providerId: "codex", + displayName: "Codex", + sevenDay: 9, + thirtyDay: 2, + periodCost: 9, + periodTokens: 900, + currency: "USD", + source: "local", + includedInOverview: true, + }, + ], + }); + + render( + + + , + ); + + expect(await screen.findByText("OverviewSpendPeriodTitle")).toBeInTheDocument(); + expect(screen.getByText("$9.00")).toBeInTheDocument(); + expect(screen.queryByText("$2.00")).not.toBeInTheDocument(); + // The scan carries no explicit window: the backend resolves the saved one. + expect(tauriMocks.getUsageSpendSummary).toHaveBeenCalledWith(); + }); +}); diff --git a/apps/desktop-tauri/src/surfaces/TrayPanel.test.tsx b/apps/desktop-tauri/src/surfaces/TrayPanel.test.tsx index 8e156a34bf..318cc43b3c 100644 --- a/apps/desktop-tauri/src/surfaces/TrayPanel.test.tsx +++ b/apps/desktop-tauri/src/surfaces/TrayPanel.test.tsx @@ -320,6 +320,7 @@ describe("TrayPanel provider grid", () => { it("offers an Overview share snapshot using only included spend rows", async () => { tauriMocks.getUsageSpendSummary.mockResolvedValue({ contract: {}, + reportingPeriod: "rolling:30", reportingDay: "2026-09-19", dashboardTimezone: "UTC", rows: [ @@ -328,6 +329,8 @@ describe("TrayPanel provider grid", () => { displayName: "Codex", sevenDay: 1, thirtyDay: 2, + periodCost: 2, + periodTokens: null, currency: "USD", source: "local", includedInOverview: true, @@ -337,6 +340,8 @@ describe("TrayPanel provider grid", () => { displayName: "Claude", sevenDay: 3, thirtyDay: 4, + periodCost: 4, + periodTokens: null, currency: "USD", source: "hidden", includedInOverview: false, @@ -428,7 +433,9 @@ describe("TrayPanel provider grid", () => { PanelAllProviders: "すべてのプロバイダー", PanelAllProvidersShort: "すべて", PanelLatestTokens: "最新トークン", - PanelThirtyDayCost: "30日間のコスト", + CostPeriodShortDays: "{}日", + PanelPeriodCost: "{}間のコスト", + PanelPeriodTokens: "{}間のトークン", PanelTopModelPrefix: "トップモデル", PanelEstimatedFromLocalLogs: "ローカルログから推定", PanelZoom: "ズーム", @@ -446,6 +453,9 @@ describe("TrayPanel provider grid", () => { todayCost: null, thirtyDayCost: 1.23, thirtyDayTokens: 584_000, + periodCost: 1.23, + periodTokens: 584_000, + reportingPeriod: "rolling:30", latestTokens: 1200, topModel: "gpt-5.5", estimateNote: "Estimated from local logs", diff --git a/apps/desktop-tauri/src/surfaces/TrayPanel.tsx b/apps/desktop-tauri/src/surfaces/TrayPanel.tsx index ccc2e8fe2f..5f3f8c9e91 100644 --- a/apps/desktop-tauri/src/surfaces/TrayPanel.tsx +++ b/apps/desktop-tauri/src/surfaces/TrayPanel.tsx @@ -2,6 +2,7 @@ import { Fragment, useEffect, useState, type CSSProperties } from "react"; import { getCurrentWindow } from "@tauri-apps/api/window"; import type { BootstrapState, ProviderUsageSnapshot, UsageSpendSummary } from "../types/bridge"; import type { LocaleKey } from "../i18n/keys"; +import { costPeriodLabel, costPeriodShortLabel } from "../lib/costPeriod"; import { beginFlyoutGesture, getUsageSpendSummary, @@ -206,7 +207,11 @@ export default function TrayPanel({ state }: { state: BootstrapState }) { />
{selectedProviderId === null && ( - provider.providerId)} t={t} /> + provider.providerId)} + period={settings.costReportingPeriod} + t={t} + /> )}
{useWideColumns @@ -336,45 +341,59 @@ function TrayResizeHandles() { ); } -function OverviewSpendSummary({ providerIds, t }: { providerIds: string[]; t: (key: LocaleKey) => string }) { +function OverviewSpendSummary({ + providerIds, + period, + t, +}: { + providerIds: string[]; + /** Saved History window; a change rescans. Omitted means the backend default. */ + period?: string; + t: (key: LocaleKey) => string; +}) { const [summary, setSummary] = useState(null); const [shareError, setShareError] = useState(null); useEffect(() => { let cancelled = false; - void getUsageSpendSummary({ historyDays: 30 }) + // No explicit period: the backend resolves the saved History window. + void getUsageSpendSummary() .then((value) => { if (!cancelled) setSummary(value); }) .catch(() => { if (!cancelled) setSummary(null); }); return () => { cancelled = true; }; - }, [providerIds.join("|")]); + }, [providerIds.join("|"), period]); // Overview consumes the same backend spend catalog as Usage & Spend. Do not // restrict accounting to whichever cards happen to be rendered in this tray. const overviewSummary = summary ? filterUsageSpendSummaryForOverview(summary) : null; if (!overviewSummary) return null; + // The summary names the History window its period columns cover. + const summaryPeriod = overviewSummary.reportingPeriod; + const title = t("OverviewSpendPeriodTitle").replace("{}", costPeriodShortLabel(summaryPeriod, t)); const onShare = () => { setShareError(null); const error = shareUsageSpendPng( overviewSummary, - t("OverviewSpendTitle"), + title, `codexbar-overview-usage-${overviewSummary.reportingDay}.png`, + costPeriodLabel(summaryPeriod, t), ); if (error) setShareError(t(error as LocaleKey)); }; const rows = overviewSummary.rows; const summable = rows.filter((row) => (row.currency || "USD") === "USD"); - const known = summable.filter((row) => row.thirtyDay != null && Number.isFinite(row.thirtyDay)); + const known = summable.filter((row) => row.periodCost != null && Number.isFinite(row.periodCost)); if (known.length === 0) return null; - const total = known.reduce((sum, row) => sum + (row.thirtyDay ?? 0), 0); + const total = known.reduce((sum, row) => sum + (row.periodCost ?? 0), 0); const partial = known.length < rows.length; const formatter = new Intl.NumberFormat(undefined, { style: "currency", currency: "USD", maximumFractionDigits: 2 }); return (
- {t("OverviewSpendTitle")} + {title} {partial ? "~" : ""}{formatter.format(total)}
diff --git a/apps/desktop-tauri/src/surfaces/settings/tabs/CostPeriodControl.tsx b/apps/desktop-tauri/src/surfaces/settings/tabs/CostPeriodControl.tsx new file mode 100644 index 0000000000..5e0c517a2d --- /dev/null +++ b/apps/desktop-tauri/src/surfaces/settings/tabs/CostPeriodControl.tsx @@ -0,0 +1,105 @@ +import { useEffect, useState } from "react"; +import type { LocaleKey } from "../../../i18n/keys"; +import { + MAX_ROLLING_DAYS, + PRESET_COST_PERIODS, + costPeriodLabel, + customPeriodRaw, + isPresetCostPeriod, + rollingDays, +} from "../../../lib/costPeriod"; + +const CUSTOM_OPTION = "custom"; + +/** + * History window picker: month to date, all, 1/7/30/90/365 days, or a custom + * day count in 1..=365. `value` is the persisted form (`rolling:N`, + * `month-to-date`, `all`). A custom count is committed on blur or Enter, once + * it is valid, so typing a multi-digit value triggers one rescan. + */ +export default function CostPeriodControl({ + value, + onChange, + disabled = false, + t, +}: { + value: string; + onChange: (raw: string) => void; + disabled?: boolean; + t: (key: LocaleKey) => string; +}) { + const [customMode, setCustomMode] = useState(() => !isPresetCostPeriod(value)); + const [customText, setCustomText] = useState(() => String(rollingDays(value) ?? "")); + // Follow external changes (settings loaded after mount, another window). + useEffect(() => { + if (isPresetCostPeriod(value)) { + setCustomMode(false); + } else { + setCustomMode(true); + setCustomText(String(rollingDays(value) ?? "")); + } + }, [value]); + + const customInvalid = customMode && customText !== "" && customPeriodRaw(customText) === null; + const commitCustom = () => { + const raw = customPeriodRaw(customText); + if (raw && raw !== value) onChange(raw); + }; + + return ( +
+ +

{t("CostPeriodHistoryWindowHelper")}

+
+ + {customMode && ( + setCustomText(event.target.value)} + onBlur={commitCustom} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault(); + commitCustom(); + } + }} + /> + )} +
+
+ ); +} diff --git a/apps/desktop-tauri/src/surfaces/settings/tabs/UsageSpendTab.test.tsx b/apps/desktop-tauri/src/surfaces/settings/tabs/UsageSpendTab.test.tsx new file mode 100644 index 0000000000..7ed5879627 --- /dev/null +++ b/apps/desktop-tauri/src/surfaces/settings/tabs/UsageSpendTab.test.tsx @@ -0,0 +1,234 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const tauriMocks = vi.hoisted(() => ({ + getSettingsSnapshot: vi.fn(), + getUsageSpendSummary: vi.fn(), + updateSettings: vi.fn(), + writeUsageSpendExport: vi.fn(), +})); + +vi.mock("../../../lib/tauri", () => tauriMocks); +vi.mock("@tauri-apps/plugin-dialog", () => ({ save: vi.fn() })); +vi.mock("@tauri-apps/api/event", () => ({ + listen: vi.fn().mockResolvedValue(() => {}), +})); + +const EN: Record = { + CostPeriodMonthToDate: "Month to date", + CostPeriodAll: "All available history", + CostPeriodToday: "Today", + CostPeriodLastDays: "Last {} days", + CostPeriodCustom: "Custom days", + UsageSpendModelsPeriodCaption: "Models for: {}", + UsageSpendProjectsPeriodCaption: "Projects for: {}", +}; +vi.mock("../../../hooks/useLocale", () => ({ + useLocale: () => ({ t: (key: string) => EN[key] ?? key }), +})); + +import UsageSpendTab from "./UsageSpendTab"; +import type { TabProps } from "../settingsTabs"; +import type { SpendContract, UsageSpendSummary } from "../../../types/bridge"; + +function contract(reportingPeriod: string): SpendContract { + return { + providerId: "codex", + historyDays: 30, + reportingPeriod, + knownCostUsd: 12, + knownZero: false, + provenance: "listPriceEstimate", + priceCoverage: { priced: 1, unpriced: 0 }, + priceCoverageRatio: 1, + historyCoverageEstablished: true, + tokenMix: {}, + conversationCount: 1, + models: [], + projects: [], + conversations: [], + daily: [], + hourlyActivity: [], + projectSourceStatus: null, + imports: [], + customPricingActive: false, + } as unknown as SpendContract; +} + +function summary(reportingPeriod: string): UsageSpendSummary { + return { + rows: [ + { + providerId: "codex", + displayName: "Codex", + sevenDay: 1, + thirtyDay: 2, + periodCost: 9, + periodTokens: 900, + currency: "USD", + source: "local", + includedInOverview: true, + }, + ], + contract: contract(reportingPeriod), + reportingPeriod, + reportingDay: "2026-09-29", + dashboardTimezone: "UTC", + } as unknown as UsageSpendSummary; +} + +const props = {} as TabProps; + +function lastScanPeriod(): string | undefined { + const calls = tauriMocks.getUsageSpendSummary.mock.calls; + return calls[calls.length - 1]?.[0]?.period; +} + +describe("UsageSpendTab History window", () => { + beforeEach(() => { + vi.clearAllMocks(); + tauriMocks.getSettingsSnapshot.mockResolvedValue({ + costReportingPeriod: "month-to-date", + costSummaryDisplayStyle: "compact", + }); + tauriMocks.updateSettings.mockResolvedValue({}); + tauriMocks.getUsageSpendSummary.mockImplementation( + async (options?: { period?: string }) => summary(options?.period ?? "rolling:30"), + ); + }); + + it("scans once with the saved window and shows it selected", async () => { + render(); + + await waitFor(() => expect(tauriMocks.getUsageSpendSummary).toHaveBeenCalledTimes(1)); + expect(tauriMocks.getUsageSpendSummary).toHaveBeenCalledWith({ + period: "month-to-date", + forceRefresh: false, + }); + const select = screen.getByLabelText("CostPeriodHistoryWindow") as HTMLSelectElement; + expect(select.value).toBe("month-to-date"); + // The period column is labelled with the window and shows its numbers. + expect(await screen.findByRole("columnheader", { name: "Month to date" })).toBeTruthy(); + expect(screen.getByText(/900/)).toBeTruthy(); + expect(screen.getByText("Models for: Month to date")).toBeTruthy(); + }); + + it("migrates a settings payload without a saved window to 30 days", async () => { + tauriMocks.getSettingsSnapshot.mockResolvedValue({ costSummaryDisplayStyle: "compact" }); + render(); + + await waitFor(() => expect(tauriMocks.getUsageSpendSummary).toHaveBeenCalled()); + expect(lastScanPeriod()).toBe("rolling:30"); + expect((screen.getByLabelText("CostPeriodHistoryWindow") as HTMLSelectElement).value).toBe( + "rolling:30", + ); + }); + + it("falls back to 30 days when the saved window is unreadable", async () => { + tauriMocks.getSettingsSnapshot.mockResolvedValue({ + costReportingPeriod: "rolling:0", + costSummaryDisplayStyle: "compact", + }); + render(); + + await waitFor(() => expect(tauriMocks.getUsageSpendSummary).toHaveBeenCalled()); + expect(lastScanPeriod()).toBe("rolling:30"); + }); + + it("persists a preset choice and rescans with it", async () => { + render(); + await waitFor(() => expect(tauriMocks.getUsageSpendSummary).toHaveBeenCalledTimes(1)); + + fireEvent.change(screen.getByLabelText("CostPeriodHistoryWindow"), { + target: { value: "rolling:90" }, + }); + + await waitFor(() => + expect(tauriMocks.updateSettings).toHaveBeenCalledWith({ costReportingPeriod: "rolling:90" }), + ); + await waitFor(() => expect(lastScanPeriod()).toBe("rolling:90")); + expect(await screen.findByRole("columnheader", { name: "Last 90 days" })).toBeTruthy(); + }); + + it("offers a custom day count and persists one valid value on blur", async () => { + render(); + await waitFor(() => expect(tauriMocks.getUsageSpendSummary).toHaveBeenCalledTimes(1)); + + fireEvent.change(screen.getByLabelText("CostPeriodHistoryWindow"), { + target: { value: "custom" }, + }); + const input = screen.getByLabelText("CostPeriodCustomDays") as HTMLInputElement; + + fireEvent.change(input, { target: { value: "400" } }); + expect(input.getAttribute("aria-invalid")).toBe("true"); + expect(tauriMocks.updateSettings).not.toHaveBeenCalled(); + + fireEvent.change(input, { target: { value: "0" } }); + expect(tauriMocks.updateSettings).not.toHaveBeenCalled(); + + // Typing "1" on the way to "14" saves nothing and keeps the input open. + fireEvent.change(input, { target: { value: "1" } }); + expect(tauriMocks.updateSettings).not.toHaveBeenCalled(); + expect(screen.getByLabelText("CostPeriodCustomDays")).toBe(input); + + fireEvent.change(input, { target: { value: "14" } }); + expect(tauriMocks.updateSettings).not.toHaveBeenCalled(); + fireEvent.blur(input); + await waitFor(() => + expect(tauriMocks.updateSettings).toHaveBeenCalledWith({ costReportingPeriod: "rolling:14" }), + ); + expect(tauriMocks.updateSettings).toHaveBeenCalledTimes(1); + await waitFor(() => expect(lastScanPeriod()).toBe("rolling:14")); + expect(input.getAttribute("aria-invalid")).toBe("false"); + expect(await screen.findByRole("columnheader", { name: "Last 14 days" })).toBeTruthy(); + }); + + it("commits a valid custom day count on Enter", async () => { + render(); + await waitFor(() => expect(tauriMocks.getUsageSpendSummary).toHaveBeenCalledTimes(1)); + + fireEvent.change(screen.getByLabelText("CostPeriodHistoryWindow"), { + target: { value: "custom" }, + }); + const input = screen.getByLabelText("CostPeriodCustomDays") as HTMLInputElement; + fireEvent.change(input, { target: { value: "45" } }); + expect(tauriMocks.updateSettings).not.toHaveBeenCalled(); + + fireEvent.keyDown(input, { key: "Enter" }); + await waitFor(() => + expect(tauriMocks.updateSettings).toHaveBeenCalledWith({ costReportingPeriod: "rolling:45" }), + ); + expect(tauriMocks.updateSettings).toHaveBeenCalledTimes(1); + }); + + it("starts in custom mode for a saved count that is not a preset", async () => { + tauriMocks.getSettingsSnapshot.mockResolvedValue({ + costReportingPeriod: "rolling:14", + costSummaryDisplayStyle: "compact", + }); + render(); + + const input = (await screen.findByLabelText("CostPeriodCustomDays")) as HTMLInputElement; + expect(input.value).toBe("14"); + expect((screen.getByLabelText("CostPeriodHistoryWindow") as HTMLSelectElement).value).toBe( + "custom", + ); + }); + + it("reverts the picker and shows the error when saving fails", async () => { + tauriMocks.updateSettings.mockRejectedValue(new Error("Invalid cost reporting period: x")); + render(); + await waitFor(() => expect(tauriMocks.getUsageSpendSummary).toHaveBeenCalledTimes(1)); + + fireEvent.change(screen.getByLabelText("CostPeriodHistoryWindow"), { + target: { value: "all" }, + }); + + expect(await screen.findByText("Invalid cost reporting period: x")).toBeTruthy(); + await waitFor(() => + expect((screen.getByLabelText("CostPeriodHistoryWindow") as HTMLSelectElement).value).toBe( + "month-to-date", + ), + ); + }); +}); diff --git a/apps/desktop-tauri/src/surfaces/settings/tabs/UsageSpendTab.tsx b/apps/desktop-tauri/src/surfaces/settings/tabs/UsageSpendTab.tsx index 0f7cc7ee89..9433e0439c 100644 --- a/apps/desktop-tauri/src/surfaces/settings/tabs/UsageSpendTab.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/tabs/UsageSpendTab.tsx @@ -15,12 +15,21 @@ import { } from "../../../lib/usageSpendSharing"; import type { CostSummaryDisplayStyle, SettingsSnapshot, SpendContract, UsageSpendSummary } from "../../../types/bridge"; import type { LocaleKey } from "../../../i18n/keys"; +import { + DEFAULT_COST_PERIOD, + costPeriodLabel, + normalizeCostPeriod, +} from "../../../lib/costPeriod"; import type { TabProps } from "../settingsTabs"; +import CostPeriodControl from "./CostPeriodControl"; export default function UsageSpendTab(_props: TabProps) { const { t } = useLocale(); const [summary, setSummary] = useState(null); - const [selectedDays, setSelectedDays] = useState<0 | 7 | 30>(30); + // Null until the saved History window is known, so the first scan uses it + // instead of scanning the default and then rescanning. + const [period, setPeriod] = useState(null); + const [periodError, setPeriodError] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [shareError, setShareError] = useState(null); @@ -36,26 +45,43 @@ export default function UsageSpendTab(_props: TabProps) { .then((settings) => { setIncludeOpenCodex(settings.openCodexUsageLogsEnabled ?? false); setHideNativeCodex(settings.hideNativeCodexCostWhenOpenCodexPresent ?? false); + setPeriod(normalizeCostPeriod(settings.costReportingPeriod)); }) .catch(() => { - // Keep the safe default (off) if settings cannot be loaded. + // Keep the safe defaults (off, 30 days) if settings cannot be loaded. + setPeriod(DEFAULT_COST_PERIOD); }); }, []); const load = useCallback(async (forceRefresh = false, silent = false) => { + if (period === null) return; if (!silent) { setLoading(true); setError(null); } try { - const data = await getUsageSpendSummary({ historyDays: selectedDays, forceRefresh }); + const data = await getUsageSpendSummary({ period, forceRefresh }); setSummary(data); } catch (err: unknown) { if (!silent) setError(err instanceof Error ? err.message : String(err)); } finally { if (!silent) setLoading(false); } - }, [hideNativeCodex, includeOpenCodex, selectedDays]); + }, [hideNativeCodex, includeOpenCodex, period]); + + const onPeriodChange = useCallback( + (next: string) => { + const previous = period; + setPeriodError(null); + setPeriod(next); + void updateSettings({ costReportingPeriod: next }).catch((err: unknown) => { + setPeriod(previous); + // Kept apart from `error`: the rescan for the restored window clears it. + setPeriodError(err instanceof Error ? err.message : String(err)); + }); + }, + [period], + ); useEffect(() => { load(false); @@ -84,15 +110,20 @@ export default function UsageSpendTab(_props: TabProps) { }; }, [load]); + // Label the period column with the window the summary was built for, so it + // stays accurate while a rescan for a newly picked window is in flight. + const periodTitle = costPeriodLabel(summary?.reportingPeriod ?? period, t); + const onShare = useCallback(() => { setShareError(null); const error = shareUsageSpendPng( summary, t("UsageSpendTitle"), `codexbar-usage-spend-${summary?.reportingDay ?? "unknown"}.png`, + periodTitle, ); if (error) setShareError(t(error as LocaleKey)); - }, [summary, t]); + }, [summary, t, periodTitle]); const onCopyJson = useCallback(async () => { setShareError(null); @@ -168,19 +199,10 @@ export default function UsageSpendTab(_props: TabProps) {
+ {period !== null && } +
- {([7, 30, 0] as const).map((days) => ( - - ))} -