From ab5cad9c5500cd8665e71497cfe869a3f8683373 Mon Sep 17 00:00:00 2001 From: Factory Agent Date: Fri, 23 Oct 2026 15:24:29 +0700 Subject: [PATCH 1/2] Keep calendar days in Muse, OpenAI API, and Groq charts Idle days in the 30-day token and cost charts were dropped for Muse, OpenAI API, and Groq, so the bars skipped days with no usage. Give every day of the window a zero slot when the fetch returned data. Empty results stay empty, and unpriced days stay unknown. OpenAI and Groq history keep their UTC and local-date stamps. Upstream: steipete/CodexBar 54164d4c3 (#4689). --- CHANGELOG.md | 1 + .../src-tauri/src/commands/chart.rs | 11 +-- rust/src/core/usage_snapshot.rs | 3 +- rust/src/providers/groq/console.rs | 19 ++++- rust/src/providers/groq/console_tests.rs | 39 ++++++++++ rust/src/providers/muse/local_usage/mod.rs | 75 ++++++++++++++++++- rust/src/providers/openaiapi/history.rs | 38 ++++++++++ rust/src/providers/openaiapi/history_tests.rs | 39 +++++++++- rust/src/providers/openaiapi/mod.rs | 11 ++- rust/src/providers/openaiapi/tests.rs | 11 ++- 10 files changed, 232 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ac06238ab6..c8d15087dc9 100755 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ ## [Windows] Unreleased +- Charts: the Muse token chart, the OpenAI API daily history and the Groq console history keep every day of the 30-day window, so idle days show as zero bars instead of disappearing. Unpriced or incomplete days stay unknown (upstream #4689). - Overview: disabling a provider, in Settings or from the tray menu, removes its card and account rows at once instead of after a relaunch. claude-swap and tcr Overview rows now fill from a background read during refresh (at most every 5 minutes), so you no longer need to open Settings first. - **Claude**: built-in price rows choose one long-context tier per request from input plus all cache tokens and bill the whole request at that tier, as upstream does. Opus 4.6 and Sonnet 4.6 use long-context rates only for usage dated before the upstream cutoff. - Tray and Settings: percentages always say "left" or "used" and follow **Show usage as used** (the tray tooltip, tray status rows, the Claude accounts menu and the claude-swap/tcr list). "1 minute ago" is singular in every language. Under **Hide personal information**, Claude and Grok accounts are numbered in list order, and the claude-swap list masks emails as soon as the setting changes. diff --git a/apps/desktop-tauri/src-tauri/src/commands/chart.rs b/apps/desktop-tauri/src-tauri/src/commands/chart.rs index 05a4cb7f8cc..054dab81373 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/chart.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/chart.rs @@ -288,13 +288,10 @@ fn build_provider_chart_data_with_cancel( (Vec::new(), Vec::new(), true, None) } else { let report = muse_local_usage::scan(30, cancel.as_deref()); - let tokens_history = report - .daily - .iter() - .map(|day| DailyTokenPoint { - date: day.day.clone(), - tokens: day.total_tokens, - }) + // One slot per calendar day, so idle days show as zero bars. + let tokens_history = muse_local_usage::token_history_slots(&report.daily, 30) + .into_iter() + .map(|(date, tokens)| DailyTokenPoint { date, tokens }) .collect(); let period = current_reporting_period(); let period_report = muse_period_report(period, &report, cancel.as_deref()); diff --git a/rust/src/core/usage_snapshot.rs b/rust/src/core/usage_snapshot.rs index 48188d5951f..4fd80f5df32 100755 --- a/rust/src/core/usage_snapshot.rs +++ b/rust/src/core/usage_snapshot.rs @@ -110,7 +110,8 @@ pub struct OpenAiApiUsageHistory { pub history_days: u32, /// Project the Admin queries were scoped to, when one is configured. pub project_id: Option, - /// One bucket per UTC day that has data, ascending by `start_time`. + /// One bucket per UTC day in the window, ascending by `start_time`. A day with no + /// usage is a zero bucket once the fetch that produced the history has data. pub daily: Vec, } diff --git a/rust/src/providers/groq/console.rs b/rust/src/providers/groq/console.rs index 0b2a070cffe..7e61351f436 100644 --- a/rust/src/providers/groq/console.rs +++ b/rust/src/providers/groq/console.rs @@ -18,6 +18,7 @@ use crate::core::{ CostSnapshot, OpenAiApiDailyUsage, OpenAiApiModelUsage, OpenAiApiUsageHistory, ProviderDisplayDetail, ProviderError, ProviderFetchResult, RateWindow, UsageSnapshot, }; +use crate::providers::openaiapi::history::fill_missing_days; pub(super) const SESSION_COOKIE: &str = "stytch_session"; pub(super) const JWT_COOKIE: &str = "stytch_session_jwt"; @@ -229,7 +230,12 @@ async fn fetch_usage( let url = activity_url(&endpoints.api_base, &org, start, end)?; let rows = fetch_activity(client, url, &jwt).await?; let organization = organization_name(&rows); - let daily = daily_usage(&rows, &local); + let mut daily = daily_usage(&rows, &local); + // The activity fetch covered the whole window, so a local day with no rows is zero. + fill_missing_days( + &mut daily, + history_day_starts(&now.with_timezone(&local), HISTORY_DAYS), + ); Ok(build_result(daily, organization, now)) } @@ -393,6 +399,17 @@ pub(super) fn activity_url( Ok(url) } +/// UTC-midnight stamps of the last `days` local calendar days, today included, newest +/// first. Groq stamps each local day at UTC midnight of its local date, so these +/// match the daily buckets. +pub(super) fn history_day_starts(now: &DateTime, days: u32) -> Vec { + let today = now.date_naive(); + (0..days.clamp(1, 365)) + .filter_map(|ago| today.checked_sub_days(Days::new(u64::from(ago)))) + .map(|day| day.and_time(NaiveTime::MIN).and_utc().timestamp()) + .collect() +} + /// Unix seconds bounding the last `days` local calendar days, today included. pub(super) fn history_window(now: &DateTime, days: u32) -> (i64, i64) { let tz = now.timezone(); diff --git a/rust/src/providers/groq/console_tests.rs b/rust/src/providers/groq/console_tests.rs index d44cb8189e7..9793982f238 100644 --- a/rust/src/providers/groq/console_tests.rs +++ b/rust/src/providers/groq/console_tests.rs @@ -10,6 +10,7 @@ use super::console::{ parse_activity, }; use crate::core::{FetchContext, ProviderError, ProviderFetchResult, SourceMode}; +use crate::providers::openaiapi::history::fill_missing_days; const ACTIVITY: &str = include_str!("fixtures/activity.json"); /// The pack's unsigned session JWT: org `org_parity_0008`. @@ -263,6 +264,44 @@ fn days_are_bucketed_by_local_date_and_labelled_with_it() { assert_eq!(days, vec![(oct9, oct10, 1), (oct10, oct10 + 86_400, 2)]); } +#[test] +fn groq_history_keeps_every_local_day_of_the_window_in_a_non_utc_zone() { + // Tokyo is ahead of UTC, so its local dates and UTC days differ; each day is + // stamped at UTC midnight of its local date and the idle days stay as zero slots. + let tokyo = chrono::FixedOffset::east_opt(9 * 3600).unwrap(); + let now_local = now().with_timezone(&tokyo); + let today = now_local.date_naive(); + let used_day = today.checked_sub_days(chrono::Days::new(3)).unwrap(); + let used = used_day.and_hms_opt(0, 0, 0).unwrap().and_utc().timestamp(); + let mut daily = vec![crate::core::OpenAiApiDailyUsage { + start_time: used, + end_time: used + 86_400, + cost_usd: 1.0, + requests: 1, + input_tokens: 1, + cached_input_tokens: 0, + output_tokens: 0, + total_tokens: 1, + line_items: Vec::new(), + models: Vec::new(), + }]; + + fill_missing_days(&mut daily, console::history_day_starts(&now_local, 30)); + + assert_eq!(daily.len(), 30); + assert_eq!(daily.iter().filter(|day| day.total_tokens > 0).count(), 1); + assert_eq!( + daily[daily.len() - 1].start_time, + today.and_hms_opt(0, 0, 0).unwrap().and_utc().timestamp() + ); + assert!( + daily + .windows(2) + .all(|pair| pair[0].start_time < pair[1].start_time) + ); + assert!(daily.iter().all(|day| day.start_time % 86_400 == 0)); +} + #[test] fn history_window_covers_thirty_local_days_including_today() { assert_eq!( diff --git a/rust/src/providers/muse/local_usage/mod.rs b/rust/src/providers/muse/local_usage/mod.rs index 06b392eae31..c3ec8aac329 100644 --- a/rust/src/providers/muse/local_usage/mod.rs +++ b/rust/src/providers/muse/local_usage/mod.rs @@ -18,7 +18,7 @@ use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, Ordering}; use std::time::{Duration, SystemTime, UNIX_EPOCH}; -use chrono::{TimeZone, Utc}; +use chrono::{NaiveDate, TimeZone, Utc}; use serde::{Deserialize, Serialize}; use serde_json::Value; use sha2::{Digest, Sha256}; @@ -156,6 +156,32 @@ fn cache_root() -> PathBuf { .join("CodexBar") } +/// Daily token totals for the last `days` calendar days, ascending, with a zero +/// slot for each day that has no events. An empty report stays empty, so the chart +/// keeps its empty state when no local history exists. +pub fn token_history_slots(daily: &[DailyUsage], days: u32) -> Vec<(String, u64)> { + let end = crate::cost_reporting_period::cost_bucket_zone().date(Utc::now()); + fill_token_slots(daily, end, days) +} + +fn fill_token_slots(daily: &[DailyUsage], end: NaiveDate, days: u32) -> Vec<(String, u64)> { + if daily.is_empty() { + return Vec::new(); + } + let tokens_by_day: HashMap<&str, u64> = daily + .iter() + .map(|day| (day.day.as_str(), day.total_tokens)) + .collect(); + (0..days) + .rev() + .map(|days_ago| { + let day = (end - chrono::Duration::days(i64::from(days_ago))).to_string(); + let tokens = tokens_by_day.get(day.as_str()).copied().unwrap_or(0); + (day, tokens) + }) + .collect() +} + fn day_window(days: u32) -> (String, String) { let days = crate::cost_reporting_period::clamp_window_days(days); let today = crate::cost_reporting_period::cost_bucket_zone().date(Utc::now()); @@ -509,3 +535,50 @@ pub fn scan_in( coverage, } } + +#[cfg(test)] +mod slot_tests { + use super::*; + + fn day(day: &str, total_tokens: u64) -> DailyUsage { + DailyUsage { + day: day.to_string(), + input_tokens: 0, + output_tokens: 0, + cache_read_tokens: 0, + cache_write_tokens: 0, + reasoning_tokens: 0, + total_tokens, + request_count: 1, + models: Vec::new(), + } + } + + #[test] + fn token_slots_keep_every_calendar_day_with_zero_for_idle_days() { + let end = NaiveDate::from_ymd_opt(2026, 9, 10).unwrap(); + let daily = [day("2026-09-05", 100), day("2026-09-08", 50)]; + + let slots = fill_token_slots(&daily, end, 7); + + assert_eq!( + slots, + vec![ + ("2026-09-04".to_string(), 0), + ("2026-09-05".to_string(), 100), + ("2026-09-06".to_string(), 0), + ("2026-09-07".to_string(), 0), + ("2026-09-08".to_string(), 50), + ("2026-09-09".to_string(), 0), + ("2026-09-10".to_string(), 0), + ] + ); + } + + #[test] + fn token_slots_stay_empty_without_local_history() { + let end = NaiveDate::from_ymd_opt(2026, 9, 10).unwrap(); + + assert!(fill_token_slots(&[], end, 30).is_empty()); + } +} diff --git a/rust/src/providers/openaiapi/history.rs b/rust/src/providers/openaiapi/history.rs index 0d485146499..55e3c109529 100644 --- a/rust/src/providers/openaiapi/history.rs +++ b/rust/src/providers/openaiapi/history.rs @@ -17,6 +17,7 @@ use crate::core::{ const MAX_CARD_ENTRIES: usize = 10_000; /// Largest integer a JavaScript number holds exactly; upstream rejects anything above it. const MAX_SAFE_COUNT: u64 = 9_007_199_254_740_991; +const SECONDS_PER_DAY: i64 = 86_400; const DEFAULT_LINE_ITEM: &str = "API"; const DEFAULT_MODEL: &str = "Responses and Chat Completions"; @@ -164,6 +165,43 @@ pub(super) fn daily_usage( Ok(daily) } +/// Add a zero bucket for every day in `day_starts` that has no bucket yet, then keep +/// the list ascending. Days match by UTC day, so a bucket that starts mid-day still +/// counts as its day. An empty `daily` stays empty: the chart keeps its empty state +/// when the API reported nothing at all, and only a fetch with data proves the +/// missing days are zero. +pub(crate) fn fill_missing_days( + daily: &mut Vec, + day_starts: impl IntoIterator, +) { + if daily.is_empty() { + return; + } + for start in day_starts { + let day = start.div_euclid(SECONDS_PER_DAY); + if daily + .iter() + .any(|existing| existing.start_time.div_euclid(SECONDS_PER_DAY) == day) + { + continue; + } + let start_time = day * SECONDS_PER_DAY; + daily.push(OpenAiApiDailyUsage { + start_time, + end_time: start_time + SECONDS_PER_DAY, + cost_usd: 0.0, + requests: 0, + input_tokens: 0, + cached_input_tokens: 0, + output_tokens: 0, + total_tokens: 0, + line_items: Vec::new(), + models: Vec::new(), + }); + } + daily.sort_by_key(|day| day.start_time); +} + /// The card payload for `daily`, or `None` when it breaks an upstream card bound (a day /// that does not end after it starts, or more than [`MAX_CARD_ENTRIES`] breakdown rows). /// The chart is auxiliary, so an out-of-bounds history is dropped and the spend summary diff --git a/rust/src/providers/openaiapi/history_tests.rs b/rust/src/providers/openaiapi/history_tests.rs index fd07e7653ce..89a43ec2377 100644 --- a/rust/src/providers/openaiapi/history_tests.rs +++ b/rust/src/providers/openaiapi/history_tests.rs @@ -95,6 +95,34 @@ fn daily(costs: &[CostBucket], completions: &[CompletionsUsageBucket]) -> Vec = buckets.iter().map(|day| day.start_time).collect(); + assert_eq!(starts, vec![NOW - 2 * DAY, day_one + 3_600, NOW]); + assert_eq!(buckets[0].end_time, NOW - DAY); + assert_eq!(buckets[0].cost_usd, 0.0); + assert_eq!(buckets[0].total_tokens, 0); + assert!(buckets[0].models.is_empty()); + assert_eq!(buckets[1].cost_usd, 1.0); + assert_eq!(buckets[2].cost_usd, 0.0); +} + +#[test] +fn fill_missing_days_keeps_an_empty_history_empty() { + let mut buckets: Vec = Vec::new(); + + history::fill_missing_days(&mut buckets, [NOW - DAY, NOW]); + + assert!(buckets.is_empty()); +} + #[test] fn upstream_fixture_buckets_into_two_days() { let daily = daily(&upstream_costs(), &upstream_completions()); @@ -150,7 +178,16 @@ fn upstream_fixture_result_carries_history_and_summary() { let history = result.open_ai_api_usage.as_ref().unwrap(); assert_eq!(history.history_days, 30); assert_eq!(history.project_id.as_deref(), Some("proj_abc")); - assert_eq!(history.daily.len(), 2); + // Two fixture days carry usage; the other 28 days of the window are zero slots. + assert_eq!(history.daily.len(), 30); + assert_eq!( + history + .daily + .iter() + .filter(|day| day.total_tokens > 0) + .count(), + 2 + ); assert_eq!(result.cost.as_ref().unwrap().used, 18.75); assert_eq!(result.cost.as_ref().unwrap().period, "Last 30 days"); } diff --git a/rust/src/providers/openaiapi/mod.rs b/rust/src/providers/openaiapi/mod.rs index 4e923dd2e93..f8bddca572a 100644 --- a/rust/src/providers/openaiapi/mod.rs +++ b/rust/src/providers/openaiapi/mod.rs @@ -33,7 +33,7 @@ use serde::Deserialize; use std::collections::{HashMap, HashSet}; use std::time::Duration; -mod history; +pub(crate) mod history; pub mod live; pub mod org_limits; @@ -479,7 +479,7 @@ fn result_from_admin_usage( now: DateTime, project_id: Option<&str>, ) -> Result { - let daily = history::daily_usage(costs, completions, now, HISTORY_DAYS)?; + let mut daily = history::daily_usage(costs, completions, now, HISTORY_DAYS)?; let cost_total: f64 = daily.iter().map(|day| day.cost_usd).sum(); let request_total: u128 = daily.iter().map(|day| u128::from(day.requests)).sum(); @@ -550,6 +550,13 @@ fn result_from_admin_usage( "USD", format!("Last {HISTORY_DAYS} days"), )); + // Every UTC day of the window gets a slot: a day without a bucket is zero, since + // the fetch above returned data for the whole window (upstream keeps calendar days). + let today_start = now.timestamp().div_euclid(SECONDS_PER_DAY) * SECONDS_PER_DAY; + history::fill_missing_days( + &mut daily, + (0..i64::from(HISTORY_DAYS)).map(|ago| today_start - ago * SECONDS_PER_DAY), + ); if let Some(history) = history::usage_history(daily, HISTORY_DAYS, project_id) { result = result.with_open_ai_api_usage(history); } diff --git a/rust/src/providers/openaiapi/tests.rs b/rust/src/providers/openaiapi/tests.rs index 5e9648f827d..cfed5f96134 100644 --- a/rust/src/providers/openaiapi/tests.rs +++ b/rust/src/providers/openaiapi/tests.rs @@ -440,8 +440,15 @@ async fn openai_admin_usage_returns_per_day_history_from_the_wire_pages() { .expect("Admin path returns history"); assert_eq!(history.history_days, 30); assert_eq!(history.project_id.as_deref(), Some("proj_abc")); - assert_eq!(history.daily.len(), 1); - let bucket = &history.daily[0]; + // One slot per UTC day of the 30-day window; only the bucket's day has usage. + assert_eq!(history.daily.len(), 30); + let with_usage: Vec<_> = history + .daily + .iter() + .filter(|d| d.total_tokens > 0) + .collect(); + assert_eq!(with_usage.len(), 1); + let bucket = with_usage[0]; assert_eq!((bucket.start_time, bucket.end_time), (day, day + 86_400)); assert_eq!(bucket.cost_usd, 2.5); assert_eq!(bucket.requests, 4); From 5f8fb488e6caaa78f2765f2245239209e7777939 Mon Sep 17 00:00:00 2001 From: Factory Agent Date: Sat, 24 Oct 2026 01:24:39 +0700 Subject: [PATCH 2/2] Pad Muse token history only for complete scans --- .../src-tauri/src/commands/chart.rs | 3 +- rust/src/providers/muse/local_usage/mod.rs | 45 +++++++++++++++---- 2 files changed, 38 insertions(+), 10 deletions(-) diff --git a/apps/desktop-tauri/src-tauri/src/commands/chart.rs b/apps/desktop-tauri/src-tauri/src/commands/chart.rs index 054dab81373..aa05d6e904c 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/chart.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/chart.rs @@ -288,8 +288,7 @@ fn build_provider_chart_data_with_cancel( (Vec::new(), Vec::new(), true, None) } else { let report = muse_local_usage::scan(30, cancel.as_deref()); - // One slot per calendar day, so idle days show as zero bars. - let tokens_history = muse_local_usage::token_history_slots(&report.daily, 30) + let tokens_history = muse_local_usage::token_history_slots(&report, 30) .into_iter() .map(|(date, tokens)| DailyTokenPoint { date, tokens }) .collect(); diff --git a/rust/src/providers/muse/local_usage/mod.rs b/rust/src/providers/muse/local_usage/mod.rs index c3ec8aac329..bbcce7479af 100644 --- a/rust/src/providers/muse/local_usage/mod.rs +++ b/rust/src/providers/muse/local_usage/mod.rs @@ -156,15 +156,28 @@ fn cache_root() -> PathBuf { .join("CodexBar") } -/// Daily token totals for the last `days` calendar days, ascending, with a zero -/// slot for each day that has no events. An empty report stays empty, so the chart -/// keeps its empty state when no local history exists. -pub fn token_history_slots(daily: &[DailyUsage], days: u32) -> Vec<(String, u64)> { +/// Daily token totals for the last `days` calendar days, ascending. A complete +/// scan gets a zero slot for each day that has no events. A partial scan keeps +/// only the reported days, because an unscanned day is unknown, not idle. An +/// empty report stays empty, so the chart keeps its empty state when no local +/// history exists. +pub fn token_history_slots(report: &Report, days: u32) -> Vec<(String, u64)> { let end = crate::cost_reporting_period::cost_bucket_zone().date(Utc::now()); - fill_token_slots(daily, end, days) + fill_token_slots(&report.daily, report.is_complete(), end, days) } -fn fill_token_slots(daily: &[DailyUsage], end: NaiveDate, days: u32) -> Vec<(String, u64)> { +fn fill_token_slots( + daily: &[DailyUsage], + complete: bool, + end: NaiveDate, + days: u32, +) -> Vec<(String, u64)> { + if !complete { + return daily + .iter() + .map(|day| (day.day.clone(), day.total_tokens)) + .collect(); + } if daily.is_empty() { return Vec::new(); } @@ -559,7 +572,7 @@ mod slot_tests { let end = NaiveDate::from_ymd_opt(2026, 9, 10).unwrap(); let daily = [day("2026-09-05", 100), day("2026-09-08", 50)]; - let slots = fill_token_slots(&daily, end, 7); + let slots = fill_token_slots(&daily, true, end, 7); assert_eq!( slots, @@ -579,6 +592,22 @@ mod slot_tests { fn token_slots_stay_empty_without_local_history() { let end = NaiveDate::from_ymd_opt(2026, 9, 10).unwrap(); - assert!(fill_token_slots(&[], end, 30).is_empty()); + assert!(fill_token_slots(&[], true, end, 30).is_empty()); + } + + #[test] + fn partial_scan_keeps_only_reported_days() { + let end = NaiveDate::from_ymd_opt(2026, 9, 10).unwrap(); + let daily = [day("2026-09-05", 100), day("2026-09-08", 50)]; + + let slots = fill_token_slots(&daily, false, end, 7); + + assert_eq!( + slots, + vec![ + ("2026-09-05".to_string(), 100), + ("2026-09-08".to_string(), 50), + ] + ); } }