diff --git a/apps/desktop-tauri/src-tauri/src/auto_refresh.rs b/apps/desktop-tauri/src-tauri/src/auto_refresh.rs index 29ccceca0a..a427664b46 100644 --- a/apps/desktop-tauri/src-tauri/src/auto_refresh.rs +++ b/apps/desktop-tauri/src-tauri/src/auto_refresh.rs @@ -214,6 +214,10 @@ fn auto_resume_scheduled_at( } fn local_usage_provider_ids(settings: &Settings) -> Vec { + // "Track costs" off: no background local cost-log scan. + if !settings.cost_usage_enabled { + return Vec::new(); + } settings .get_enabled_provider_ids() .into_iter() @@ -459,6 +463,18 @@ mod tests { } } + #[test] + fn local_usage_refresh_skips_every_provider_when_cost_tracking_is_off() { + let settings = Settings { + cost_usage_enabled: false, + enabled_providers: ["codex".to_string(), "claude".to_string()] + .into_iter() + .collect(), + ..Default::default() + }; + assert!(local_usage_provider_ids(&settings).is_empty()); + } + #[test] fn note_menu_open_sets_recent_age() { let _guard = ADAPTIVE_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); diff --git a/apps/desktop-tauri/src-tauri/src/commands/bridge.rs b/apps/desktop-tauri/src-tauri/src/commands/bridge.rs index a76e637999..14503ee3f9 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/bridge.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/bridge.rs @@ -907,6 +907,10 @@ pub struct SettingsSnapshot { /// History window as its persisted raw form (`rolling:N`, /// `month-to-date`, `all`). cost_reporting_period: String, + cost_usage_enabled: bool, + /// Usage & Spend time range, same raw form as `cost_reporting_period`. + spend_dashboard_period: String, + spend_dashboard_hidden_source_ids: Vec, provider_accent_colors: std::collections::HashMap, } @@ -1053,6 +1057,9 @@ impl From for SettingsSnapshot { 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(), + cost_usage_enabled: settings.cost_usage_enabled, + spend_dashboard_period: settings.spend_dashboard_period.raw(), + spend_dashboard_hidden_source_ids: settings.spend_dashboard_hidden_source_ids, 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 4510d60c23..32883200a0 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/chart.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/chart.rs @@ -209,6 +209,18 @@ fn build_provider_chart_data_with_cancel( cancel: Option>, weekly_window: Option, ) -> ProviderChartData { + if !cost_tracking_enabled() { + // Track costs off: no local log scan, so no cost, token, local-usage or + // quota-window history (all read local logs). OpenAI dashboard data is + // fetched from the web and stays. + let (credits_history, usage_breakdown) = + load_openai_dashboard_chart_data(&provider_id, account_email.as_deref()); + return ProviderChartData { + credits_history, + usage_breakdown, + ..ProviderChartData::empty(provider_id) + }; + } let live_window = weekly_window.as_ref().map(rate_window_from_snapshot); let build_snapshot = || { codexbar::providers::chart::build_chart_snapshot( @@ -401,6 +413,11 @@ fn map_quota_window_history( } } +/// "Track costs" (Usage & Spend). Off means no local cost-log scan at all. +fn cost_tracking_enabled() -> bool { + codexbar::settings::Settings::load().cost_usage_enabled +} + /// The History window the desktop is configured to report. fn current_reporting_period() -> CostReportingPeriod { codexbar::settings::Settings::load().cost_reporting_period @@ -489,6 +506,9 @@ fn load_local_usage_summary_with_unknown_models( provider_id: &str, cancel: Option<&AtomicBool>, ) -> (Option, HashSet) { + if !cost_tracking_enabled() { + return (None, HashSet::new()); + } let period = current_reporting_period(); if provider_id == "muse" { let summary = if cancel.is_some_and(|flag| flag.load(Ordering::Relaxed)) { @@ -688,7 +708,7 @@ pub(crate) async fn refresh_provider_local_usage_cache(provider_ids: Vec .await .unwrap_or(summary); } - store_local_usage_summary(&provider_id, summary); + store_local_usage_summary_if_tracking(&provider_id, summary); } } @@ -704,6 +724,11 @@ fn load_local_usage_summary_cached( provider_id: &str, cancel: Option<&AtomicBool>, ) -> Option { + // Checked before the cache so an entry from before "Track costs" was + // turned off is never served. + if !cost_tracking_enabled() { + return None; + } let cache = local_usage_cache(); let period_identity = local_usage_period_identity(); if let Ok(guard) = cache.lock() @@ -723,10 +748,31 @@ fn load_local_usage_summary_cached( return None; } - store_local_usage_summary(provider_id, summary.clone()); + store_local_usage_summary_if_tracking(provider_id, summary.clone()); summary } +/// A scan can outlive a "Track costs" off switch, which clears the cache; drop +/// its result rather than refill the cache that the PowerToys pipe reads. +fn store_local_usage_summary_if_tracking( + provider_id: &str, + summary: Option, +) { + let tracking = summary.is_none() || cost_tracking_enabled(); + store_scanned_local_usage_summary(provider_id, summary, tracking); +} + +fn store_scanned_local_usage_summary( + provider_id: &str, + summary: Option, + cost_tracking_enabled: bool, +) { + if summary.is_some() && !cost_tracking_enabled { + return; + } + store_local_usage_summary(provider_id, summary); +} + fn store_local_usage_summary(provider_id: &str, summary: Option) { let period_identity = match summary.as_ref() { Some(summary) => { diff --git a/apps/desktop-tauri/src-tauri/src/commands/chart/tests.rs b/apps/desktop-tauri/src-tauri/src/commands/chart/tests.rs index 9152dfed2e..f583555700 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/chart/tests.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/chart/tests.rs @@ -227,3 +227,30 @@ fn local_usage_today_hides_known_zero_and_unknown_cost_but_keeps_tokens() { let absent = summary_for(None); assert_eq!((absent.today_cost, absent.latest_tokens), (None, None)); } + +#[test] +fn a_scan_that_finishes_after_track_costs_went_off_is_not_cached() { + use super::{cached_provider_local_usage_summary, store_scanned_local_usage_summary}; + let summary = || ProviderLocalUsageSummary { + today_cost: Some(1.25), + thirty_day_cost: Some(12.5), + thirty_day_tokens: Some(42_000), + period_cost: Some(12.5), + period_tokens: Some(42_000), + reporting_period: "month-to-date".to_string(), + latest_tokens: None, + top_model: None, + estimate_note: "estimated".to_string(), + token_cost_updated_at_ms: 1234, + incomplete_request_count: None, + }; + + store_scanned_local_usage_summary("track-off-race", Some(summary()), false); + assert!(cached_provider_local_usage_summary("track-off-race").is_none()); + + store_scanned_local_usage_summary("track-on-scan", Some(summary()), true); + assert_eq!( + cached_provider_local_usage_summary("track-on-scan").and_then(|s| s.today_cost), + Some(1.25) + ); +} diff --git a/apps/desktop-tauri/src-tauri/src/commands/preferences_transfer.rs b/apps/desktop-tauri/src-tauri/src/commands/preferences_transfer.rs index cf1c66baf7..7ccccc79a9 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/preferences_transfer.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/preferences_transfer.rs @@ -57,13 +57,20 @@ pub async fn import_preferences( let mut settings = Settings::load(); let previous_language = settings.ui_language; let previous_period = settings.cost_reporting_period; + let previous_cost_tracking = settings.cost_usage_enabled; let previous_enabled = settings.get_enabled_provider_ids(); let applied = document .apply_to(&mut settings) .map_err(|error| error.to_string())?; + if settings.cost_usage_enabled && !previous_cost_tracking { + // Turning "Track costs" on pins the bucket zone, as in Settings. + settings.pin_cost_usage_bucket_time_zone(); + } settings.save().map_err(|error| error.to_string())?; tracing::info!(applied, "imported portable preferences"); - if settings.cost_reporting_period != previous_period { + if settings.cost_reporting_period != previous_period + || settings.cost_usage_enabled != previous_cost_tracking + { // Cached local cost summaries cover the old period's window. super::clear_provider_local_usage_cache(); } diff --git a/apps/desktop-tauri/src-tauri/src/commands/settings.rs b/apps/desktop-tauri/src-tauri/src/commands/settings.rs index c1210ad131..154b7c13b9 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/settings.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/settings.rs @@ -103,6 +103,12 @@ pub struct SettingsUpdate { 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, + /// "Track costs" in Usage & Spend. + pub cost_usage_enabled: Option, + /// Usage & Spend time range, same format as `cost_reporting_period`. + pub spend_dashboard_period: Option, + /// Usage & Spend sources left out of the dashboard; stored sorted and unique. + pub spend_dashboard_hidden_source_ids: Option>, } impl SettingsUpdate { @@ -122,6 +128,7 @@ impl SettingsUpdate { || self.adaptive_refresh.is_some() || self.codex_custom_sessions_dirs.is_some() || self.cost_reporting_period.is_some() + || self.cost_usage_enabled.is_some() || self.high_usage_threshold.is_some() || self.critical_usage_threshold.is_some() || self.provider_usage_thresholds.is_some() @@ -199,6 +206,24 @@ impl SettingsUpdate { { settings.cost_reporting_period = v; } + if let Some(v) = self + .spend_dashboard_period + .as_deref() + .and_then(codexbar::cost_reporting_period::CostReportingPeriod::parse) + { + settings.spend_dashboard_period = v; + } + if let Some(ids) = self.spend_dashboard_hidden_source_ids.clone() { + settings.spend_dashboard_hidden_source_ids = + codexbar::settings::normalize_spend_dashboard_hidden_source_ids(ids); + } + if let Some(v) = self.cost_usage_enabled { + if v && !settings.cost_usage_enabled { + // Mac pins the bucket zone when cost tracking is turned on. + settings.pin_cost_usage_bucket_time_zone(); + } + settings.cost_usage_enabled = v; + } if let Some(v) = self.open_codex_usage_logs_enabled { settings.open_codex_usage_logs_enabled = v; } @@ -505,6 +530,11 @@ impl SettingsUpdate { { return Err(format!("Invalid cost reporting period: {value}")); } + if let Some(value) = self.spend_dashboard_period.as_deref() + && codexbar::cost_reporting_period::CostReportingPeriod::parse(value).is_none() + { + return Err(format!("Invalid spend dashboard period: {value}")); + } if let Some(overrides) = &self.switcher_shortcuts { settings.switcher_shortcuts = codexbar::switcher_shortcuts::normalize_overrides(overrides) @@ -616,8 +646,9 @@ 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() || patch.cost_reporting_period.is_some(); + let clear_local_usage_cache = patch.codex_custom_sessions_dirs.is_some() + || patch.cost_reporting_period.is_some() + || patch.cost_usage_enabled.is_some(); let rebuild_tray_menu = patch.rebuilds_tray_menu(); let refresh_tray_presentation = patch.refreshes_tray_presentation(); let stay_awake_changed = patch.stay_awake_enabled.is_some(); @@ -840,6 +871,66 @@ mod tests { ); } + #[test] + fn spend_dashboard_updates_are_validated_and_applied() { + use codexbar::cost_reporting_period::CostReportingPeriod; + + let mut settings = Settings::default(); + let bad: SettingsUpdate = + serde_json::from_str(r#"{"spendDashboardPeriod":"fortnight"}"#).unwrap(); + let error = bad + .apply_to(&mut settings) + .expect_err("unknown dashboard period must be rejected"); + assert_eq!(error, "Invalid spend dashboard period: fortnight"); + + let good: SettingsUpdate = serde_json::from_str( + r#"{"spendDashboardPeriod":"rolling:90","spendDashboardHiddenSourceIds":["pi"," Codex","pi"]}"#, + ) + .unwrap(); + good.apply_to(&mut settings) + .expect("valid dashboard patch applies"); + assert_eq!( + settings.spend_dashboard_period, + CostReportingPeriod::Rolling(90) + ); + // The menu / float-bar period is a separate key. + assert_eq!( + settings.cost_reporting_period, + CostReportingPeriod::Rolling(30) + ); + assert_eq!(settings.spend_dashboard_hidden_source_ids, ["codex", "pi"]); + } + + #[test] + fn turning_cost_tracking_on_pins_the_bucket_zone_and_off_keeps_it() { + let mut settings = Settings { + cost_usage_enabled: false, + cost_usage_bucket_time_zone: String::new(), + ..Settings::default() + }; + let on: SettingsUpdate = serde_json::from_str(r#"{"costUsageEnabled":true}"#).unwrap(); + assert!(on.notifies_float_bar()); + on.apply_to(&mut settings).expect("enable applies"); + assert!(settings.cost_usage_enabled); + let mut expected = Settings { + cost_usage_bucket_time_zone: String::new(), + ..Settings::default() + }; + expected.pin_cost_usage_bucket_time_zone(); + assert_eq!( + settings.cost_usage_bucket_time_zone, + expected.cost_usage_bucket_time_zone + ); + + let off: SettingsUpdate = serde_json::from_str(r#"{"costUsageEnabled":false}"#).unwrap(); + off.apply_to(&mut settings).expect("disable applies"); + assert!(!settings.cost_usage_enabled); + assert_eq!( + settings.cost_usage_bucket_time_zone, + expected.cost_usage_bucket_time_zone + ); + } + #[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 e083b05178..3fdcfb64fa 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/spend_contract.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/spend_contract.rs @@ -28,6 +28,9 @@ pub async fn get_spend_contract( let settings = Settings::load(); let period = CostReportingPeriod::resolve_request(period.as_deref(), settings.cost_reporting_period); + if !settings.cost_usage_enabled { + return SpendContract::empty(&provider, period); + } let scanner = CostScanner::for_period(period); let summary = match provider.as_str() { "codex" => scanner.scan_codex(), 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 2ade811da4..c4bc78638d 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/usage_spend.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/usage_spend.rs @@ -83,6 +83,9 @@ pub struct UsageSpendSummary { pub reporting_period: String, pub reporting_day: String, pub dashboard_timezone: String, + /// False when "Track costs" is off: nothing was scanned and the summary + /// is empty, so the pane shows "Cost tracking is off". + pub cost_tracking_enabled: bool, } #[derive(Debug, Clone)] @@ -218,10 +221,14 @@ pub async fn get_usage_spend_summary( }; // 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 settings = codexbar::settings::Settings::load(); + let period = + CostReportingPeriod::resolve_request(period.as_deref(), settings.cost_reporting_period); + // Checked before any cache read, so a summary built while tracking was on + // is never served after it is turned off. + if !settings.cost_usage_enabled { + return Ok(cost_tracking_off_summary(period)); + } let force_refresh = force_refresh.unwrap_or(false); refresh_opencodex_pricing_before_build(&cached, period, force_refresh).await; let built = tauri::async_runtime::spawn_blocking(move || { @@ -775,6 +782,22 @@ fn build_usage_spend_summary( reporting_period: period.raw(), reporting_day, dashboard_timezone, + cost_tracking_enabled: true, + } +} + +/// The summary while "Track costs" is off: no rows, no scan. +fn cost_tracking_off_summary(period: CostReportingPeriod) -> UsageSpendSummary { + UsageSpendSummary { + rows: Vec::new(), + contract: SpendContract::empty("codex", period), + reporting_period: period.raw(), + reporting_day: chrono::Local::now() + .date_naive() + .format("%Y-%m-%d") + .to_string(), + dashboard_timezone: codexbar::core::local_timezone_name(), + cost_tracking_enabled: false, } } @@ -1046,9 +1069,22 @@ mod cache_key_tests { }, reporting_day: "2026-10-01".to_string(), dashboard_timezone: "UTC".to_string(), + cost_tracking_enabled: true, } } + #[test] + fn cost_tracking_off_summary_is_empty_and_flagged() { + let summary = cost_tracking_off_summary(CostReportingPeriod::MonthToDate); + assert!(!summary.cost_tracking_enabled); + assert!(summary.rows.is_empty()); + assert_eq!(summary.reporting_period, "month-to-date"); + assert_eq!(summary.contract.known_cost_usd, None); + assert!(summary.contract.daily.is_empty()); + let json = serde_json::to_value(&summary).expect("serialize"); + assert_eq!(json["costTrackingEnabled"], serde_json::json!(false)); + } + #[test] fn only_a_summary_rebuild_may_refresh_opencodex_pricing() { let mut coordinator = UsageSpendCoordinator::default(); diff --git a/apps/desktop-tauri/src/components/MenuCard.tsx b/apps/desktop-tauri/src/components/MenuCard.tsx index 6596f00f32..01fb2edb3b 100644 --- a/apps/desktop-tauri/src/components/MenuCard.tsx +++ b/apps/desktop-tauri/src/components/MenuCard.tsx @@ -40,6 +40,8 @@ export interface MenuCardDisplayOptions { quotaWarningMarkersVisible?: boolean; weeklyProgressWorkDays?: number | null; workdayTickStyle?: WorkdayTickStyle; + /** Settings "Track costs"; a change re-reads the chart so local estimates follow it. */ + costTrackingEnabled?: boolean; } interface MenuCardProps { @@ -152,6 +154,7 @@ export default function MenuCard({ quotaWarningMarkersVisible = true, weeklyProgressWorkDays = null, workdayTickStyle = "subtle", + costTrackingEnabled = true, } = display; const { t, language } = useLocale(); const [chartData, setChartData] = useState(null); @@ -181,7 +184,8 @@ export default function MenuCard({ return () => { cancelled = true; }; - }, [provider.providerId, provider.accountEmail, localDay, onLayoutChange]); + // "Track costs" flips local estimates on or off; the backend answers for the new value. + }, [provider.providerId, provider.accountEmail, localDay, costTrackingEnabled, onLayoutChange]); const isWayfinder = provider.providerId === "wayfinder"; const email = isWayfinder ? null : headerAccount(provider, hideEmail); diff --git a/apps/desktop-tauri/src/floatbar/FloatBar.tsx b/apps/desktop-tauri/src/floatbar/FloatBar.tsx index dd31f17ced..5bfdc0bd39 100644 --- a/apps/desktop-tauri/src/floatbar/FloatBar.tsx +++ b/apps/desktop-tauri/src/floatbar/FloatBar.tsx @@ -440,8 +440,8 @@ export default function FloatBar({ state }: { state: BootstrapState }) { return () => { cancelled = true; }; - // A History window change re-reads the local usage summaries. - }, [visibleCostTargets, settings.costReportingPeriod]); + // A History window or "Track costs" change re-reads the local usage summaries. + }, [visibleCostTargets, settings.costReportingPeriod, settings.costUsageEnabled]); const visibleCosts = visible .map((provider) => localCosts[providerCostKey(provider)]) diff --git a/apps/desktop-tauri/src/i18n/keys.ts b/apps/desktop-tauri/src/i18n/keys.ts index 5a980cd436..1e815c9bce 100644 --- a/apps/desktop-tauri/src/i18n/keys.ts +++ b/apps/desktop-tauri/src/i18n/keys.ts @@ -1547,6 +1547,30 @@ export const ALL_LOCALE_KEYS = [ "MenuContentSectionTitle", "MenuCostSummarySectionTitle", "MenuWindowsSectionTitle", + "UsageSpendPeriodLabel", + "UsageSpendRefreshFailures", + "UsageSpendLocalHistory", + "UsageSpendEstimatedSpend", + "UsageSpendTrackedTokens", + "UsageSpendSources", + "UsageSpendTokenInput", + "UsageSpendTokenOutput", + "UsageSpendTokenCacheRead", + "UsageSpendTokenCacheWrite", + "UsageSpendTokenReasoning", + "UsageSpendPriced", + "UsageSpendUnpricedCount", + "UsageSpendUnmetered", + "UsageSpendEstimated", + "UsageSpendBreakdownLabel", + "UsageSpendViewProviders", + "UsageSpendViewSessions", + "UsageSpendNoSessions", + "UsageSpendDataControls", + "UsageSpendTrackCosts", + "UsageSpendTrackCostsHelper", + "UsageSpendTrackingOffTitle", + "UsageSpendTrackingOffBody", ] as const; export type LocaleKey = (typeof ALL_LOCALE_KEYS)[number]; diff --git a/apps/desktop-tauri/src/lib/usageSpendBreakdown.ts b/apps/desktop-tauri/src/lib/usageSpendBreakdown.ts index 227ca228a9..6937a00ef5 100644 --- a/apps/desktop-tauri/src/lib/usageSpendBreakdown.ts +++ b/apps/desktop-tauri/src/lib/usageSpendBreakdown.ts @@ -1,4 +1,4 @@ -import type { SpendContract, SpendDailyPoint, SpendModelRow } from "../types/bridge"; +import type { SpendContract, SpendDailyPoint, SpendModelRow, UsageSpendRow } from "../types/bridge"; /** Upstream 0.64.0 (#3353) `spendProviderModelDisplayLimit`. */ export const PROVIDER_MODEL_DISPLAY_LIMIT = 6; @@ -14,6 +14,10 @@ export interface SpendProviderGroup { /** True when model history is incomplete, so the model list is a partial view. */ hasPartialModelHistory: boolean; models: SpendModelRow[]; + /** Currency of `costUsd`; USD when absent. */ + currency?: string; + /** The summary row behind this group (Providers view), for its 7 and 30 day detail. */ + row?: UsageSpendRow; } /** @@ -59,6 +63,34 @@ export function buildSpendProviderGroups( return [group].sort(compareSpendProviderGroups); } +/** + * Providers view: one group per visible summary row with its selected-window + * cost and tokens. The detail contract's models nest under its provider; other + * providers have no model-level history yet (cross-provider contracts are a + * backend follow-up). + */ +export function buildSpendProviderRowGroups( + rows: readonly UsageSpendRow[], + contract: SpendContract, +): SpendProviderGroup[] { + return rows + .map((row): SpendProviderGroup => { + const isContract = row.providerId === contract.providerId; + return { + providerId: row.providerId, + displayName: row.displayName, + costUsd: row.periodCost, + totalTokens: row.periodTokens, + costIsPartial: isContract && contract.priceCoverage.unpriced > 0, + hasPartialModelHistory: isContract && contract.models.length > 0 && !contract.historyCoverageEstablished, + models: isContract ? contract.models : [], + currency: row.currency || "USD", + row, + }; + }) + .sort(compareSpendProviderGroups); +} + /** The daily point for `day`, or null when that day is not in the current range. */ export function findSpendDay(daily: SpendDailyPoint[], day: string | null): SpendDailyPoint | null { if (!day) return null; diff --git a/apps/desktop-tauri/src/lib/usageSpendDashboard.test.ts b/apps/desktop-tauri/src/lib/usageSpendDashboard.test.ts new file mode 100644 index 0000000000..3d808021bc --- /dev/null +++ b/apps/desktop-tauri/src/lib/usageSpendDashboard.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it } from "vitest"; +import type { SpendContract, UsageSpendRow, UsageSpendSummary } from "../types/bridge"; +import { + formatCompactTokens, + normalizeHiddenSourceIds, + spendSources, + spendTotalsByCurrency, + toggleHiddenSource, + visibleUsageSpendSummary, +} from "./usageSpendDashboard"; + +function row(providerId: string, periodCost: number | null, periodTokens: number | null, currency = "USD"): UsageSpendRow { + return { + providerId, + displayName: providerId[0].toUpperCase() + providerId.slice(1), + sevenDay: null, + thirtyDay: null, + periodCost, + periodTokens, + currency, + source: "local", + includedInOverview: true, + }; +} + +function codexContract(): SpendContract { + return { + providerId: "codex", + historyDays: 30, + reportingPeriod: "rolling:30", + knownCostUsd: 12, + knownZero: false, + provenance: "listPriceEstimate", + priceCoverage: { priced: 4, unpriced: 0, unmetered: 0, estimated: 0 }, + priceCoverageRatio: 1, + historyCoverageEstablished: true, + tokenMix: { inputTokens: 100, outputTokens: 20, cacheReadTokens: 50, cacheCreationTokens: null, reasoningTokens: 5 }, + conversationCount: 2, + models: [{ model: "gpt-5", costUsd: 12, inputTokens: 100, outputTokens: 20, cacheReadTokens: 50, totalTokens: 170, customPricing: false }], + projects: [], + conversations: [], + daily: [], + hourlyActivity: [], + projectSourceStatus: null, + customPricingActive: false, + imports: [], + } as SpendContract; +} + +function summary(rows: UsageSpendRow[]): UsageSpendSummary { + return { rows, contract: codexContract(), reportingPeriod: "rolling:30", reportingDay: "2026-10-11", dashboardTimezone: "UTC" }; +} + +describe("spendTotalsByCurrency", () => { + it("sums cost, tokens and sources per currency with USD first", () => { + const totals = spendTotalsByCurrency([ + row("codex", 25.42, 18_000_000), + row("claude", 1.99, 695_000), + row("zai", 3, 100, "cny"), + row("cursor", null, 1_600_000), + ]); + expect(totals.map((entry) => entry.currency)).toEqual(["USD", "CNY"]); + expect(totals[0].cost).toBeCloseTo(27.41, 5); + expect(totals[0].tokens).toBe(20_295_000); + expect(totals[0].sources).toBe(3); + expect(totals[1]).toEqual({ currency: "CNY", cost: 3, tokens: 100, sources: 1 }); + }); + + it("keeps an unknown total unknown instead of zero", () => { + const [usd] = spendTotalsByCurrency([row("cursor", null, null)]); + expect(usd).toEqual({ currency: "USD", cost: null, tokens: null, sources: 1 }); + }); +}); + +describe("hidden sources", () => { + it("normalizes ids the way the backend stores them", () => { + expect(normalizeHiddenSourceIds([" Pi ", "claude", "", "CLAUDE"])).toEqual(["claude", "pi"]); + expect(toggleHiddenSource(["pi"], "Claude", false)).toEqual(["claude", "pi"]); + expect(toggleHiddenSource(["claude", "pi"], "claude", true)).toEqual(["pi"]); + }); + + it("drops hidden rows and blanks the detail contract when its provider is hidden", () => { + const data = summary([row("codex", 25, 18), row("claude", 2, 7)]); + + const noClaude = visibleUsageSpendSummary(data, ["claude"]); + expect(noClaude.rows.map((entry) => entry.providerId)).toEqual(["codex"]); + expect(noClaude.contract.models).toHaveLength(1); + + const noCodex = visibleUsageSpendSummary(data, ["codex"]); + expect(noCodex.rows.map((entry) => entry.providerId)).toEqual(["claude"]); + expect(noCodex.contract.providerId).toBe("codex"); + expect(noCodex.contract.models).toEqual([]); + expect(noCodex.contract.knownCostUsd).toBeNull(); + expect(noCodex.contract.tokenMix.inputTokens).toBeNull(); + expect(noCodex.contract.reportingPeriod).toBe("rolling:30"); + }); + + it("lists every returned source plus hidden ones without a row", () => { + const data = summary([row("codex", 1, 1), row("claude", 1, 1)]); + expect(spendSources(data, ["claude", "pi"]).map((source) => source.id)).toEqual(["claude", "codex", "pi"]); + }); +}); + +describe("formatCompactTokens", () => { + it("abbreviates large counts and marks unknown", () => { + expect(formatCompactTokens(20_295_000)).toBe("20.3M"); + expect(formatCompactTokens(106_000)).toBe("106K"); + expect(formatCompactTokens(null)).toBe("—"); + }); +}); diff --git a/apps/desktop-tauri/src/lib/usageSpendDashboard.ts b/apps/desktop-tauri/src/lib/usageSpendDashboard.ts new file mode 100644 index 0000000000..c68d305485 --- /dev/null +++ b/apps/desktop-tauri/src/lib/usageSpendDashboard.ts @@ -0,0 +1,141 @@ +import type { SpendContract, UsageSpendRow, UsageSpendSummary } from "../types/bridge"; + +/** + * Settings > Usage & Spend dashboard helpers. Hidden sources are a view + * filter: the backend still returns every row, and the pane, Copy JSON, + * Export JSON and Share Stats all read the summary this module filters. + */ + +/** Period segments in the order the macOS pane shows them. */ +export const SPEND_DASHBOARD_PERIODS = ["rolling:7", "rolling:30", "rolling:90", "month-to-date", "all"] as const; + +/** Mirrors `normalize_spend_dashboard_hidden_source_ids`: trimmed, lowercase, non-empty, sorted, unique. */ +export function normalizeHiddenSourceIds(ids: Iterable): string[] { + const normalized = new Set(); + for (const id of ids) { + const value = id.trim().toLowerCase(); + if (value) normalized.add(value); + } + return [...normalized].sort(); +} + +/** The hidden list after showing (`visible`) or hiding one source. */ +export function toggleHiddenSource(hidden: readonly string[], sourceId: string, visible: boolean): string[] { + const id = sourceId.trim().toLowerCase(); + const rest = hidden.filter((value) => value.trim().toLowerCase() !== id); + return normalizeHiddenSourceIds(visible ? rest : [...rest, id]); +} + +export interface SpendSource { + id: string; + displayName: string; +} + +/** + * Every source the Sources filter lists: each row the backend returned, plus + * hidden ids that have no row this scan, so a hidden source can always be + * shown again. + */ +export function spendSources(summary: UsageSpendSummary | null, hidden: readonly string[]): SpendSource[] { + const sources = new Map(); + for (const row of summary?.rows ?? []) { + const id = row.providerId.trim().toLowerCase(); + if (id && !sources.has(id)) sources.set(id, { id, displayName: row.displayName }); + } + for (const id of normalizeHiddenSourceIds(hidden)) { + if (!sources.has(id)) sources.set(id, { id, displayName: id }); + } + return [...sources.values()].sort((a, b) => a.displayName.localeCompare(b.displayName)); +} + +/** TS mirror of `SpendContract::empty`: nothing scanned for this provider and window. */ +export function emptySpendContract(providerId: string, contract: SpendContract): SpendContract { + return { + providerId, + historyDays: contract.historyDays, + reportingPeriod: contract.reportingPeriod, + knownCostUsd: null, + knownZero: false, + provenance: "unknown", + priceCoverage: { priced: 0, unpriced: 0, unmetered: 0, estimated: 0 }, + priceCoverageRatio: null, + historyCoverageEstablished: false, + tokenMix: { + inputTokens: null, + outputTokens: null, + cacheReadTokens: null, + cacheCreationTokens: null, + reasoningTokens: null, + }, + conversationCount: 0, + models: [], + projects: [], + conversations: [], + daily: [], + hourlyActivity: [], + projectSourceStatus: null, + customPricingActive: false, + imports: [], + }; +} + +/** True when the detail contract's provider is one of the hidden sources. */ +export function isContractHidden(summary: UsageSpendSummary, hidden: readonly string[]): boolean { + const ids = new Set(normalizeHiddenSourceIds(hidden)); + return ids.has(summary.contract.providerId.trim().toLowerCase()); +} + +/** + * The summary the pane shows and exports: hidden rows are dropped, and when + * the detail contract belongs to a hidden source it is replaced by an empty + * one so its tokens, models, projects and sessions do not leak. + */ +export function visibleUsageSpendSummary(summary: UsageSpendSummary, hidden: readonly string[]): UsageSpendSummary { + const ids = new Set(normalizeHiddenSourceIds(hidden)); + if (ids.size === 0) return summary; + const rows = summary.rows.filter((row) => !ids.has(row.providerId.trim().toLowerCase())); + const contract = isContractHidden(summary, hidden) + ? emptySpendContract(summary.contract.providerId, summary.contract) + : summary.contract; + return { ...summary, rows, contract }; +} + +export interface CurrencyTotals { + currency: string; + /** Sum of the known period costs; null when no row in this currency has one. */ + cost: number | null; + /** Sum of the known period tokens; null when no row in this currency has a count. */ + tokens: number | null; + /** Rows (sources) reporting in this currency. */ + sources: number; +} + +/** Stat-strip totals over the selected window, one entry per currency (USD first). */ +export function spendTotalsByCurrency(rows: readonly UsageSpendRow[]): CurrencyTotals[] { + const byCurrency = new Map(); + for (const row of rows) { + const currency = (row.currency || "USD").toUpperCase(); + const totals = byCurrency.get(currency) ?? { currency, cost: null, tokens: null, sources: 0 }; + totals.sources += 1; + if (row.periodCost != null && Number.isFinite(row.periodCost)) { + totals.cost = (totals.cost ?? 0) + row.periodCost; + } + if (row.periodTokens != null && Number.isFinite(row.periodTokens) && row.periodTokens >= 0) { + totals.tokens = (totals.tokens ?? 0) + row.periodTokens; + } + byCurrency.set(currency, totals); + } + return [...byCurrency.values()].sort((a, b) => { + if (a.currency === "USD") return -1; + if (b.currency === "USD") return 1; + return a.currency.localeCompare(b.currency); + }); +} + +const compactFormatter = new Intl.NumberFormat("en-US", { notation: "compact", maximumFractionDigits: 1 }); + +/** "20M", "106K", "—" for unknown. Matches the macOS pane's compact token figures. */ +export function formatCompactTokens(value: number | null | undefined): string { + if (value == null || !Number.isFinite(value) || value < 0) return "—"; + return compactFormatter.format(value); +} diff --git a/apps/desktop-tauri/src/surfaces/TrayPanel.test.tsx b/apps/desktop-tauri/src/surfaces/TrayPanel.test.tsx index 1bbcbb5f94..66f7624460 100644 --- a/apps/desktop-tauri/src/surfaces/TrayPanel.test.tsx +++ b/apps/desktop-tauri/src/surfaces/TrayPanel.test.tsx @@ -431,6 +431,90 @@ describe("TrayPanel provider grid", () => { expect(screen.queryByRole("button", { name: "UsageSpendShare" })).not.toBeInTheDocument(); }); + it("hides overview spend when Track costs is off", async () => { + tauriMocks.getUsageSpendSummary.mockResolvedValue({ + contract: {}, + reportingPeriod: "rolling:30", + reportingDay: "2026-09-19", + dashboardTimezone: "UTC", + costTrackingEnabled: false, + rows: [ + { + providerId: "codex", + displayName: "Codex", + sevenDay: 1, + thirtyDay: 2, + periodCost: 2, + periodTokens: null, + currency: "USD", + source: "local", + includedInOverview: true, + }, + ], + }); + + const { container } = renderTrayPanel([provider("codex", "Codex", 35)]); + + await waitFor(() => { + expect(container.querySelector(".tray-panel-reveal--ready")).not.toBeNull(); + }); + await waitFor(() => expect(tauriMocks.getUsageSpendSummary).toHaveBeenCalled()); + await Promise.resolve(); + expect(screen.queryByText(/providers have spend/)).not.toBeInTheDocument(); + expect(container.textContent).not.toMatch(/\$2\.00/); + }); + + it("follows Track costs live: off hides Overview spend and re-reads the cards, on restores them", async () => { + const onSummary = { + contract: {}, + reportingPeriod: "rolling:30", + reportingDay: "2026-09-19", + dashboardTimezone: "UTC", + costTrackingEnabled: true, + rows: [ + { + providerId: "codex", + displayName: "Codex", + sevenDay: 1, + thirtyDay: 2, + periodCost: 2, + periodTokens: null, + currency: "USD", + source: "local", + includedInOverview: true, + }, + ], + }; + tauriMocks.getUsageSpendSummary.mockResolvedValue(onSummary); + const { container } = renderTrayPanel([provider("codex", "Codex", 35)], { costUsageEnabled: true }); + const total = new Intl.NumberFormat(undefined, { + style: "currency", + currency: "USD", + maximumFractionDigits: 2, + }).format(2); + const shown = () => + /providers have spend/.test(container.textContent ?? "") + && [...container.querySelectorAll("strong")].some((e) => e.textContent?.includes(total)); + + await waitFor(() => expect(shown()).toBe(true)); + await waitFor(() => expect(tauriMocks.getProviderChartData).toHaveBeenCalledTimes(1)); + const summaryCalls = tauriMocks.getUsageSpendSummary.mock.calls.length; + + // Track costs turned off in the Settings window: the flyout only hears settings-changed. + tauriMocks.getSettingsSnapshot.mockResolvedValue(settings({ costUsageEnabled: false })); + await act(async () => emitEvent("settings-changed", null)); + await waitFor(() => expect(shown()).toBe(false)); + await waitFor(() => expect(tauriMocks.getProviderChartData).toHaveBeenCalledTimes(2)); + expect(tauriMocks.getUsageSpendSummary.mock.calls.length).toBe(summaryCalls); + + // Turned on again: the summary and the card chart are read again. + tauriMocks.getSettingsSnapshot.mockResolvedValue(settings({ costUsageEnabled: true })); + await act(async () => emitEvent("settings-changed", null)); + await waitFor(() => expect(shown()).toBe(true)); + await waitFor(() => expect(tauriMocks.getProviderChartData).toHaveBeenCalledTimes(3)); + expect(tauriMocks.getUsageSpendSummary.mock.calls.length).toBe(summaryCalls + 1); + }); + it("dismisses the tray panel on unmodified Escape", async () => { const { container } = renderTrayPanel([provider("claude", "Claude", 35)]); diff --git a/apps/desktop-tauri/src/surfaces/TrayPanel.tsx b/apps/desktop-tauri/src/surfaces/TrayPanel.tsx index aea467b0f1..a185f9b0a4 100644 --- a/apps/desktop-tauri/src/surfaces/TrayPanel.tsx +++ b/apps/desktop-tauri/src/surfaces/TrayPanel.tsx @@ -93,6 +93,7 @@ export default function TrayPanel({ state }: { state: BootstrapState }) { quotaWarningMarkersVisible: settings.quotaWarningMarkersVisible ?? true, weeklyProgressWorkDays: settings.weeklyProgressWorkDays ?? null, workdayTickStyle: settings.workdayTickStyle ?? "subtle", + costTrackingEnabled: settings.costUsageEnabled ?? true, }} accentColor={settings.providerAccentColors[p.providerId]} onLayoutChange={requestLayout} @@ -144,6 +145,7 @@ export default function TrayPanel({ state }: { state: BootstrapState }) { provider.providerId)} period={settings.costReportingPeriod} + costTrackingEnabled={settings.costUsageEnabled ?? true} t={t} /> )} @@ -182,11 +184,14 @@ export function splitSpendTitle( function OverviewSpendSummary({ providerIds, period, + costTrackingEnabled = true, t, }: { providerIds: string[]; /** Saved History window; a change rescans. Omitted means the backend default. */ period?: string; + /** Settings "Track costs"; a change re-reads the summary (off hides it). */ + costTrackingEnabled?: boolean; t: (key: LocaleKey) => string; }) { const { preferredCode, rates } = useCurrency(); @@ -194,16 +199,23 @@ function OverviewSpendSummary({ useEffect(() => { let cancelled = false; + if (!costTrackingEnabled) { + setSummary(null); + return () => { cancelled = true; }; + } // 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("|"), period]); + }, [providerIds.join("|"), period, costTrackingEnabled]); // 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; + // Track costs off: no local estimates anywhere, so no Overview spend either. + const overviewSummary = summary && summary.costTrackingEnabled !== false + ? filterUsageSpendSummaryForOverview(summary) + : null; if (!overviewSummary) return null; // The summary names the History window its period columns cover. diff --git a/apps/desktop-tauri/src/surfaces/settings/settings-layout.css b/apps/desktop-tauri/src/surfaces/settings/settings-layout.css index f31e27ba98..4ddd665f0c 100644 --- a/apps/desktop-tauri/src/surfaces/settings/settings-layout.css +++ b/apps/desktop-tauri/src/surfaces/settings/settings-layout.css @@ -856,3 +856,179 @@ .cost-period-control .number-input { width: 72px; } + +/* ── Usage & Spend (macOS pane layout) ─────────────────────────────── */ +.usage-spend__header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; +} + +.usage-spend__refresh { + flex: none; +} + +.usage-spend__segmented { + display: flex; + width: 100%; + margin: 4px 0 16px; +} + +.usage-spend__segmented > .provider-detail-segmented__option { + flex: 1 1 0; + min-width: max-content; + font-size: 13px; +} + +.usage-spend__failures { + display: flex; + flex-direction: column; + gap: 2px; + margin: 0 0 12px; + padding: 8px 12px; + border: 1px solid var(--provider-status-error); + border-radius: 6px; + font-size: 12px; + color: var(--text-primary); +} + +.usage-spend__failures strong { + color: var(--provider-status-error); +} + +.usage-spend__off { + margin: 8px 0 20px; + padding: 16px 12px; + border: 1px solid var(--panel-border); + border-radius: 6px; +} + +.usage-spend__off p { + margin: 4px 0 0; +} + +.usage-spend__currency + .usage-spend__currency { + margin-top: 16px; +} + +.usage-spend__currency-code { + margin: 0; + font-size: 13px; + font-weight: 650; +} + +.usage-spend__strip, +.usage-spend__mix { + display: grid; + gap: 12px; + margin: 8px 0 0; +} + +.usage-spend__strip { + grid-template-columns: repeat(3, minmax(0, 1fr)); + padding-bottom: 16px; + border-bottom: 1px solid var(--panel-border); +} + +.usage-spend__mix { + grid-template-columns: repeat(4, minmax(0, 1fr)); + margin-top: 16px; +} + +.usage-spend__stat { + min-width: 0; +} + +.usage-spend__stat dt { + font-size: 12px; + color: var(--text-secondary); +} + +.usage-spend__stat dd { + margin: 2px 0 0; + font-size: 13px; + font-weight: 600; + font-variant-numeric: tabular-nums; + color: var(--text-primary); +} + +.usage-spend__stat--large dd { + font-size: 20px; + font-weight: 650; +} + +.usage-spend__coverage { + margin-top: 12px; +} + +.usage-spend__card { + margin-top: 16px; + padding: 12px; + border: 1px solid var(--panel-border); + border-radius: 6px; + background: var(--surface-elevated); +} + +.usage-spend__card > .usage-spend__segmented { + margin: 0 0 12px; +} + +.usage-spend-provider__detail { + display: block; + margin: 4px 0 0; +} + +.usage-spend__row-button { + width: 100%; + display: grid; + grid-template-columns: minmax(0, 1fr) auto auto; + gap: 12px; + align-items: center; + border: 0; + padding: 0; + background: transparent; + color: inherit; + text-align: left; + cursor: pointer; +} + +.usage-spend__controls { + margin-top: 20px; +} + +.usage-spend__controls > summary { + margin: 0 0 8px; + font-size: 13px; + font-weight: 650; + color: var(--text-primary); + cursor: pointer; +} + +.usage-spend__sources { + display: flex; + flex-direction: column; + gap: 6px; + margin: 0; + padding: 10px 0; + border: 0; + border-top: 1px solid var(--panel-border); +} + +.usage-spend__sources legend { + float: left; + width: 100%; + margin-bottom: 4px; + padding: 0; +} + +.usage-spend__exports { + display: flex; + flex-wrap: wrap; + gap: 8px; + padding: 10px 0; +} + +.usage-spend__share { + margin-left: auto; +} diff --git a/apps/desktop-tauri/src/surfaces/settings/tabs/UsageSpendProviderGroups.tsx b/apps/desktop-tauri/src/surfaces/settings/tabs/UsageSpendProviderGroups.tsx index f1ab9a4157..0f58ce8169 100644 --- a/apps/desktop-tauri/src/surfaces/settings/tabs/UsageSpendProviderGroups.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/tabs/UsageSpendProviderGroups.tsx @@ -1,5 +1,5 @@ import { useState } from "react"; -import { formatUsd } from "../../../lib/usageSpendSharing"; +import { formatSpendMetric, formatUsd } from "../../../lib/usageSpendSharing"; import { PROVIDER_MODEL_DISPLAY_LIMIT, type SpendProviderGroup, @@ -11,13 +11,15 @@ interface Props { /** Names the History window the model rows cover. */ caption: string; t: (key: LocaleKey) => string; + /** False inside the Providers view, where the picker already names the list. */ + showHeading?: boolean; } /** * Upstream 0.64.0 (#3353): models grouped by provider, one collapsible group * per provider with its spend and its models. */ -export function UsageSpendProviderGroups({ groups, caption, t }: Props) { +export function UsageSpendProviderGroups({ groups, caption, t, showHeading = true }: Props) { const [collapsed, setCollapsed] = useState>(() => new Set()); const [expandedModels, setExpandedModels] = useState>(() => new Set()); const toggle = (set: Set, id: string) => { @@ -28,8 +30,8 @@ export function UsageSpendProviderGroups({ groups, caption, t }: Props) { }; return ( -
-

{t("UsageSpendModels")}

+
+ {showHeading &&

{t("UsageSpendModels")}

}

{caption}

{groups.length === 0 ? (

{t("UsageSpendNoModels")}

@@ -61,10 +63,14 @@ export function UsageSpendProviderGroups({ groups, caption, t }: Props) { > {group.displayName} {groupMetric(group, t("UsageSpendTokens"))} - + - {!isCollapsed && ( + {group.row && } + {!isCollapsed && group.models.length > 0 && (
+ {!showHeading && ( + {t("UsageSpendModels")} + )} {group.hasPartialModelHistory && ( {t("UsageSpendPartialModelBreakdown")} )} @@ -101,9 +107,28 @@ export function UsageSpendProviderGroups({ groups, caption, t }: Props) { ); } +/** Windows detail kept from the old provider table: the fixed 7 and 30 day values and the source. */ +function RowDetail({ row, t }: { row: NonNullable; t: (key: LocaleKey) => string }) { + const tokens = t("UsageSpendTokens"); + const known = t("UsageSpendKnownSubtotal"); + const currency = row.currency || "USD"; + const sevenDay = formatSpendMetric(row.sevenDay, row.sevenDayTokens, currency, tokens, row.sevenDayEstimate?.knownSubtotalUsd, known, row.sevenDayTokensLowerBound); + const thirtyDay = formatSpendMetric(row.thirtyDay, row.thirtyDayTokens, currency, tokens, row.thirtyDayEstimate?.knownSubtotalUsd, known, row.thirtyDayTokensLowerBound); + return ( + + {t("UsageSpendCol7d")}: {sevenDay} · {t("UsageSpendCol30d")}: {thirtyDay} · {row.source} + {row.refreshing && ( + + {" · "}{t("UsageSpendRefreshing")} + + )} + + ); +} + function groupMetric(group: SpendProviderGroup, tokenLabel: string): string { const parts: string[] = []; - if (group.costUsd != null) parts.push(`${group.costIsPartial ? "~" : ""}${formatUsd(group.costUsd, "USD")}`); + if (group.costUsd != null) parts.push(`${group.costIsPartial ? "~" : ""}${formatUsd(group.costUsd, group.currency ?? "USD")}`); if (group.totalTokens != null) parts.push(`${group.totalTokens.toLocaleString()} ${tokenLabel}`); return parts.length > 0 ? parts.join(" · ") : "—"; } diff --git a/apps/desktop-tauri/src/surfaces/settings/tabs/UsageSpendTab.test.tsx b/apps/desktop-tauri/src/surfaces/settings/tabs/UsageSpendTab.test.tsx index ca5661f249..d74d1f197a 100644 --- a/apps/desktop-tauri/src/surfaces/settings/tabs/UsageSpendTab.test.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/tabs/UsageSpendTab.test.tsx @@ -1,10 +1,8 @@ -import { render, screen, waitFor } from "@testing-library/react"; +import { fireEvent, render, screen, waitFor, within } 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(), })); @@ -19,9 +17,17 @@ const EN: Record = { CostPeriodAll: "All available history", CostPeriodToday: "Today", CostPeriodLastDays: "Last {} days", - CostPeriodCustom: "Custom days", + CostPeriodShortAll: "All", + CostPeriodShortDays: "{}d", UsageSpendModelsPeriodCaption: "Models for: {}", UsageSpendProjectsPeriodCaption: "Projects for: {}", + UsageSpendEstimatedSpend: "Estimated spend", + UsageSpendTrackedTokens: "Tracked tokens", + UsageSpendSources: "Sources", + UsageSpendTrackCosts: "Track costs", + UsageSpendTrackingOffTitle: "Cost tracking is off", + UsageSpendCopyJson: "Copy JSON", + UsageSpendPeriodLabel: "Time range", }; vi.mock("../../../hooks/useLocale", () => ({ useLocale: () => ({ t: (key: string) => EN[key] ?? key }), @@ -29,7 +35,7 @@ vi.mock("../../../hooks/useLocale", () => ({ import UsageSpendTab from "./UsageSpendTab"; import type { TabProps } from "../settingsTabs"; -import type { SpendContract, UsageSpendSummary } from "../../../types/bridge"; +import type { SettingsSnapshot, SpendContract, UsageSpendRow, UsageSpendSummary } from "../../../types/bridge"; function contract(reportingPeriod: string): SpendContract { return { @@ -39,12 +45,12 @@ function contract(reportingPeriod: string): SpendContract { knownCostUsd: 12, knownZero: false, provenance: "listPriceEstimate", - priceCoverage: { priced: 1, unpriced: 0 }, + priceCoverage: { priced: 1, unpriced: 0, unmetered: 0, estimated: 0 }, priceCoverageRatio: 1, historyCoverageEstablished: true, - tokenMix: {}, + tokenMix: { inputTokens: 700, outputTokens: 200, cacheReadTokens: null, cacheCreationTokens: null, reasoningTokens: null }, conversationCount: 1, - models: [], + models: [{ model: "gpt-5", costUsd: 9, inputTokens: 700, outputTokens: 200, cacheReadTokens: 0, totalTokens: 900, customPricing: false }], projects: [], conversations: [], daily: [], @@ -52,90 +58,158 @@ function contract(reportingPeriod: string): SpendContract { projectSourceStatus: null, imports: [], customPricingActive: false, - } as unknown as SpendContract; + } as SpendContract; } -function summary(reportingPeriod: string): UsageSpendSummary { +function row(providerId: string, displayName: string, periodCost: number | null, periodTokens: number | null, currency = "USD"): UsageSpendRow { return { - rows: [ - { - providerId: "codex", - displayName: "Codex", - sevenDay: 1, - thirtyDay: 2, - periodCost: 9, - periodTokens: 900, - currency: "USD", - source: "local", - includedInOverview: true, - }, - ], + providerId, + displayName, + sevenDay: 1, + thirtyDay: 2, + periodCost, + periodTokens, + currency, + source: "local", + includedInOverview: true, + }; +} + +function summary(reportingPeriod: string, costTrackingEnabled = true): UsageSpendSummary { + return { + rows: costTrackingEnabled + ? [row("codex", "Codex", 9, 900), row("claude", "Claude", 3.5, 2_100), row("zai", "z.ai", 4, 50, "CNY")] + : [], contract: contract(reportingPeriod), reportingPeriod, - reportingDay: "2026-09-29", + reportingDay: "2026-10-11", dashboardTimezone: "UTC", - } as unknown as UsageSpendSummary; + costTrackingEnabled, + }; } -const props = {} as TabProps; +function renderTab(settings: Partial = {}) { + const set = vi.fn(); + const props = { + settings: { costReportingPeriod: "rolling:7", spendDashboardPeriod: "month-to-date", ...settings } as SettingsSnapshot, + set, + saving: false, + } satisfies TabProps; + render(); + return set; +} function lastScanPeriod(): string | undefined { const calls = tauriMocks.getUsageSpendSummary.mock.calls; return calls[calls.length - 1]?.[0]?.period; } -describe("UsageSpendTab History window", () => { +function stat(label: string, scope: HTMLElement = document.body): string | null | undefined { + const term = within(scope).getAllByText(label, { selector: "dt" })[0]; + return term.nextElementSibling?.textContent; +} + +describe("UsageSpendTab", () => { 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"), ); + Object.assign(navigator, { clipboard: { writeText: vi.fn().mockResolvedValue(undefined) } }); }); - it("scans once with the saved window and labels the period with it", async () => { - render(); + it("scans the dashboard period, not the menu History window, and saves a new segment", async () => { + const set = renderTab(); await waitFor(() => expect(tauriMocks.getUsageSpendSummary).toHaveBeenCalledTimes(1)); - expect(tauriMocks.getUsageSpendSummary).toHaveBeenCalledWith({ - period: "month-to-date", - forceRefresh: false, - }); - // 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(); + expect(tauriMocks.getUsageSpendSummary).toHaveBeenCalledWith({ period: "month-to-date", forceRefresh: false }); + const range = screen.getByRole("radiogroup", { name: "Time range" }); + expect(within(range).getByRole("radio", { name: "Month to date" }).getAttribute("aria-checked")).toBe("true"); + + fireEvent.click(within(range).getByRole("radio", { name: "90d" })); + expect(set).toHaveBeenCalledWith({ spendDashboardPeriod: "rolling:90" }); }); - it("migrates a settings payload without a saved window to 30 days", async () => { - tauriMocks.getSettingsSnapshot.mockResolvedValue({ costSummaryDisplayStyle: "compact" }); - render(); + it("copies the menu History window when no dashboard period is saved", async () => { + renderTab({ spendDashboardPeriod: undefined, costReportingPeriod: "all" }); + await waitFor(() => expect(tauriMocks.getUsageSpendSummary).toHaveBeenCalled()); + expect(lastScanPeriod()).toBe("all"); + }); + it("keeps a legacy custom window as its own active segment", async () => { + renderTab({ spendDashboardPeriod: "rolling:14" }); await waitFor(() => expect(tauriMocks.getUsageSpendSummary).toHaveBeenCalled()); - expect(lastScanPeriod()).toBe("rolling:30"); + expect(lastScanPeriod()).toBe("rolling:14"); + const range = screen.getByRole("radiogroup", { name: "Time range" }); + expect(within(range).getAllByRole("radio")).toHaveLength(6); + expect(within(range).getByRole("radio", { name: "14d" }).getAttribute("aria-checked")).toBe("true"); }); - it("falls back to 30 days when the saved window is unreadable", async () => { - tauriMocks.getSettingsSnapshot.mockResolvedValue({ - costReportingPeriod: "rolling:0", - costSummaryDisplayStyle: "compact", - }); - render(); + it("sums the stat strip across providers per currency", async () => { + renderTab(); + await screen.findByText("USD"); + const usd = screen.getByText("USD").closest(".usage-spend__currency") as HTMLElement; + const cny = screen.getByText("CNY").closest(".usage-spend__currency") as HTMLElement; - await waitFor(() => expect(tauriMocks.getUsageSpendSummary).toHaveBeenCalled()); - expect(lastScanPeriod()).toBe("rolling:30"); + expect(stat("Estimated spend", usd)).toBe("$12.50"); + expect(stat("Tracked tokens", usd)).toBe("3K"); + expect(stat("Sources", usd)).toBe("2"); + expect(stat("Sources", cny)).toBe("1"); + }); + + it("leaves hidden sources out of the strip and out of Copy JSON", async () => { + const set = renderTab({ spendDashboardHiddenSourceIds: ["claude"] }); + await screen.findByText("USD"); + const usd = screen.getByText("USD").closest(".usage-spend__currency") as HTMLElement; + expect(stat("Estimated spend", usd)).toBe("$9.00"); + expect(stat("Sources", usd)).toBe("1"); + + // The hidden source stays in the filter, unchecked, so it can be shown again. + const claude = screen.getByRole("checkbox", { name: "Claude" }) as HTMLInputElement; + expect(claude.checked).toBe(false); + fireEvent.click(claude); + expect(set).toHaveBeenCalledWith({ spendDashboardHiddenSourceIds: [] }); + + fireEvent.click(screen.getByRole("button", { name: "Copy JSON" })); + await waitFor(() => expect(navigator.clipboard.writeText).toHaveBeenCalledTimes(1)); + const copied = JSON.parse(vi.mocked(navigator.clipboard.writeText).mock.calls[0][0]) as UsageSpendSummary; + expect(copied.rows.map((entry) => entry.providerId)).toEqual(["codex", "zai"]); + }); + + it("blanks the Codex detail when Codex is hidden", async () => { + renderTab({ spendDashboardHiddenSourceIds: ["codex"] }); + await screen.findByText("USD"); + expect(screen.queryByText("UsageSpendTokenInput")).toBeNull(); + + fireEvent.click(screen.getByRole("button", { name: "Copy JSON" })); + await waitFor(() => expect(navigator.clipboard.writeText).toHaveBeenCalled()); + const copied = JSON.parse(vi.mocked(navigator.clipboard.writeText).mock.calls[0][0]) as UsageSpendSummary; + expect(copied.rows.map((entry) => entry.providerId)).toEqual(["claude", "zai"]); + expect(copied.contract.models).toEqual([]); + expect(copied.contract.knownCostUsd).toBeNull(); + }); + + it("shows Cost tracking is off and keeps the switch to turn it back on", async () => { + tauriMocks.getUsageSpendSummary.mockImplementation(async (options?: { period?: string }) => + summary(options?.period ?? "rolling:30", false), + ); + const set = renderTab({ costUsageEnabled: false }); + + expect(await screen.findByText("Cost tracking is off")).toBeTruthy(); + expect(screen.queryByText("Estimated spend")).toBeNull(); + expect(screen.queryByRole("radiogroup", { name: "Time range" })).toBeNull(); + + const toggle = screen.getByRole("checkbox", { name: "Track costs" }) as HTMLInputElement; + expect(toggle.checked).toBe(false); + fireEvent.click(toggle); + expect(set).toHaveBeenCalledWith({ costUsageEnabled: true }); }); it("leaves the History window and Cost summary pickers to Settings > Menu", async () => { - render(); + renderTab(); await waitFor(() => expect(tauriMocks.getUsageSpendSummary).toHaveBeenCalledTimes(1)); expect(screen.queryByLabelText("CostPeriodHistoryWindow")).toBeNull(); expect(screen.queryByText("CostSummaryDisplayStyle")).toBeNull(); - expect(tauriMocks.updateSettings).not.toHaveBeenCalled(); }); }); diff --git a/apps/desktop-tauri/src/surfaces/settings/tabs/UsageSpendTab.tsx b/apps/desktop-tauri/src/surfaces/settings/tabs/UsageSpendTab.tsx index 4796a2035d..4e66247083 100644 --- a/apps/desktop-tauri/src/surfaces/settings/tabs/UsageSpendTab.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/tabs/UsageSpendTab.tsx @@ -1,63 +1,64 @@ -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { save } from "@tauri-apps/plugin-dialog"; import { listen, type UnlistenFn } from "@tauri-apps/api/event"; import { useLocale } from "../../../hooks/useLocale"; +import { Field, Toggle } from "../../../components/FormControls"; import { UsageSpendDailyLedger } from "../../../components/UsageSpendDailyLedger"; +import { getUsageSpendSummary, writeUsageSpendExport } from "../../../lib/tauri"; +import { formatUsd, shareUsageSpendPng } from "../../../lib/usageSpendSharing"; +import { buildSpendProviderRowGroups } from "../../../lib/usageSpendBreakdown"; import { - getSettingsSnapshot, - getUsageSpendSummary, - updateSettings, - writeUsageSpendExport, -} from "../../../lib/tauri"; -import { - formatSpendMetric, - formatUsd, - shareUsageSpendPng, -} from "../../../lib/usageSpendSharing"; -import { buildSpendProviderGroups, findSpendDay, formatSpendDay } from "../../../lib/usageSpendBreakdown"; + SPEND_DASHBOARD_PERIODS, + formatCompactTokens, + isContractHidden, + normalizeHiddenSourceIds, + spendSources, + spendTotalsByCurrency, + toggleHiddenSource, + visibleUsageSpendSummary, +} from "../../../lib/usageSpendDashboard"; import type { SpendContract, UsageSpendSummary } from "../../../types/bridge"; import type { LocaleKey } from "../../../i18n/keys"; import { - DEFAULT_COST_PERIOD, costPeriodLabel, + costPeriodShortLabel, normalizeCostPeriod, + parseCostPeriod, } from "../../../lib/costPeriod"; import type { TabProps } from "../settingsTabs"; import ProjectConversations from "./ProjectConversations"; import { UsageSpendProviderGroups } from "./UsageSpendProviderGroups"; import { UsageSpendTrendPanel } from "./UsageSpendTrendPanel"; -export default function UsageSpendTab(_props: TabProps) { +type Translate = (key: LocaleKey) => string; +type BreakdownView = "providers" | "projects" | "sessions"; + +/** + * Settings > Usage & Spend, laid out like the macOS pane: header and refresh, + * the time range, the per-currency stat strip, token mix and coverage, the + * Providers / Projects / Sessions picker, the trend and ledger, then the data + * controls (Track costs, OpenCodex, Sources, exports). + * + * Hidden sources only filter this view: every section and every export reads + * `visibleUsageSpendSummary`. + */ +export default function UsageSpendTab({ settings, set, saving }: TabProps) { const { t } = useLocale(); + const period = normalizeCostPeriod(settings.spendDashboardPeriod ?? settings.costReportingPeriod); + const trackCosts = settings.costUsageEnabled ?? true; + const includeOpenCodex = settings.openCodexUsageLogsEnabled ?? false; + const hideNativeCodex = settings.hideNativeCodexCostWhenOpenCodexPresent ?? false; + const hiddenKey = normalizeHiddenSourceIds(settings.spendDashboardHiddenSourceIds ?? []).join(","); + const hidden = useMemo(() => (hiddenKey ? hiddenKey.split(",") : []), [hiddenKey]); + const [summary, setSummary] = useState(null); - // 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 [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [shareError, setShareError] = useState(null); - const [includeOpenCodex, setIncludeOpenCodex] = useState(false); - const [hideNativeCodex, setHideNativeCodex] = useState(false); const [selectedDay, setSelectedDay] = useState(null); - const [showAllProjects, setShowAllProjects] = useState(false); - const [expandedProjects, setExpandedProjects] = useState>(() => new Set()); - const tableRef = useRef(null); - - useEffect(() => { - void getSettingsSnapshot() - .then((settings) => { - setIncludeOpenCodex(settings.openCodexUsageLogsEnabled ?? false); - setHideNativeCodex(settings.hideNativeCodexCostWhenOpenCodexPresent ?? false); - setPeriod(normalizeCostPeriod(settings.costReportingPeriod)); - }) - .catch(() => { - // Keep the safe defaults (off, 30 days) if settings cannot be loaded. - setPeriod(DEFAULT_COST_PERIOD); - }); - }, []); + const [view, setView] = useState("providers"); const load = useCallback(async (forceRefresh = false, silent = false) => { - if (period === null) return; if (!silent) { setLoading(true); setError(null); @@ -65,367 +66,423 @@ export default function UsageSpendTab(_props: TabProps) { try { const data = await getUsageSpendSummary({ period, forceRefresh }); setSummary(data); + if (silent) setError(null); } catch (err: unknown) { if (!silent) setError(err instanceof Error ? err.message : String(err)); } finally { if (!silent) setLoading(false); } - }, [hideNativeCodex, includeOpenCodex, period]); + // The OpenCodex flags and Track costs change what the backend scans. + }, [period, trackCosts, includeOpenCodex, hideNativeCodex]); useEffect(() => { - load(false); + void load(false); }, [load]); - // A selected day belongs to one History window; changing the window clears it. + // A selected day belongs to one time range; changing the range clears it. useEffect(() => { setSelectedDay(null); }, [period]); - // Upstream 0.55.0 #3106 parity: provider token/cost publications refresh - // Usage & Spend silently while the pane is open. Coalesce bursts so a - // multi-provider refresh does not trigger one dashboard rebuild per event. + // Upstream 0.55.0 #3106 parity: provider token/cost publications refresh the + // pane silently while it is open. A settings save (Track costs, OpenCodex) + // lands after the optimistic toggle, so rescan when it is persisted too. + // Bursts are coalesced into one rebuild. useEffect(() => { - let unlisten: UnlistenFn | null = null; + const unlisteners: UnlistenFn[] = []; let timer: ReturnType | null = null; let cancelled = false; - void listen("provider-updated", () => { + const schedule = () => { if (timer) clearTimeout(timer); timer = setTimeout(() => { if (!cancelled) void load(false, true); }, 200); - }).then((fn) => { - if (cancelled) fn(); - else unlisten = fn; - }).catch(() => {}); + }; + for (const event of ["provider-updated", "settings-changed"]) { + void listen(event, schedule) + .then((fn) => { + if (cancelled) fn(); + else unlisteners.push(fn); + }) + .catch(() => {}); + } return () => { cancelled = true; if (timer) clearTimeout(timer); - unlisten?.(); + for (const fn of unlisteners) fn(); }; }, [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 tracking = trackCosts && summary?.costTrackingEnabled !== false; + const visible = useMemo( + () => (summary && tracking ? visibleUsageSpendSummary(summary, hidden) : null), + [summary, tracking, hidden], + ); + const contractHidden = summary ? isContractHidden(summary, hidden) : false; + const periodTitle = costPeriodLabel(visible?.reportingPeriod ?? period, t); + + const exportJson = visible ? JSON.stringify(visible, null, 2) : null; const onShare = useCallback(() => { setShareError(null); - const error = shareUsageSpendPng( - summary, + const failure = shareUsageSpendPng( + visible, t("UsageSpendTitle"), - `codexbar-usage-spend-${summary?.reportingDay ?? "unknown"}.png`, + `codexbar-usage-spend-${visible?.reportingDay ?? "unknown"}.png`, periodTitle, ); - if (error) setShareError(t(error as LocaleKey)); - }, [summary, t, periodTitle]); + if (failure) setShareError(t(failure as LocaleKey)); + }, [visible, t, periodTitle]); const onCopyJson = useCallback(async () => { setShareError(null); - if (!summary) { + if (!exportJson) { setShareError(t("UsageSpendShareEmpty")); return; } try { - await navigator.clipboard.writeText(JSON.stringify(summary, null, 2)); + await navigator.clipboard.writeText(exportJson); } catch (cause: unknown) { setShareError(cause instanceof Error ? cause.message : String(cause)); } - }, [summary, t]); + }, [exportJson, t]); - const onSaveJson = useCallback(async () => { + const onExportJson = useCallback(async () => { setShareError(null); - if (!summary) { + if (!exportJson || !visible) { setShareError(t("UsageSpendShareEmpty")); return; } try { - const stamp = summary.reportingDay; const path = await save({ - defaultPath: `codexbar-usage-spend-${stamp}.json`, + defaultPath: `codexbar-usage-spend-${visible.reportingDay}.json`, filters: [{ name: "JSON", extensions: ["json"] }], }); if (!path) return; - await writeUsageSpendExport(path, JSON.stringify(summary, null, 2)); + await writeUsageSpendExport(path, exportJson); } catch (cause: unknown) { setShareError(cause instanceof Error ? cause.message : String(cause)); } - }, [summary, t]); + }, [exportJson, visible, t]); - return ( -
-

- {t("UsageSpendTitle")} -

-

{t("UsageSpendCaption")}

+ const sources = spendSources(summary, hidden); + const exportsDisabled = loading || !visible; -
- - - + return ( +
+
+
+

{t("UsageSpendTitle")}

+

{t("UsageSpendCaption")}

+
-
- - {includeOpenCodex && ( - - )} -
- - {error &&

{error}

} - {shareError &&

{shareError}

} - - {!error && ( - - - - - - - - - - - - - {(summary?.rows ?? []).map((row) => ( - - - - - - - - - ))} - {!loading && (summary?.rows?.length ?? 0) === 0 && ( - - - - )} - -
{t("UsageSpendColProvider")}{periodTitle}{t("UsageSpendCol7d")}{t("UsageSpendCol30d")}{t("UsageSpendColCurrency")}{t("UsageSpendColSource")}
{row.displayName} - {formatSpendMetric( - row.periodCost, - row.periodTokens, - row.currency, - t("UsageSpendTokens"), - )} - - {formatSpendMetric( - row.sevenDay, - row.sevenDayTokens, - row.currency, - t("UsageSpendTokens"), - row.sevenDayEstimate?.knownSubtotalUsd, - t("UsageSpendKnownSubtotal"), - row.sevenDayTokensLowerBound, - )} - - {formatSpendMetric( - row.thirtyDay, - row.thirtyDayTokens, - row.currency, - t("UsageSpendTokens"), - row.thirtyDayEstimate?.knownSubtotalUsd, - t("UsageSpendKnownSubtotal"), - row.thirtyDayTokensLowerBound, - )} - {row.currency || "USD"} - {row.source} - {row.refreshing && ( - - {" · "}{t("UsageSpendRefreshing")} - - )} -
{t("UsageSpendEmpty")}
+ {trackCosts && ( + set({ spendDashboardPeriod: next })} t={t} /> )} - {!error && summary && } - - {!error && summary && ( - + {error && ( +
+ {t("UsageSpendRefreshFailures")} + {error} +
)} - {!error && summary && } + {!tracking ? ( +
+ {t("UsageSpendTrackingOffTitle")} +

{t("UsageSpendTrackingOffBody")}

+
+ ) : ( + visible && ( + + ) + )} - {!error && summary && ( - row.providerId === summary.contract.providerId)?.displayName - ?? summary.contract.providerId, +
+ {t("UsageSpendDataControls")} +
+ + set({ costUsageEnabled: value })} + /> + + + set({ openCodexUsageLogsEnabled: value })} + /> + + {includeOpenCodex && ( + + set({ hideNativeCodexCostWhenOpenCodexPresent: value })} + /> + )} - caption={t("UsageSpendModelsPeriodCaption").replace("{}", costPeriodLabel(summary.contract.reportingPeriod, t))} - t={t} - /> - )} + {sources.length > 0 && ( +
+ {t("UsageSpendSources")} + {sources.map((source) => ( + + set({ spendDashboardHiddenSourceIds: toggleHiddenSource(hidden, source.id, show) }) + } + /> + ))} +
+ )} +
+ + + +
+ {shareError &&

{shareError}

} +
+
+
+ ); +} - {!error && summary && ( - setShowAllProjects((value) => !value)} - t={t} - onToggleProject={(id) => { - setExpandedProjects((current) => { - const next = new Set(current); - if (next.has(id)) next.delete(id); - else next.add(id); - return next; - }); +/** The macOS 7d / 30d / 90d / Month to date / All segments; a legacy custom window gets its own segment. */ +function PeriodPicker({ + value, + disabled, + onChange, + t, +}: { + value: string; + disabled: boolean; + onChange: (period: string) => void; + t: Translate; +}) { + const options: string[] = [...SPEND_DASHBOARD_PERIODS]; + if (!options.includes(value)) options.push(value); + const label = (raw: string) => + parseCostPeriod(raw)?.kind === "monthToDate" ? t("CostPeriodMonthToDate") : costPeriodShortLabel(raw, t); + return ( +
+ {options.map((option) => ( +
+ > + {label(option)} + + ))} +
); } +function DashboardBody({ + summary, + contractHidden, + loading, + view, + onView, + selectedDay, + onSelectDay, + t, +}: { + summary: UsageSpendSummary; + contractHidden: boolean; + loading: boolean; + view: BreakdownView; + onView: (view: BreakdownView) => void; + selectedDay: string | null; + onSelectDay: (day: string | null) => void; + t: Translate; +}) { + const totals = spendTotalsByCurrency(summary.rows); + const contract = summary.contract; + const views: { id: BreakdownView; label: string }[] = [ + { id: "providers", label: t("UsageSpendViewProviders") }, + { id: "projects", label: t("UsageSpendProjects") }, + { id: "sessions", label: t("UsageSpendViewSessions") }, + ]; + return ( + <> + {totals.length === 0 && !loading &&

{t("UsageSpendEmpty")}

} + {totals.map((entry) => ( +
+

{entry.currency}

+

{t("UsageSpendLocalHistory")}

+
+ + + +
+
+ ))} -function SpendContractOverview({ contract, selectedDay, t }: { contract: SpendContract; selectedDay: string | null; t: (key: LocaleKey) => string }) { - const day = findSpendDay(contract.daily, selectedDay); - const coverage = contract.priceCoverageRatio == null - ? t("UsageSpendUnknown") - : `${Math.round(contract.priceCoverageRatio * 100)}%`; - const provenance = contract.provenance === "listPriceEstimate" - ? t("UsageSpendApiEstimate") - : contract.provenance === "vendorMetered" - ? t("UsageSpendVendorMetered") - : contract.provenance === "mixed" - ? t("UsageSpendMixedSources") - : t("UsageSpendUnknown"); - const total = contract.knownCostUsd == null - ? "—" - : `${contract.priceCoverage.unpriced > 0 ? "~" : ""}${formatUsd(contract.knownCostUsd, "USD")}`; - const tokenParts = [ - contract.tokenMix.inputTokens == null ? null : `${contract.tokenMix.inputTokens.toLocaleString()} input`, - contract.tokenMix.outputTokens == null ? null : `${contract.tokenMix.outputTokens.toLocaleString()} output`, - contract.tokenMix.cacheReadTokens == null ? null : `${contract.tokenMix.cacheReadTokens.toLocaleString()} cached`, - ].filter(Boolean).join(" · "); + {!contractHidden && } + {!contractHidden && } - return ( -
-
- {day ? ( - +
+ {views.map((option) => ( + + ))} +
+ {view === "providers" && ( + - ) : ( - )} - - - + {view === "projects" && } + {view === "sessions" && + (contract.conversations.length === 0 ? ( +

{t("UsageSpendNoSessions")}

+ ) : ( + + ))}
- {contract.imports.map((source) => ( -

- {source.displayName}: {source.requestCount.toLocaleString()} {t("UsageSpendRequests")} · {source.conversationCount.toLocaleString()} {t("UsageSpendConversations").toLowerCase()} -

- ))} -
+ + {!contractHidden && ( + + )} + {!contractHidden && } + ); } -function MetricCard({ label, value, detail }: { label: string; value: string; detail: string }) { +function Stat({ label, value, large }: { label: string; value: string; large?: boolean }) { return ( -
- {label} - {value} - {detail} +
+
{label}
+
{value}
); } -function ProjectsPanel({ - contract, - showAll, - expanded, - onToggleAll, - onToggleProject, - t, -}: { - contract: SpendContract; - showAll: boolean; - expanded: Set; - onToggleAll: () => void; - onToggleProject: (id: string) => void; - t: (key: LocaleKey) => string; -}) { +/** Five-cell token mix for the detail contract; a source that never records a kind shows "—". */ +function TokenMix({ contract, t }: { contract: SpendContract; t: Translate }) { + const mix = contract.tokenMix; + const cells: [LocaleKey, number | null][] = [ + ["UsageSpendTokenInput", mix.inputTokens], + ["UsageSpendTokenOutput", mix.outputTokens], + ["UsageSpendTokenCacheRead", mix.cacheReadTokens], + ["UsageSpendTokenCacheWrite", mix.cacheCreationTokens], + ["UsageSpendTokenReasoning", mix.reasoningTokens], + ]; + if (cells.every(([, value]) => value == null)) return null; + return ( +
+ {cells.map(([key, value]) => ( + + ))} +
+ ); +} + +function CoverageLine({ contract, t }: { contract: SpendContract; t: Translate }) { + const coverage = contract.priceCoverage; + const counted = coverage.priced + coverage.unpriced + coverage.unmetered + coverage.estimated; + if (counted === 0 && contract.provenance === "unknown") return null; + const provenance = contract.provenance === "listPriceEstimate" + ? t("UsageSpendApiEstimate") + : contract.provenance === "vendorMetered" + ? t("UsageSpendVendorMetered") + : contract.provenance === "mixed" + ? t("UsageSpendMixedSources") + : t("UsageSpendUnknown"); + const parts = [ + `${t("UsageSpendPriced")} ${coverage.priced}`, + `${t("UsageSpendUnpricedCount")} ${coverage.unpriced}`, + `${t("UsageSpendUnmetered")} ${coverage.unmetered}`, + `${t("UsageSpendEstimated")} ${coverage.estimated}`, + provenance, + ]; + if (contract.customPricingActive) parts.push(t("UsageSpendCustomPricingActive")); + return

{parts.join(" · ")}

; +} + +function ProjectsPanel({ contract, t }: { contract: SpendContract; t: Translate }) { + const [showAll, setShowAll] = useState(false); + const [expanded, setExpanded] = useState>(() => new Set()); const projects = showAll ? contract.projects : contract.projects.slice(0, 8); const partial = contract.projectSourceStatus != null && contract.projectSourceStatus !== "complete"; return ( -
+
-
-

{t("UsageSpendProjects")}

-

- {t("UsageSpendProjectsPeriodCaption").replace("{}", costPeriodLabel(contract.reportingPeriod, t))} - {partial ? ` · ${t("UsageSpendPartialHistory")}` : ""}. -

-
+

+ {t("UsageSpendProjectsPeriodCaption").replace("{}", costPeriodLabel(contract.reportingPeriod, t))} + {partial ? ` · ${t("UsageSpendPartialHistory")}` : ""}. +

{contract.projects.length > 8 && ( - )} @@ -442,21 +499,16 @@ function ProjectsPanel({